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
18 changes: 17 additions & 1 deletion docs/config/piclaw-env-observations.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@
"semanticReaderOccurrences": 69
},
"docsDeploy": {
"referencedNames": 141,
"referencedNames": 142,
"directReaderNames": 16,
"directReaderOccurrences": 32,
"helperReaderNames": 0,
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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": {
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
46 changes: 46 additions & 0 deletions runtime/test/agent-control/fixtures/cli-auth-preload-0991.mjs
Original file line number Diff line number Diff line change
@@ -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(); },
});
Loading
Loading