From fd7ab316dfa9eb196cd9e0fff28bc84d84b31aa6 Mon Sep 17 00:00:00 2001 From: Rui Carmo Date: Thu, 1 Oct 2026 01:17:48 +0100 Subject: [PATCH] test: qualify packaged Earendil CLI synthetic provider login --- docs/config/piclaw-env-observations.json | 18 ++- .../earendil-0991-packaged-cli-auth.md | 46 ++++++ .../earendil-0991-packaged-cli-auth-bun.json | 145 ++++++++++++++++++ .../fixtures/cli-auth-preload-0991.mjs | 46 ++++++ .../fixtures/packaged-cli-auth-0991.ts | 130 ++++++++++++++++ .../provider-auth-cli-receipt.test.ts | 47 ++++++ .../provider-auth-cli.optional.test.ts | 37 +++++ 7 files changed, 468 insertions(+), 1 deletion(-) create mode 100644 docs/design/earendil-agent-harness-integration-adr/evidence/earendil-0991-packaged-cli-auth.md create mode 100644 docs/design/earendil-agent-harness-integration-adr/evidence/receipts/earendil-0991-packaged-cli-auth-bun.json create mode 100644 runtime/test/agent-control/fixtures/cli-auth-preload-0991.mjs create mode 100644 runtime/test/agent-control/fixtures/packaged-cli-auth-0991.ts create mode 100644 runtime/test/agent-control/provider-auth-cli-receipt.test.ts create mode 100644 runtime/test/agent-control/provider-auth-cli.optional.test.ts diff --git a/docs/config/piclaw-env-observations.json b/docs/config/piclaw-env-observations.json index b92ec8227..b0418df22 100644 --- a/docs/config/piclaw-env-observations.json +++ b/docs/config/piclaw-env-observations.json @@ -87,7 +87,7 @@ "semanticReaderOccurrences": 69 }, "docsDeploy": { - "referencedNames": 141, + "referencedNames": 142, "directReaderNames": 16, "directReaderOccurrences": 32, "helperReaderNames": 0, @@ -1787,6 +1787,7 @@ "semanticReaders": 0, "files": [ ".github/workflows/e2e.yml", + "docs/design/earendil-agent-harness-integration-adr/evidence/earendil-0991-packaged-cli-auth.md", "docs/design/earendil-agent-harness-integration-adr/evidence/earendil-0991-packed-provider-auth.md", "docs/design/earendil-agent-harness-integration-adr/evidence/earendil-0991-provider-auth-ui-matrix.md", "docs/development.md" @@ -3969,6 +3970,21 @@ } } }, + { + "name": "PICLAW_RUN_AUTH_CLI_TESTS", + "scopes": { + "docsDeploy": { + "referenced": true, + "directReaders": 0, + "helperReaders": 0, + "semanticReaders": 0, + "files": [ + "docs/design/earendil-agent-harness-integration-adr/evidence/earendil-0991-packaged-cli-auth.md" + ], + "readerFiles": [] + } + } + }, { "name": "PICLAW_RUN_AUTH_CRASH_TESTS", "scopes": { diff --git a/docs/design/earendil-agent-harness-integration-adr/evidence/earendil-0991-packaged-cli-auth.md b/docs/design/earendil-agent-harness-integration-adr/evidence/earendil-0991-packaged-cli-auth.md new file mode 100644 index 000000000..8d19aa418 --- /dev/null +++ b/docs/design/earendil-agent-harness-integration-adr/evidence/earendil-0991-packaged-cli-auth.md @@ -0,0 +1,46 @@ +# Earendil 0.99.1 packaged interactive CLI authentication + +The official packaged Earendil CLI completes synthetic OpenAI and Codex OAuth login, rejects denied exchanges and mismatched state, and cancels before exchange. Eight PTY cases run under Bun 1.4.2 in separate loopback-only network namespaces. + +## Artifact and invocation + +The admitted registry receipt pins the coding-agent archive SHA-512. All 70 bundled JavaScript files in the installed package were compared byte-for-byte with that verified archive. The receipt pins an aggregate bundle SHA-256, both public OAuth module hashes and the repository lockfile hash; ordinary CI checks these fingerprints. Package version checks alone are insufficient. + +The child invokes the public `pi` entry `dist/bundle/cli.js`, interactive `/login openai` or `/login openai-codex`, and `/quit`. It imports no private runtime API. Flags disable session persistence, extensions, skills, prompt templates, themes, context files and tools. Profiles, home, working directory and browser launcher are test-owned temporary directories. No inference prompt is submitted. + +## Isolation and terminal checks + +- `sudo -n unshare --net` creates a separate network namespace; only loopback is enabled. Before CLI startup, the driver checks namespace identity, `/proc/net/dev` and the absence of IPv4 routes. +- `setpriv` returns to the invoking non-root UID/GID, clears supplementary groups and all capability sets, and sets `no_new_privs`. The UID retains normal filesystem access; this is not a mount/filesystem sandbox or protection against malicious package code. +- A minimal child environment contains disposable profile paths and synthetic fixture configuration. No keychain values or parent credential variables are forwarded. +- A test-owned `xdg-open` records the argument count and URL hash. Exactly one argument must match the displayed authorisation URL. URLs and tokens are never written to the launcher receipt. +- A preload occupies callback port 1455 to select manual handoff. It validates provider endpoint, POST payload, client/resource, PKCE verifier and exact exchange count. Codex startup's global-fetch polyfill assignment is intercepted by a stable test-owned getter/setter. +- OS isolation denies external egress independently of the fetch guard. `unexpectedFetchRequests` counts guarded fetch/preconnect violations only. Direct socket attempts and loopback traffic are not audited. +- GNU `timeout` bounds the namespace process group; the PTY driver has a separate child watchdog. Parent cleanup removes owned profiles. Raw PTY output is never emitted on failure. + +Success requires the CLI's login status, exact provider-scoped synthetic credentials, expiry, mode `0600`, OpenAI issued-client/scopes or Codex account ID, one matching PKCE exchange, and restored-editor `/quit` exit zero. Denial and state rejection require their expected error class and no credentials. Cancellation requires no exchange or credentials and a responsive restored editor. An exchange receipt alone cannot pass. + +## Reproduction + +Linux network namespaces, passwordless `sudo -n` for the namespace launcher, `ip`, `setpriv`, GNU `timeout` and Bun PTYs are required. Missing prerequisites fail the explicitly enabled suite; there is no unsandboxed fallback. + +```sh +bun run test:local --cwd runtime \ + --env PICLAW_RUN_AUTH_CLI_TESTS=1 --env PICLAW_E2E_DISPOSABLE=1 -- \ + bun test --timeout 600000 test/agent-control/provider-auth-cli.optional.test.ts \ + test/agent-control/provider-auth-cli-receipt.test.ts +``` + +Ordinary canonical CI validates the receipt and isolation refusal; the opt-in suite executes all eight terminal flows separately. + +## Initial failures + +The first probe lacked OS isolation. Bundled Codex startup replaced global fetch and the probe returned provider-facing token-validation text. It was stopped and excluded from qualification. Disposable credentials were synthetic; that run's network behaviour was not fully audited. All subsequent CLI qualification uses mandatory OS isolation. + +The first guarded parent test failed before CLI startup because mounted `/sys/class/net` reflected the container namespace. Namespace-local `/proc/net/dev` fixed the check. Later assertions used `scope` rather than the SDK's stored `scopes` array, and mismatched the providers' denial/state-error wording. Those runs failed; source inspection corrected the field and exact error classes without relaxing expected scopes or rejection. The final focused run passed 3 tests / 104 assertions. + +## Evidence boundary + +This receipt covers the official Earendil interactive CLI package and two providers with synthetic exchanges. Piclaw distributable CLI/card routes, Node terminal behaviour, automatic browser callback, live accounts, provider token validation, refresh/logout through the CLI, historical credential cleanup, Delegate parity and native MCP acceptance are unqualified. #1442 and #1458 stay open. No deployment or restart occurred. + +At baseline `717d1b2b87b8ad9351e88cfd6b5135c501299508`, `make ci-fast` passed 5,972 runtime tests, eight existing/opt-in skips and no failures, plus 25 feature tests and nine web checks. The optional PTY suite ran separately: three tests / 104 assertions, covering all eight flows and receipt/refusal checks. Pack hygiene passed 24,742 files. Five typechecks, scoped strict fixture typing, Oxlint, silent-swallow, local-entrypoint and diff checks passed; compose retains 95 unchanged diagnostics. Independent review findings were fixed and re-reviewed with no blockers. Private Bun caches were used without shared-cache permission changes. diff --git a/docs/design/earendil-agent-harness-integration-adr/evidence/receipts/earendil-0991-packaged-cli-auth-bun.json b/docs/design/earendil-agent-harness-integration-adr/evidence/receipts/earendil-0991-packaged-cli-auth-bun.json new file mode 100644 index 000000000..292f03855 --- /dev/null +++ b/docs/design/earendil-agent-harness-integration-adr/evidence/receipts/earendil-0991-packaged-cli-auth-bun.json @@ -0,0 +1,145 @@ +{ + "version": "0.99.1", + "scope": "official_earendil_packaged_interactive_cli_synthetic_auth_only", + "runtime": "Bun 1.4.2", + "sourceCommit": "717d1b2b87b8ad9351e88cfd6b5135c501299508", + "packageIntegrity": "sha512-cWUrTOqA5M73cOYMgsh9PlhDrsBhavd+n5kVY6F7BGbGl1RjqCteVCoeVMVqhngoGACVDyw1tbLjajL8l9jrHg==", + "officialArchiveComparison": "all_bundled_js_bytes_equal", + "bundleSha256": "ff993ffb7780928e762ef9411352f206d8bb80605bad8bd9f26358782f439cf9", + "bundleFileCount": 70, + "sdkFileSha256": { + "openai-chatgpt.js": "b8d3c0513eb9e3879714be046968b1ec443ee9d8f02d3248ce2181911b88d1fa", + "openai-codex.js": "0740315fb80f9c90ccee677b3cfdce4bce289610090b64b74af8f002e230868f" + }, + "lockSha256": "b11097e905d172e35eb91c5e043ee03ad300b8819d690e4b7ebe32231b9bbc3f", + "results": [ + { + "version": "0.99.1", + "runtime": "Bun 1.4.2", + "provider": "openai", + "mode": "success", + "status": "pass", + "cliLogin": "completed", + "credentialPersistence": "disposable_profile", + "tokenRequests": 1, + "unexpectedFetchRequests": 0, + "externalEgress": "os_namespace_denied", + "browserLauncher": "test_owned_noop", + "networkGuard": "distinct_loopback_only_os_namespace_and_stable_preload", + "cliExit": "restored_editor_quit_0", + "inference": "not_invoked" + }, + { + "version": "0.99.1", + "runtime": "Bun 1.4.2", + "provider": "openai", + "mode": "denied", + "status": "pass", + "cliLogin": "rejected_or_cancelled", + "credentialPersistence": "none", + "tokenRequests": 1, + "unexpectedFetchRequests": 0, + "externalEgress": "os_namespace_denied", + "browserLauncher": "test_owned_noop", + "networkGuard": "distinct_loopback_only_os_namespace_and_stable_preload", + "cliExit": "restored_editor_quit_0", + "inference": "not_invoked" + }, + { + "version": "0.99.1", + "runtime": "Bun 1.4.2", + "provider": "openai", + "mode": "bad-state", + "status": "pass", + "cliLogin": "rejected_or_cancelled", + "credentialPersistence": "none", + "tokenRequests": 0, + "unexpectedFetchRequests": 0, + "externalEgress": "os_namespace_denied", + "browserLauncher": "test_owned_noop", + "networkGuard": "distinct_loopback_only_os_namespace_and_stable_preload", + "cliExit": "restored_editor_quit_0", + "inference": "not_invoked" + }, + { + "version": "0.99.1", + "runtime": "Bun 1.4.2", + "provider": "openai", + "mode": "cancel", + "status": "pass", + "cliLogin": "rejected_or_cancelled", + "credentialPersistence": "none", + "tokenRequests": 0, + "unexpectedFetchRequests": 0, + "externalEgress": "os_namespace_denied", + "browserLauncher": "test_owned_noop", + "networkGuard": "distinct_loopback_only_os_namespace_and_stable_preload", + "cliExit": "restored_editor_quit_0", + "inference": "not_invoked" + }, + { + "version": "0.99.1", + "runtime": "Bun 1.4.2", + "provider": "openai-codex", + "mode": "success", + "status": "pass", + "cliLogin": "completed", + "credentialPersistence": "disposable_profile", + "tokenRequests": 1, + "unexpectedFetchRequests": 0, + "externalEgress": "os_namespace_denied", + "browserLauncher": "test_owned_noop", + "networkGuard": "distinct_loopback_only_os_namespace_and_stable_preload", + "cliExit": "restored_editor_quit_0", + "inference": "not_invoked" + }, + { + "version": "0.99.1", + "runtime": "Bun 1.4.2", + "provider": "openai-codex", + "mode": "denied", + "status": "pass", + "cliLogin": "rejected_or_cancelled", + "credentialPersistence": "none", + "tokenRequests": 1, + "unexpectedFetchRequests": 0, + "externalEgress": "os_namespace_denied", + "browserLauncher": "test_owned_noop", + "networkGuard": "distinct_loopback_only_os_namespace_and_stable_preload", + "cliExit": "restored_editor_quit_0", + "inference": "not_invoked" + }, + { + "version": "0.99.1", + "runtime": "Bun 1.4.2", + "provider": "openai-codex", + "mode": "bad-state", + "status": "pass", + "cliLogin": "rejected_or_cancelled", + "credentialPersistence": "none", + "tokenRequests": 0, + "unexpectedFetchRequests": 0, + "externalEgress": "os_namespace_denied", + "browserLauncher": "test_owned_noop", + "networkGuard": "distinct_loopback_only_os_namespace_and_stable_preload", + "cliExit": "restored_editor_quit_0", + "inference": "not_invoked" + }, + { + "version": "0.99.1", + "runtime": "Bun 1.4.2", + "provider": "openai-codex", + "mode": "cancel", + "status": "pass", + "cliLogin": "rejected_or_cancelled", + "credentialPersistence": "none", + "tokenRequests": 0, + "unexpectedFetchRequests": 0, + "externalEgress": "os_namespace_denied", + "browserLauncher": "test_owned_noop", + "networkGuard": "distinct_loopback_only_os_namespace_and_stable_preload", + "cliExit": "restored_editor_quit_0", + "inference": "not_invoked" + } + ] +} diff --git a/runtime/test/agent-control/fixtures/cli-auth-preload-0991.mjs b/runtime/test/agent-control/fixtures/cli-auth-preload-0991.mjs new file mode 100644 index 000000000..75d3dc12c --- /dev/null +++ b/runtime/test/agent-control/fixtures/cli-auth-preload-0991.mjs @@ -0,0 +1,46 @@ +import assert from "node:assert/strict"; +import { createServer } from "node:http"; +import { createHash } from "node:crypto"; +import { writeFileSync } from "node:fs"; + +const provider = process.env.SYNTHETIC_AUTH_PROVIDER; +const mode = process.env.SYNTHETIC_AUTH_MODE; +assert.ok(["openai", "openai-codex"].includes(provider)); +assert.ok(["success", "denied", "bad-state", "cancel"].includes(mode)); +const blocker = createServer((_request, response) => { response.writeHead(404); response.end(); }); +await new Promise((accept, reject) => { blocker.once("error", reject); blocker.listen(1455, "127.0.0.1", accept); }); +const jwt = `eyJhbGciOiJub25lIn0.${Buffer.from(JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: "synthetic-cli-account" } })).toString("base64url")}.fixture`; +const receipt = { preloadActive: true, provider, mode, tokenRequests: 0, unexpected: 0, polyfillAssignments: 0, pkceChallenge: "" }; +const persist = () => writeFileSync(process.env.SYNTHETIC_AUTH_GUARD, JSON.stringify(receipt), { mode: 0o600 }); +persist(); +const mockFetch = Object.assign(async (input, init) => { + try { + assert.equal(String(input), provider === "openai" ? "https://auth.openai.com/api/accounts/oauth/token" : "https://auth.openai.com/oauth/token"); + assert.equal(init?.method, "POST"); + const body = new URLSearchParams(init.body); + assert.equal(body.get("grant_type"), "authorization_code"); + assert.equal(body.get("code"), "synthetic-cli-code"); + assert.equal(body.get("redirect_uri"), provider === "openai" ? "http://127.0.0.1:1455/auth/callback" : "http://localhost:1455/auth/callback"); + assert.ok(body.get("code_verifier")); + if (provider === "openai") { + assert.equal(body.get("client_id"), "synthetic-issued-client"); + assert.equal(body.get("resource"), "https://api.openai.com/v1"); + } else assert.equal(body.get("client_id"), "app_EMoamEEZ73f0CkXaXp7hrann"); + assert.ok(["success", "denied"].includes(mode)); + receipt.tokenRequests++; + assert.equal(receipt.tokenRequests, 1); + receipt.pkceChallenge = createHash("sha256").update(body.get("code_verifier")).digest("base64url"); + persist(); + if (mode === "denied") return Response.json({ error: "access_denied" }, { status: 400 }); + return Response.json({ access_token: jwt, refresh_token: "synthetic-cli-refresh", expires_in: 3600, id_token: "synthetic-id", scope: "openid chatgpt.tokens.use.direct" }); + } catch { + receipt.unexpected++; persist(); + throw new Error("Unexpected CLI request or token exchange."); + } +}, { preconnect: () => { receipt.unexpected++; persist(); throw new Error("Unexpected CLI preconnect."); } }); +// Codex startup installs a fetch polyfill. Keep this test-owned interception +// stable; a mandatory OS network namespace independently denies egress. +Object.defineProperty(globalThis, "fetch", { + configurable: false, get: () => mockFetch, + set: () => { receipt.polyfillAssignments++; persist(); }, +}); diff --git a/runtime/test/agent-control/fixtures/packaged-cli-auth-0991.ts b/runtime/test/agent-control/fixtures/packaged-cli-auth-0991.ts new file mode 100644 index 000000000..d2d3e9af5 --- /dev/null +++ b/runtime/test/agent-control/fixtures/packaged-cli-auth-0991.ts @@ -0,0 +1,130 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, readlinkSync, statSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const [provider, mode, root, parentNamespace] = process.argv.slice(2); +assert.ok(root && parentNamespace && ["openai", "openai-codex"].includes(provider)); +assert.ok(["success", "denied", "bad-state", "cancel"].includes(mode)); +// Fail before CLI startup unless the caller established independent OS isolation. +assert.notEqual(readlinkSync("/proc/self/ns/net"), parentNamespace, "A distinct network namespace is required."); +const interfaces = readFileSync("/proc/net/dev", "utf8").trim().split("\n").slice(2).map(line => line.split(":")[0].trim()); +assert.deepEqual(interfaces, ["lo"], "Only loopback is permitted."); +assert.equal(readFileSync("/proc/net/route", "utf8").trim().split("\n").length, 1, "No IPv4 route is permitted."); +assert.equal(process.getuid?.(), Number(process.env.SYNTHETIC_EXPECT_UID), "Privilege drop is required."); +assert.notEqual(process.getuid?.(), 0); +const processStatus = readFileSync("/proc/self/status", "utf8"); +for (const field of ["CapInh", "CapPrm", "CapEff", "CapBnd", "CapAmb"]) assert.match(processStatus, new RegExp(`${field}:\\s+0{16}(?:\\n|$)`)); +assert.match(processStatus, /NoNewPrivs:\s+1(?:\n|$)/); +assert.deepEqual(process.getgroups?.(), [], "Supplementary groups must be cleared."); + +const packageRoot = dirname(dirname(fileURLToPath(import.meta.resolve("@earendil-works/pi-coding-agent")))); +const aiRoot = dirname(dirname(fileURLToPath(import.meta.resolve("@earendil-works/pi-ai")))); +assert.equal(JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8")).version, "0.99.1"); +assert.equal(JSON.parse(readFileSync(join(aiRoot, "package.json"), "utf8")).version, "0.99.1"); +const digest = (path: string) => createHash("sha256").update(readFileSync(path)).digest("hex"); +const bundle = join(packageRoot, "dist/bundle"); +const bundleFiles = [...new Bun.Glob("**/*.js").scanSync(bundle)].sort(); +const bundleSha256 = createHash("sha256").update(bundleFiles.map(file => `${file}\0${digest(join(bundle, file))}\n`).join("")).digest("hex"); +const sdkFileSha256 = Object.fromEntries(["openai-chatgpt.js", "openai-codex.js"].map(file => [file, digest(join(aiRoot, "dist/auth/oauth", file))])); +const archived = JSON.parse(readFileSync(resolve(import.meta.dir, "../../../../docs/design/earendil-agent-harness-integration-adr/evidence/receipts/earendil-0991-packaged-cli-auth-bun.json"), "utf8")); +assert.equal(bundleSha256, archived.bundleSha256, "Packaged CLI bundle drift."); +assert.deepEqual(sdkFileSha256, archived.sdkFileSha256, "OAuth module drift."); + +const home = join(root, "home"), agent = join(root, "agent"), bin = join(root, "bin"), cwd = join(root, "cwd"); +for (const dir of [home, agent, bin, cwd]) mkdirSync(dir, { recursive: true, mode: 0o700 }); +assert.ok(!existsSync(join(agent, "auth.json")), "A fresh profile is required."); +const launcher = join(root, "launcher-called"), guard = join(root, "guard.json"); +writeFileSync(join(bin, "xdg-open"), '#!/bin/sh\nprintf "%s\\n" "$#" >> "$SYNTHETIC_LAUNCH_MARKER"\nprintf "%s" "$1" | sha256sum | cut -d " " -f 1 >> "$SYNTHETIC_LAUNCH_MARKER"\n', { mode: 0o700 }); +let output = "", sentMethod = false, sentCallback = false, cancelled = false, completed = false; +let authUrl: URL | undefined; +const decoder = new TextDecoder(); +const terminal = new Bun.Terminal({ cols: 220, rows: 60, data(term, bytes) { + const chunk = decoder.decode(bytes, { stream: true }); + output = (output + chunk).slice(-500_000); + if (chunk.includes("\x1b[6n")) term.write("\x1b[1;1R"); + // OSC hyperlinks and ANSI sequences are terminal protocol bytes. + // eslint-disable-next-line no-control-regex + const match = output.match(/\x1b\]8;;(https:\/\/auth\.openai\.com\/[^\x07\x1b]+)/); + if (match) authUrl = new URL(match[1]); +} }); +// eslint-disable-next-line no-control-regex +const plainOutput = () => output.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "").replace(/\x1b\][^\x07]*(?:\x07)/g, ""); +const child = Bun.spawn([process.execPath, "--no-env-file", "--preload", resolve(import.meta.dir, "cli-auth-preload-0991.mjs"), join(bundle, "cli.js"), + "--no-session", "--no-extensions", "--no-skills", "--no-prompt-templates", "--no-themes", "--no-context-files", "--no-tools", "--tui-mode", "regular"], { + cwd, terminal, env: { PATH: `${bin}:${dirname(process.execPath)}:/usr/bin:/bin`, HOME: home, TERM: "xterm-256color", COLORTERM: "truecolor", PI_CODING_AGENT_DIR: agent, + PI_OFFLINE: "1", PI_TELEMETRY: "0", OTEL_SDK_DISABLED: "true", SYNTHETIC_AUTH_PROVIDER: provider, SYNTHETIC_AUTH_MODE: mode, SYNTHETIC_LAUNCH_MARKER: launcher, SYNTHETIC_AUTH_GUARD: guard }, +}); +const watchdog = setTimeout(() => child.kill("SIGKILL"), 60_000); +const started = Date.now(); +try { + await Bun.sleep(1500); + assert.ok(existsSync(guard), "CLI preload guard did not execute."); + terminal.write(`/login ${provider}\r`); + while (Date.now() - started < 50_000 && child.exitCode === null) { + const text = plainOutput(); + if (!sentMethod && /Select authentication method|Select OpenAI Codex login method/.test(text)) { + terminal.write("\r"); sentMethod = true; + } + if (authUrl && !sentCallback && !cancelled && /Paste|paste|callback URL|authorization code/.test(text)) { + assert.equal(authUrl.protocol, "https:"); assert.equal(authUrl.hostname, "auth.openai.com"); + assert.ok(authUrl.searchParams.get("state")); + assert.equal(authUrl.searchParams.get("code_challenge_method"), "S256"); + const expectedRedirect = provider === "openai" ? "http://127.0.0.1:1455/auth/callback" : "http://localhost:1455/auth/callback"; + assert.equal(authUrl.searchParams.get("redirect_uri"), expectedRedirect); + if (mode === "cancel") { terminal.write("\x1b"); cancelled = true; } + else { + const callback = new URL(expectedRedirect); + callback.searchParams.set("code", "synthetic-cli-code"); + callback.searchParams.set("state", mode === "bad-state" ? "synthetic-wrong-state" : authUrl.searchParams.get("state")!); + if (provider === "openai") callback.searchParams.set("client_id", "synthetic-issued-client"); + terminal.write(`${callback}\r`); sentCallback = true; + } + } + const authPath = join(agent, "auth.json"); + const hasCredentials = existsSync(authPath) && Object.keys(JSON.parse(readFileSync(authPath, "utf8"))).length > 0; + if (mode === "success" && hasCredentials && text.includes("Logged in to") && !text.includes("Failed to login") && !text.includes("could not be synchronized")) { + const credentials = JSON.parse(readFileSync(authPath, "utf8")); + assert.deepEqual(Object.keys(credentials), [provider]); + const stored = credentials[provider]; + assert.equal(stored.type, "oauth"); assert.equal(stored.refresh, "synthetic-cli-refresh"); + const expectedJwt = `eyJhbGciOiJub25lIn0.${Buffer.from(JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: "synthetic-cli-account" } })).toString("base64url")}.fixture`; + assert.equal(stored.access, expectedJwt); + assert.ok(stored.expires > started && stored.expires < Date.now() + 3_610_000); + if (provider === "openai-codex") assert.equal(stored.accountId, "synthetic-cli-account"); + else { assert.equal(stored.clientId, "synthetic-issued-client"); assert.deepEqual(stored.scopes, ["openid", "chatgpt.tokens.use.direct"]); } + assert.equal(statSync(authPath).mode & 0o777, 0o600); + completed = true; break; + } + if (mode !== "success" && ((mode === "cancel" && cancelled && Date.now() - started > 3500) || text.includes("Failed to login"))) { + assert.equal(hasCredentials, false, "Failed login persisted credentials."); + if (mode === "bad-state") assert.ok(/state mismatch/i.test(text), "Expected state rejection."); + if (mode === "denied") assert.ok(/token (?:request|exchange) failed \(400\)/.test(text), "Expected token exchange rejection."); + completed = true; break; + } + await Bun.sleep(50); + } + assert.ok(completed && authUrl && (sentCallback || cancelled), "Packaged CLI login did not reach its expected terminal state."); + assert.equal(readFileSync(launcher, "utf8"), `1\n${createHash("sha256").update(authUrl.href).digest("hex")}\n`, "Browser must receive exactly the displayed authorization URL once."); + let exchanged = JSON.parse(readFileSync(guard, "utf8")); + assert.equal(exchanged.preloadActive, true); assert.equal(exchanged.unexpected, 0); + const expectedRequests = ["success", "denied"].includes(mode) ? 1 : 0; + assert.equal(exchanged.tokenRequests, expectedRequests); + if (expectedRequests) assert.equal(exchanged.pkceChallenge, authUrl.searchParams.get("code_challenge")); + if (provider === "openai-codex") assert.ok(exchanged.polyfillAssignments > 0); + // A restored editor must accept /quit; aborting the process is never success. + terminal.write("/quit\r"); + assert.equal(await child.exited, 0, "CLI did not exit cleanly through the restored editor."); + exchanged = JSON.parse(readFileSync(guard, "utf8")); + assert.equal(exchanged.unexpected, 0); assert.equal(exchanged.tokenRequests, expectedRequests); + console.log(JSON.stringify({ version: "0.99.1", runtime: `Bun ${Bun.version}`, provider, mode, status: "pass", cliLogin: mode === "success" ? "completed" : "rejected_or_cancelled", + credentialPersistence: mode === "success" ? "disposable_profile" : "none", tokenRequests: expectedRequests, unexpectedFetchRequests: 0, externalEgress: "os_namespace_denied", + browserLauncher: "test_owned_noop", networkGuard: "distinct_loopback_only_os_namespace_and_stable_preload", cliExit: "restored_editor_quit_0", + inference: "not_invoked", bundleFileCount: bundleFiles.length, bundleSha256, sdkFileSha256 })); +} catch { + // PTY output may contain provider URLs, codes or tokens; never emit it. + throw new Error(`Packaged CLI ${provider}/${mode} qualification failed.`); +} finally { + clearTimeout(watchdog); if (child.exitCode === null) child.kill("SIGKILL"); await child.exited; terminal.close(); +} diff --git a/runtime/test/agent-control/provider-auth-cli-receipt.test.ts b/runtime/test/agent-control/provider-auth-cli-receipt.test.ts new file mode 100644 index 000000000..18bae523e --- /dev/null +++ b/runtime/test/agent-control/provider-auth-cli-receipt.test.ts @@ -0,0 +1,47 @@ +import { expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { existsSync, mkdtempSync, readFileSync, readlinkSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repo = resolve(import.meta.dir, "../../.."); +const receipt = JSON.parse(readFileSync(resolve(repo, "docs/design/earendil-agent-harness-integration-adr/evidence/receipts/earendil-0991-packaged-cli-auth-bun.json"), "utf8")); +const digest = (path: string) => createHash("sha256").update(readFileSync(path)).digest("hex"); +test("packaged CLI receipt pins the admitted archive, bundle and eight synthetic terminal outcomes", () => { + const registry = JSON.parse(readFileSync(resolve(repo, "runtime/test/fixtures/earendil-package-admission/registry-0.99.1.json"), "utf8")); + expect(receipt.packageIntegrity).toBe(registry.find((pkg: { name: string }) => pkg.name === "@earendil-works/pi-coding-agent").dist.integrity); + expect(receipt.version).toBe("0.99.1"); expect(receipt.runtime).toMatch(/^Bun /); + expect(receipt.scope).toBe("official_earendil_packaged_interactive_cli_synthetic_auth_only"); + expect(receipt.officialArchiveComparison).toBe("all_bundled_js_bytes_equal"); + expect(receipt.lockSha256).toBe(digest(resolve(repo, "bun.lock"))); + const packageRoot = dirname(dirname(fileURLToPath(import.meta.resolve("@earendil-works/pi-coding-agent")))); + const bundle = resolve(packageRoot, "dist/bundle"); + const files = [...new Bun.Glob("**/*.js").scanSync(bundle)].sort(); + expect(files.length).toBe(receipt.bundleFileCount); + expect(createHash("sha256").update(files.map(file => `${file}\0${digest(resolve(bundle, file))}\n`).join("")).digest("hex")).toBe(receipt.bundleSha256); + const aiRoot = dirname(dirname(fileURLToPath(import.meta.resolve("@earendil-works/pi-ai")))); + for (const file of ["openai-chatgpt.js", "openai-codex.js"]) expect(receipt.sdkFileSha256[file]).toBe(digest(resolve(aiRoot, "dist/auth/oauth", file))); + expect(receipt.results.map((row: { provider: string; mode: string }) => [row.provider, row.mode])).toEqual( + ["openai", "openai-codex"].flatMap(provider => ["success", "denied", "bad-state", "cancel"].map(mode => [provider, mode]))); + for (const row of receipt.results) { + expect(row).toMatchObject({ status: "pass", browserLauncher: "test_owned_noop", externalEgress: "os_namespace_denied", unexpectedFetchRequests: 0, + networkGuard: "distinct_loopback_only_os_namespace_and_stable_preload", cliExit: "restored_editor_quit_0", inference: "not_invoked" }); + expect(row.tokenRequests).toBe(["success", "denied"].includes(row.mode) ? 1 : 0); + expect(row.credentialPersistence).toBe(row.mode === "success" ? "disposable_profile" : "none"); + } +}); + +test("CLI fixture refuses ordinary network namespace execution before profile creation", async () => { + const root = mkdtempSync(resolve(tmpdir(), "cli-refusal-")); + const child = Bun.spawn([process.execPath, "--no-env-file", resolve(import.meta.dir, "fixtures/packaged-cli-auth-0991.ts"), "openai", "success", root, readlinkSync("/proc/self/ns/net")], { + env: { PATH: "/usr/bin:/bin", HOME: "/nonexistent", SYNTHETIC_EXPECT_UID: String(process.getuid?.()) }, stdout: "pipe", stderr: "pipe", + }); + const timer = setTimeout(() => child.kill("SIGKILL"), 5000); + try { + const [exit, out, err] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]); + expect(exit).not.toBe(0); expect(out).toBe(""); + expect(err).toContain("A distinct network namespace is required."); + expect(existsSync(resolve(root, "agent"))).toBe(false); + } finally { clearTimeout(timer); if (child.exitCode === null) child.kill("SIGKILL"); await child.exited; rmSync(root, { recursive: true, force: true }); } +}); diff --git a/runtime/test/agent-control/provider-auth-cli.optional.test.ts b/runtime/test/agent-control/provider-auth-cli.optional.test.ts new file mode 100644 index 000000000..3ceb74e94 --- /dev/null +++ b/runtime/test/agent-control/provider-auth-cli.optional.test.ts @@ -0,0 +1,37 @@ +import { expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { mkdtempSync, readFileSync, readlinkSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; + +const enabled = process.env.PICLAW_RUN_AUTH_CLI_TESTS === "1" && process.env.PICLAW_E2E_DISPOSABLE === "1"; +(enabled ? test : test.skip)("packaged 0.99.1 CLI authenticates and rejects synthetic OpenAI/Codex flows with mandatory OS egress isolation", async () => { + const results = []; + for (const provider of ["openai", "openai-codex"]) for (const mode of ["success", "denied", "bad-state", "cancel"]) { + const root = mkdtempSync(join(tmpdir(), "piclaw-cli-auth-")); + const child = Bun.spawn(["sudo", "-n", "unshare", "--net", "/bin/sh", "-c", + 'ip link set lo up && exec setpriv --reuid="$1" --regid="$2" --clear-groups --bounding-set=-all --inh-caps=-all --ambient-caps=-all --no-new-privs env -i PATH="$3" HOME=/nonexistent SYNTHETIC_EXPECT_UID="$1" timeout --kill-after=2s 65s "$4" --no-env-file "$5" "$6" "$7" "$8" "$9"', + "cli-auth-namespace", String(process.getuid?.()), String(process.getgid?.()), `${dirname(process.execPath)}:/usr/bin:/bin`, process.execPath, + resolve(import.meta.dir, "fixtures/packaged-cli-auth-0991.ts"), provider, mode, root, readlinkSync("/proc/self/ns/net")], { + env: { PATH: `${dirname(process.execPath)}:/usr/bin:/bin`, HOME: "/nonexistent" }, stdout: "pipe", stderr: "pipe", + }); + const timer = setTimeout(() => child.kill("SIGKILL"), 75_000); + try { + const [exit, out, err] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]); + expect(exit, `${provider}/${mode}: ${err}`).toBe(0); expect(err).toBe(""); + const result = JSON.parse(out); + expect(result).toMatchObject({ version: "0.99.1", provider, mode, status: "pass", unexpectedFetchRequests: 0, externalEgress: "os_namespace_denied", cliExit: "restored_editor_quit_0", inference: "not_invoked" }); + expect(result.tokenRequests).toBe(["success", "denied"].includes(mode) ? 1 : 0); + const archived = JSON.parse(readFileSync(resolve(import.meta.dir, "../../../docs/design/earendil-agent-harness-integration-adr/evidence/receipts/earendil-0991-packaged-cli-auth-bun.json"), "utf8")); + expect(result.bundleSha256).toBe(archived.bundleSha256); + expect(result.sdkFileSha256).toEqual(archived.sdkFileSha256); + expect(archived.packageIntegrity).toContain("sha512-"); + expect(createHash("sha256").update(readFileSync(resolve(import.meta.dir, "../../../bun.lock"))).digest("hex")).toBe(archived.lockSha256); + results.push(result); + } finally { + clearTimeout(timer); if (child.exitCode === null) child.kill("SIGKILL"); await child.exited; + rmSync(root, { recursive: true, force: true }); + } + } + expect(results).toHaveLength(8); +}, 600_000);