diff --git a/full-ai-cluster/usb-nixos-installer/zeta-install.sh b/full-ai-cluster/usb-nixos-installer/zeta-install.sh index b8c158007d..149a36c2ce 100755 --- a/full-ai-cluster/usb-nixos-installer/zeta-install.sh +++ b/full-ai-cluster/usb-nixos-installer/zeta-install.sh @@ -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 @@ -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 diff --git a/tools/installer/zeta-creds-picker.test.ts b/tools/installer/zeta-creds-picker.test.ts new file mode 100644 index 0000000000..18a97dbca1 --- /dev/null +++ b/tools/installer/zeta-creds-picker.test.ts @@ -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[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); + }); +}); diff --git a/tools/installer/zeta-creds-picker.ts b/tools/installer/zeta-creds-picker.ts new file mode 100644 index 0000000000..311beebcbb --- /dev/null +++ b/tools/installer/zeta-creds-picker.ts @@ -0,0 +1,233 @@ +#!/usr/bin/env bun +// zeta-creds-picker.ts — interactive picker at install/setup time. +// +// B-0852.3a (operator 2026-05-27 device-flow-at-setup framing). +// +// For each cred in DEFAULT_MANIFEST, prompts operator: +// [b]ake-in now / [d]efer to device-flow at runtime / [s]kip +// For bake-in: sub-prompts for value-source (literal / @file / env:VAR) +// per the per-cred handler's supportedSources. +// +// Then invokes zeta-creds-persist with the collected --bake-cred args. +// +// Composes: +// - tools/installer/zeta-creds-manifest.ts (B-0852.5; iteration source) +// - tools/installer/zeta-cred-handlers.ts (B-0852.10; per-cred source validation) +// - tools/installer/zeta-creds-persist.ts (B-0852.2b; downstream consumer) +// +// Usage (called from zeta-install.sh Step 6.95-picker or operator terminal): +// bun tools/installer/zeta-creds-picker.ts \ +// --usb-uuid \ +// --output /esp/zeta-creds.enc \ +// ( --passphrase-file | --passphrase-env ) \ +// [--persona ] \ +// [--dry-run] (print persist invocation; don't exec) +// +// Per .claude/rules/non-coercion-invariant.md HC-8: operator authority over +// own creds; no default-bake (operator must explicitly pick bake for each); +// passphrase NEVER logged; declined creds defer to device-flow at runtime. +// +// Exit codes: +// 0 success +// 2 arg parse error +// 3 picker abort (operator Ctrl+C) +// 4 persist invocation failure + +import { spawnSync } from "node:child_process"; +import { createInterface } from "node:readline/promises"; +import { stdin as input, stdout as output } from "node:process"; +import { DEFAULT_MANIFEST } from "./zeta-creds-manifest"; +import { DEFAULT_HANDLERS } from "./zeta-cred-handlers"; + +interface PickerArgs { + readonly usbUuid: string; + readonly output: string; + readonly passphraseFile: string | null; + readonly passphraseEnv: string | null; + readonly persona: string | null; + readonly dryRun: boolean; +} + +export function parseArgs(argv: readonly string[]): PickerArgs | { readonly error: string } { + let usbUuid: string | null = null; + let output: string | null = null; + let passphraseFile: string | null = null; + let passphraseEnv: string | null = null; + let persona: string | null = null; + let dryRun = false; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]!; + const next = (): string => { + if (i + 1 >= argv.length) throw new Error(`${arg} requires a value`); + return argv[++i]!; + }; + try { + if (arg === "--usb-uuid") usbUuid = next(); + else if (arg === "--output") output = next(); + else if (arg === "--passphrase-file") passphraseFile = next(); + else if (arg === "--passphrase-env") passphraseEnv = next(); + else if (arg === "--persona") persona = next(); + else if (arg === "--dry-run") dryRun = true; + else return { error: `unknown flag: ${arg}` }; + } catch (err) { + return { error: err instanceof Error ? err.message : String(err) }; + } + } + if (!usbUuid) return { error: "--usb-uuid required" }; + if (!output) return { error: "--output required" }; + if (!passphraseFile && !passphraseEnv) { + return { error: "passphrase source required: --passphrase-file or --passphrase-env " }; + } + return { usbUuid, output, passphraseFile, passphraseEnv, persona, dryRun }; +} + +/** + * Prompt operator per-cred and return the --bake-cred args list. + * Pure function over a readline interface (testable with mock). + */ +export async function runPicker( + rl: ReturnType, + persona: string | null, +): Promise { + const bakeArgs: string[] = []; + console.log("\n=== Credential picker (B-0852.3a setup-time integration) ==="); + console.log("For each declared credential: bake-in NOW, defer to device-flow at runtime, or skip."); + if (persona) console.log(`Persona: ${persona} (persona-scoped creds will use this slot).\n`); + else console.log("No --persona; only global creds will accept bake.\n"); + + for (const cred of DEFAULT_MANIFEST.credentials) { + const handler = DEFAULT_HANDLERS[cred.id]; + if (!handler) { + console.log(`-- ${cred.id}: no handler registered; skipping`); + continue; + } + if (cred.personaScoped && !persona) { + console.log(`-- ${cred.id}: persona-scoped + no --persona given; auto-skip`); + continue; + } + console.log(`\n-- ${cred.id} (${cred.personaScoped ? "persona-scoped" : "global"}; ${cred.required ? "required" : "optional"})`); + if (cred.notes) console.log(` ${cred.notes}`); + console.log(` paths: ${cred.paths.join(", ")}`); + if (handler.supportedSources.length === 0) { + console.log(` handler has NO supported sources (Phase 1 deferred); auto-defer`); + continue; + } + console.log(` supported sources: ${handler.supportedSources.join(", ")}`); + const choice = (await rl.question(" [b]ake-in / [d]efer to device-flow / [s]kip? ")).toLowerCase().trim(); + if (choice === "s" || choice === "skip") { + console.log(` → skip`); + continue; + } + if (choice === "d" || choice === "defer" || choice === "") { + console.log(` → defer (device-flow at runtime)`); + continue; + } + if (choice !== "b" && choice !== "bake") { + console.log(` → unrecognized "${choice}"; treating as defer`); + continue; + } + const sources = handler.supportedSources; + const sourceHint = sources.map((s) => `[${s[0]}]${s.slice(1)}`).join(" / "); + const sourceChoice = (await rl.question(` source ${sourceHint}? `)).toLowerCase().trim(); + let valueSpec: string; + if (sourceChoice.startsWith("l") && sources.includes("literal")) { + const v = await rl.question(` literal value (will NOT be logged): `); + if (v.length === 0) { + console.log(` → empty value; skip`); + continue; + } + valueSpec = v; + } else if (sourceChoice.startsWith("f") && sources.includes("file")) { + const p = await rl.question(` file path: `); + if (p.length === 0) { + console.log(` → empty path; skip`); + continue; + } + valueSpec = `@${p}`; + } else if (sourceChoice.startsWith("e") && sources.includes("env")) { + const v = await rl.question(` env var name: `); + if (v.length === 0) { + console.log(` → empty env var name; skip`); + continue; + } + valueSpec = `env:${v}`; + } else { + console.log(` → unrecognized or unsupported source; skip`); + continue; + } + bakeArgs.push(`${cred.id}=${valueSpec}`); + // SECURITY: source label computed from operator's choice letter, NOT + // from valueSpec (which contains the actual value/path/var-name). + // Avoids any chance of the value/path/var-name reaching console output + // through ternary-on-valueSpec inspection. + const sourceLabel = sourceChoice.startsWith("l") ? "literal" : sourceChoice.startsWith("f") ? "@file" : "env"; + console.log(` → baked (source: ${sourceLabel})`); + } + return bakeArgs; +} + +async function main(): Promise { + const argv = process.argv.slice(2); + const parsed = parseArgs(argv); + if ("error" in parsed) { + console.error(`zeta-creds-picker: ${parsed.error}`); + return 2; + } + const rl = createInterface({ input, output }); + let bakeArgs: string[] = []; + try { + bakeArgs = await runPicker(rl, parsed.persona); + } catch (err) { + rl.close(); + console.error(`zeta-creds-picker: aborted: ${err instanceof Error ? err.message : String(err)}`); + return 3; + } finally { + rl.close(); + } + console.log(`\n=== Picker complete: ${bakeArgs.length} cred(s) selected for bake-in ===`); + for (const a of bakeArgs) { + const id = a.split("=", 1)[0]; + console.log(` --bake-cred ${id}=`); + } + const persistArgs = [ + "tools/installer/zeta-creds-persist.ts", + "--usb-uuid", parsed.usbUuid, + "--output", parsed.output, + ]; + if (parsed.passphraseFile) persistArgs.push("--passphrase-file", parsed.passphraseFile); + if (parsed.passphraseEnv) persistArgs.push("--passphrase-env", parsed.passphraseEnv); + if (parsed.persona) persistArgs.push("--persona", parsed.persona); + for (const a of bakeArgs) persistArgs.push("--bake-cred", a); + if (parsed.dryRun) { + // SECURITY: build display string from KNOWN-SAFE pieces only. + // Earlier map-based redaction kept persistArgs (tainted) in the + // dataflow; CodeQL doesn't recognize runtime ternary as breaking + // taint, so it kept flagging. Construct from primitives instead; + // NEVER reference parsed.passphraseEnv or parsed.passphraseFile in + // the logged string. Sibling discipline to zeta-creds-persist.ts + // + zeta-creds-restore.ts P0 fix on PR #5422. + console.log(`\n=== DRY RUN — would invoke: ===`); + let displayCmd = ` bun tools/installer/zeta-creds-persist.ts --usb-uuid --output `; + if (parsed.passphraseFile) displayCmd += ` --passphrase-file `; + if (parsed.passphraseEnv) displayCmd += ` --passphrase-env `; + if (parsed.persona) displayCmd += ` --persona `; + for (const a of bakeArgs) { + const id = a.split("=", 1)[0]; + displayCmd += ` --bake-cred ${id}=`; + } + console.log(displayCmd); + return 0; + } + console.log(`\n=== Invoking zeta-creds-persist... ===`); + // eslint-disable-next-line sonarjs/no-os-command-from-path + const result = spawnSync("bun", persistArgs, { stdio: "inherit" }); + if (result.status !== 0) { + console.error(`zeta-creds-picker: persist failed (exit ${result.status})`); + return 4; + } + return 0; +} + +if (import.meta.main) { + main().then((code) => process.exit(code)); +}