From 82713ebd51cfb26c2ee60424bac627ba0072dc25 Mon Sep 17 00:00:00 2001 From: Lior Date: Tue, 26 May 2026 21:57:17 -0400 Subject: [PATCH] =?UTF-8?q?feat(B-0844):=20zflash=20--agent=20flag=20?= =?UTF-8?q?=E2=80=94=20native=20auto-type=20challenge=20response=20via=20s?= =?UTF-8?q?pawn=20with=20piped=20stdin=20closes=20docstring-vs-implementat?= =?UTF-8?q?ion=20gap=20(empirical=202026-05-26=203rd=20USB=20re-flash=20se?= =?UTF-8?q?ssion)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the --agent flag per B-0844 row. Closes the gap where zflash.ts docstring promises 'agent auto-types the yes challenge' but the execFileSync({stdio: 'inherit'}) implementation broke under any non-interactive stdin (e.g., 'bun zflash.ts | tail'). Empirical anchor: 2026-05-26 3rd USB re-flash session. Operator authorized agent-driven zflash with Touch ID. Agent ran via 'bun zflash.ts | tail -50' which broke stdin → readline returned empty → bail'd silently → tail swallowed error → iter-4.2 inject ran on PRE-EXISTING USB ESP → operator saw 'safe to remove USB' + thought flash succeeded → boot attempt failed: 'i got the fingerprint but it didn't format'. Implementation: - New '--agent' flag added to ALLOWED_FLAGS + help text - When --agent is passed: spawn flash-usb with stdio ['pipe', 'pipe', 'inherit'] instead of execFileSync({inherit}) - Pipe stdout: scan for the 'yes <4hex>' challenge line from flash-usb.ts; mirror everything back to operator's stdout - Pipe stdin: when challenge detected, auto-type 'yes \n' then close stdin - Inherit stderr: Touch ID PAM prompt + sudo error messages stay directly visible to operator - Glass-halo visibility: explicit '[agent-mode: auto-typing yes XXXX — operator visibility per glass-halo-bidirectional rule]' line printed BEFORE the auto-type so operator can see what's happening - Default behavior (no --agent flag) unchanged — execFileSync with inherited stdio remains for interactive operator runs Preserves ALL safety rails: - Touch ID PAM gate fires on operator's Mac for sudo dd; cannot be agent-bypassed (biometric physical-presence proof) - Nonce randomness preserved (random per-run; agent reads from stdout, can't pre-bake) - Runtime acceptance preserved (typing the EXACT challenge IS the acceptance signal; agent typing it from operator-observed-stdout IS substrate-honest delegation) - All flash-usb sanity rails (platform / ISO size / USB protocol / internal-disk / boot-disk / size-range checks) still fire Verified: - bun full-ai-cluster/tools/zflash.ts --help shows --agent entry with full doc text - TypeScript compiles cleanly (no type errors) - Backward-compatible (default invocation unchanged) Closes B-0844 acceptance criteria. Per substrate-or-it-didnt-happen.md: docstring promise now has backing substrate. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- full-ai-cluster/tools/zflash.ts | 104 +++++++++++++++++++++++++++----- 1 file changed, 90 insertions(+), 14 deletions(-) diff --git a/full-ai-cluster/tools/zflash.ts b/full-ai-cluster/tools/zflash.ts index 86201cdee1..1578e08b54 100755 --- a/full-ai-cluster/tools/zflash.ts +++ b/full-ai-cluster/tools/zflash.ts @@ -62,7 +62,7 @@ // bypassed by an agent; Touch ID requires the operator's actual // finger on the actual trackpad. -import { execFileSync } from "node:child_process"; +import { execFileSync, spawn } from "node:child_process"; import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs"; import { homedir, platform, tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; @@ -764,6 +764,7 @@ async function main() { "--skip-freshness-check", "--skip-iso-pull", "--host", + "--agent", ]); const argv = process.argv.slice(2); @@ -773,6 +774,7 @@ async function main() { let skipFreshnessCheck = false; let skipIsoPull = false; let hostOverride: string | null = null; + let agentMode = false; const rawFlags: string[] = []; const positional: string[] = []; for (let i = 0; i < argv.length; i++) { @@ -805,6 +807,10 @@ async function main() { skipIsoPull = true; continue; } + if (a === "--agent") { + agentMode = true; + continue; + } if (a === "--host") { const next = argv[i + 1]; if (!next || next.startsWith("-")) { @@ -862,6 +868,12 @@ async function main() { " role-stack — e.g., --host pikachu installs as pikachu\n" + " regardless of flake role config. Default: flake config name\n" + " (control-plane for the zero-typing single-node path)\n" + + " --agent (B-0844) agent-driven mode — spawn flash-usb with piped stdin\n" + + " so the agent auto-types the 'yes ' challenge by reading\n" + + " the nonce from stdout. Touch ID PAM gate STILL fires on the\n" + + " operator's Mac (cannot be agent-bypassed). Use when running\n" + + " zflash through a pipe ('| tail', '2>&1 >log', etc.) which\n" + + " breaks the default readline.question stdin-from-terminal flow.\n" + " iso-path (optional) explicit ISO; default = newest under ~/Downloads,\n" + " auto-pulled from CI if origin/main has fresher build\n" + " Run zflash-setup once first to install Touch ID for sudo.\n", @@ -946,22 +958,86 @@ async function main() { ); } - // Stdio inherit — child handles all I/O directly (readline, sudo Touch ID - // PAM prompt, dd progress). We are a thin invocation wrapper. + // Stdio inherit (default) — child handles all I/O directly (readline, + // sudo Touch ID PAM prompt, dd progress). We are a thin invocation + // wrapper. + // + // Agent mode (--agent flag, B-0844): switch from execFileSync({stdio: + // "inherit"}) to spawn({stdio: ["pipe", "pipe", "inherit"]}) — stdin + // is piped so we can auto-type the challenge response; stdout is + // piped so we can SCAN for the "yes " challenge line then + // mirror everything back to our stdout; stderr remains inherited so + // the Touch ID PAM prompt visibility is preserved. + // + // Why this exists: zflash docstring promised "agent auto-types the + // yes challenge" but the execFileSync({stdio: "inherit"}) + // implementation broke under any non-interactive stdin (e.g., + // `bun zflash.ts | tail`). The 2026-05-26 3rd USB-test session + // surfaced this empirically — operator saw "safe to remove USB" + // but USB wasn't actually formatted. This agent-mode closes the + // docstring-vs-implementation gap. + // + // Touch ID PAM gate is PRESERVED — agent cannot bypass biometric + // physical-presence proof on the operator's Mac. const flashUsbArgs = willInject ? [flashUsb, "--short", "--no-eject", isoPath] : [flashUsb, "--short", isoPath]; - try { - execFileSync("bun", flashUsbArgs, { stdio: "inherit" }); - } catch (e: unknown) { - // execFileSync throws on non-zero exit; child has already printed its - // own error message + exited with its own code via flash-usb's bail(). - // We propagate the exit code. - const status = - e && typeof e === "object" && "status" in e - ? Number((e as { status: number }).status) || 1 - : 1; - process.exit(status); + if (agentMode) { + process.stdout.write( + "\nzflash: --agent mode active — will auto-type challenge response\n" + + " (Touch ID PAM gate still fires on operator's Mac;\n" + + " biometric physical-presence proof cannot be agent-bypassed)\n\n", + ); + await new Promise((res, rej) => { + const child = spawn("bun", flashUsbArgs, { + stdio: ["pipe", "pipe", "inherit"], + }); + let stdoutBuf = ""; + let challengeAnswered = false; + child.stdout.on("data", (chunk: Buffer) => { + const text = chunk.toString(); + process.stdout.write(text); // mirror to operator's view + if (!challengeAnswered) { + stdoutBuf += text; + // Match the " yes <4hex>" line emitted by flash-usb.ts at + // the runtime-acceptance prompt. The challenge is indented + // by 2 spaces in flash-usb.ts; the nonce is exactly 4 hex + // chars (16-bit entropy; per-run random; can't be pre-baked). + const m = stdoutBuf.match(/^\s+yes ([0-9a-f]{4})\s*$/m); + if (m) { + const nonce = m[1]; + const answer = `yes ${nonce}\n`; + process.stdout.write( + `\n[agent-mode: auto-typing '${answer.trim()}' — operator visibility per glass-halo-bidirectional rule]\n`, + ); + child.stdin.write(answer); + child.stdin.end(); + challengeAnswered = true; + } + } + }); + child.on("close", (code) => { + if (code === 0) { + res(); + } else { + process.exit(code ?? 1); + } + }); + child.on("error", (err) => rej(err)); + }); + } else { + try { + execFileSync("bun", flashUsbArgs, { stdio: "inherit" }); + } catch (e: unknown) { + // execFileSync throws on non-zero exit; child has already printed + // its own error message + exited with its own code via flash-usb's + // bail(). We propagate the exit code. + const status = + e && typeof e === "object" && "status" in e + ? Number((e as { status: number }).status) || 1 + : 1; + process.exit(status); + } } if (willInject) {