Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions full-ai-cluster/usb-nixos-installer/zeta-install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1015,6 +1015,11 @@ sudo nixos-install \
cleanup_symlinks
trap - EXIT

# ── Step 6.94: B-0852.3a cred-picker stub ───────────────────────────
# The actual picker invocation lives at Step 6.95-picker (below) which
# fires AFTER 6.95a-bootstrap clones the repo + installs bun. This
# header reserves the step number for forward references; no work here.

# ── Step 6.95: iter-5.5.0 — claude-code install + credential persistence (B-0848 Phase 2) ──
# Aaron 2026-05-27 ask: "wanna make this automatic on boot before i even
# login and have it save my claude code device login like gh, also make
Expand Down Expand Up @@ -1135,6 +1140,37 @@ if [ -d "$ZETA_HOME" ]; then
bash -c 'set -o pipefail; eval "$(mise activate bash 2>/dev/null || true)"; bun install --global @openai/codex 2>&1 | tail -5' || \
echo "[iter-5.5.0] WARN: bun install codex FAILED — can retry post-reboot via 'bun install --global @openai/codex'"

# 6.95-picker — B-0852.3a cred-picker (operator interactive at setup time)
# Operator 2026-05-27 framing: "human interactive at setup time" + "ask what declared
# creds you want to bake in vs go through device flow".
#
# Runs AFTER 6.95a-bootstrap (repo + bun + mise present) and BEFORE 6.95b-* device-flow
# logins so picker decides per-cred bake-vs-defer + the device-flow steps handle the
# deferred subset.
#
# Default: SKIP (backward compat with automated installs). Opt-in via
# ZETA_CREDS_PICKER=1 + ZETA_CREDS_PASSPHRASE + /etc/zeta/usb-uuid.
#
# SECURITY (Copilot review on PR #5450): the passphrase is FORWARDED VIA SUDO
# --preserve-env=ZETA_CREDS_PASSPHRASE, NOT inlined in bash -c arg-string (the
# latter leaked the literal passphrase into the process arglist visible to ps).
# The picker reads it via --passphrase-env which references the env-var-NAME only.
if [ -n "${ZETA_CREDS_PICKER:-}" ] && [ "$ZETA_CREDS_PICKER" = "1" ] && \
[ -f /etc/zeta/usb-uuid ] && [ -n "${ZETA_CREDS_PASSPHRASE:-}" ]; then
USB_UUID="$(cat /etc/zeta/usb-uuid)"
echo "[iter-5.5.0] ── 6.95-picker: B-0852.3a cred-picker (operator interactive) ──"
# mise activate inside bash -c matches sibling 6.95a-claude/gemini/codex
# patterns at lines 1119-1141; without it, bun is not on the PATH the
# subshell sees (mise installs bun via shims; activate sets PATH).
# BUN_INSTALL pin matches sibling pattern too.
sudo --preserve-env=ZETA_CREDS_PASSPHRASE -u "#$ZETA_UID" \
HOME="$ZETA_HOME" BUN_INSTALL="$ZETA_HOME/.bun" \
bash -c "set -o pipefail; eval \"\$(mise activate bash 2>/dev/null || true)\"; cd '$ZETA_HOME/Zeta' && bun tools/installer/zeta-creds-picker.ts --usb-uuid '$USB_UUID' --output /esp/zeta-creds.enc --passphrase-env ZETA_CREDS_PASSPHRASE" || \
echo "[iter-5.5.0] WARN: picker exited non-zero; cred-blob may be partial"
else
echo "[iter-5.5.0] SKIP 6.95-picker (set ZETA_CREDS_PICKER=1 + ZETA_CREDS_PASSPHRASE + /etc/zeta/usb-uuid to enable)"
fi

# 6.95b — interactive claude login (mirror iter-5.4.0 gh auth login)
CLAUDE_BIN="$ZETA_HOME/.bun/bin/claude"
if [ -x "$CLAUDE_BIN" ]; then
Expand Down
154 changes: 154 additions & 0 deletions tools/installer/zeta-creds-picker.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
// zeta-creds-picker.test.ts — B-0852.3a picker tests.
//
// Tests parseArgs (pure) + runPicker (against a mock readline-like interface).

import { describe, expect, it } from "bun:test";
import { parseArgs, runPicker } from "./zeta-creds-picker";

describe("parseArgs", () => {
it("accepts well-formed args with --passphrase-env", () => {
const r = parseArgs(["--usb-uuid", "u1", "--output", "/o", "--passphrase-env", "P"]);
if ("error" in r) throw new Error(r.error);
expect(r.usbUuid).toBe("u1");
expect(r.output).toBe("/o");
expect(r.passphraseEnv).toBe("P");
expect(r.persona).toBe(null);
expect(r.dryRun).toBe(false);
});

it("accepts --persona + --dry-run", () => {
const r = parseArgs([
"--usb-uuid", "u1", "--output", "/o", "--passphrase-file", "/pp",
"--persona", "otto", "--dry-run",
]);
if ("error" in r) throw new Error(r.error);
expect(r.persona).toBe("otto");
expect(r.dryRun).toBe(true);
expect(r.passphraseFile).toBe("/pp");
});

it("rejects missing --usb-uuid", () => {
const r = parseArgs(["--output", "/o", "--passphrase-env", "P"]);
expect("error" in r).toBe(true);
});

it("rejects missing --output", () => {
const r = parseArgs(["--usb-uuid", "u1", "--passphrase-env", "P"]);
expect("error" in r).toBe(true);
});

it("rejects no passphrase source", () => {
const r = parseArgs(["--usb-uuid", "u1", "--output", "/o"]);
expect("error" in r).toBe(true);
});

it("rejects unknown flag", () => {
const r = parseArgs(["--bogus"]);
expect("error" in r).toBe(true);
});
});

// Mock readline-like interface for testing runPicker against scripted answers.
function mockRl(answers: string[]) {
let idx = 0;
return {
question: (_prompt: string) => Promise.resolve(answers[idx++] ?? ""),
close: () => {},
} as unknown as Parameters<typeof runPicker>[0];
}

describe("runPicker", () => {
it("returns no bake-args when operator defers everything (no persona)", async () => {
const rl = mockRl([
"d", // gh-cli
// claude/gemini/codex auto-skip (persona-scoped + no persona)
// ssh-host-keys / ssh-operator-pubkey: prompt depending on handler/scope
"d", "d", "d", "d", "d", "d", "d", "d",
]);
const args = await runPicker(rl, null);
expect(args.length).toBe(0);
});

it("bakes gh-cli with literal value when chosen", async () => {
// Skip persona-scoped (no persona); gh-cli is the first global cred.
// Answers: bake, literal, value, then defer remaining
const rl = mockRl([
"b", "l", "ghp_test_value",
"d", "d", "d", "d", "d", "d", "d", "d",
]);
const args = await runPicker(rl, null);
expect(args.length).toBeGreaterThanOrEqual(1);
const gh = args.find((a) => a.startsWith("gh-cli="));
expect(gh).toBe("gh-cli=ghp_test_value");
});

it("skips empty literal value", async () => {
const rl = mockRl([
"b", "l", "", // gh-cli bake, literal, EMPTY
"d", "d", "d", "d", "d", "d", "d", "d",
]);
const args = await runPicker(rl, null);
expect(args.find((a) => a.startsWith("gh-cli="))).toBeUndefined();
});

it("uses @file syntax when operator picks file source", async () => {
const rl = mockRl([
"b", "f", "/tmp/test-file",
"d", "d", "d", "d", "d", "d", "d", "d",
]);
const args = await runPicker(rl, null);
const gh = args.find((a) => a.startsWith("gh-cli="));
expect(gh).toBe("gh-cli=@/tmp/test-file");
});

it("uses env: syntax when operator picks env source", async () => {
const rl = mockRl([
"b", "e", "GH_TOKEN",
"d", "d", "d", "d", "d", "d", "d", "d",
]);
const args = await runPicker(rl, null);
const gh = args.find((a) => a.startsWith("gh-cli="));
expect(gh).toBe("gh-cli=env:GH_TOKEN");
});

it("auto-skips persona-scoped creds when no persona", async () => {
const rl = mockRl(Array(15).fill("d"));
const args = await runPicker(rl, null);
// claude/gemini/codex are persona-scoped; auto-skip means NO prompt fired
// for them. With persona=null, only the global ones get bake/defer prompts.
expect(args.find((a) => a.startsWith("claude="))).toBeUndefined();
expect(args.find((a) => a.startsWith("gemini="))).toBeUndefined();
expect(args.find((a) => a.startsWith("codex="))).toBeUndefined();
});

it("bakes persona-scoped cred when persona supplied", async () => {
// With persona, claude/gemini/codex are baked-capable. Order matches
// DEFAULT_MANIFEST iteration.
const rl = mockRl([
"d", // gh-cli
"b", "l", '{"creds":"otto-claude"}', // claude bake literal
"d", "d", // gemini, codex
"d", "d", "d", "d", "d", "d", // remaining
]);
const args = await runPicker(rl, "otto");
expect(args.find((a) => a.startsWith("claude="))).toBe('claude={"creds":"otto-claude"}');
});

it("treats empty choice as defer", async () => {
const rl = mockRl(Array(15).fill(""));
const args = await runPicker(rl, null);
expect(args.length).toBe(0);
});

it("treats unrecognized choice as defer", async () => {
const rl = mockRl(Array(15).fill("xyz"));
const args = await runPicker(rl, null);
expect(args.length).toBe(0);
});

it("skip explicit returns no bake", async () => {
const rl = mockRl(Array(15).fill("s"));
const args = await runPicker(rl, null);
expect(args.length).toBe(0);
});
});
Loading
Loading