From e1a89279cd749353d36af65dfb7ae3fb4c4950d2 Mon Sep 17 00:00:00 2001 From: feelsodev <59601439+feelsodev@users.noreply.github.com> Date: Fri, 11 Sep 2026 00:43:37 +0900 Subject: [PATCH 1/2] fix(auth): honor pinned accounts during direct resolution Use the stored pin when no request slot is supplied, preserving explicit API-key and slot precedence. This keeps compaction on the account selected for ordinary requests and scopes OAuth refresh to that account. Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- packages/ai/CHANGELOG.md | 2 + packages/ai/src/auth/resolve.ts | 6 +- packages/ai/src/changes.md | 18 +++ packages/ai/test/auth-pinned-resolve.test.ts | 149 ++++++++++++++++++ .../test/credential-pool-write-paths.test.ts | 7 +- packages/coding-agent/CHANGELOG.md | 2 + 6 files changed, 179 insertions(+), 5 deletions(-) create mode 100644 packages/ai/test/auth-pinned-resolve.test.ts diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 6934f26190..41244c00da 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -10,6 +10,8 @@ ### Fixed +- Direct authentication resolution now honors the stored pinned account when no slot is explicitly requested. Explicit request keys and slots retain precedence, and OAuth refresh updates only the selected slot. + ### Removed ## [2026.9.10] - 2026-09-10 diff --git a/packages/ai/src/auth/resolve.ts b/packages/ai/src/auth/resolve.ts index 304eaf683d..09b7f4b7c5 100644 --- a/packages/ai/src/auth/resolve.ts +++ b/packages/ai/src/auth/resolve.ts @@ -7,7 +7,7 @@ import { projectOAuthSlot, refreshOAuthCredential, } from "./oauth-refresh.ts"; -import { projectSlot } from "./pool/slots.ts"; +import { type PooledCredential, projectSlot } from "./pool/slots.ts"; import type { ApiKeyAuth, ApiKeyCredential, @@ -98,8 +98,8 @@ async function resolveProviderAuthWithSignal( ); } - const stored = await readCredential(credentials, provider.id, signal); - const slotName = overrides?.slotName; + const stored: PooledCredential | undefined = await readCredential(credentials, provider.id, signal); + const slotName = overrides?.slotName ?? stored?.pinned; if (slotName !== undefined) { const projected = stored === undefined ? undefined : projectSlot(stored, slotName); if (!projected) return undefined; diff --git a/packages/ai/src/changes.md b/packages/ai/src/changes.md index 178fd415a0..167b8acd54 100644 --- a/packages/ai/src/changes.md +++ b/packages/ai/src/changes.md @@ -1,3 +1,21 @@ +## 2026-09-10 - Honor pinned accounts during direct auth resolution + +### What changed + +- `packages/ai/src/auth/resolve.ts`: resolve an explicit slot first, otherwise the stored pinned slot, before using the flat credential. Explicit request API keys retain precedence. Missing pinned slots do not fall back to another identity, and OAuth refresh targets the selected slot. + +### Why + +- Compaction resolves auth directly rather than through the ordinary request pool. It used the flat default account even when another usable account was pinned, so an exhausted default account blocked compaction while ordinary conversation succeeded. + +### Why an extension could not handle it + +- `packages/ai/src/auth/resolve.ts` owns the shared credential resolution below `ModelRegistry.getApiKeyAndHeaders()` and the text/image model collections. An extension cannot correct this account choice for every direct caller. + +### Expected merge conflict zones + +- LOW: the pooled-credential import and slot selection in `packages/ai/src/auth/resolve.ts`. + ## 2026-09-10 - Map ask_user_question to Claude Code's AskUserQuestion wire name ### What changed diff --git a/packages/ai/test/auth-pinned-resolve.test.ts b/packages/ai/test/auth-pinned-resolve.test.ts new file mode 100644 index 0000000000..5c714d6927 --- /dev/null +++ b/packages/ai/test/auth-pinned-resolve.test.ts @@ -0,0 +1,149 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { InMemoryCredentialStore } from "../src/auth/credential-store.ts"; +import { envApiKeyAuth } from "../src/auth/helpers.ts"; +import { listSlots, type PooledCredential } from "../src/auth/pool/slots.ts"; +import { resolveProviderAuth } from "../src/auth/resolve.ts"; +import type { OAuthCredential, ProviderAuth } from "../src/auth/types.ts"; + +const NOW = 1_800_000_000_000; +const FUTURE = NOW + 3_600_000; +const authContext = { env: async () => "ambient-key", fileExists: async () => false }; +const provider = { + id: "pinned-test", + auth: { + apiKey: envApiKeyAuth("Test key", ["TEST_KEY"]), + oauth: { + name: "Test OAuth", + login: async () => { + throw new Error("Login is not part of auth resolution"); + }, + refresh: async (credential: OAuthCredential) => ({ + ...credential, + access: `${credential.access}-renewed`, + refresh: `${credential.refresh}-renewed`, + expires: FUTURE, + }), + toAuth: async (credential: OAuthCredential) => ({ apiKey: credential.access }), + }, + } satisfies ProviderAuth, +}; + +function pooledCredential(type: "oauth" | "api_key"): PooledCredential { + if (type === "api_key") { + return { + type, + key: "default-key", + pinned: "login-6", + accounts: [ + { name: "default", key: "default-key" }, + { name: "login-6", key: "selected-key" }, + ], + }; + } + return { + type, + access: "default-key", + refresh: "default-refresh", + expires: FUTURE, + pinned: "login-6", + accounts: [ + { name: "default", access: "default-key", refresh: "default-refresh", expires: FUTURE }, + { name: "login-6", access: "selected-key", refresh: "selected-refresh", expires: FUTURE }, + ], + }; +} + +describe("stored pinned account auth resolution", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + }); + afterEach(() => vi.useRealTimers()); + + for (const type of ["oauth", "api_key"] as const) { + describe(type, () => { + it("uses the pin when the caller does not select a slot", async () => { + // given + const store = new InMemoryCredentialStore(); + const entry = pooledCredential(type); + await store.modify(provider.id, async () => entry); + // when + const resolved = await resolveProviderAuth(provider, store, authContext); + // then + expect(resolved?.auth.apiKey).toBe("selected-key"); + expect(await store.read(provider.id)).toEqual(entry); + }); + + it("lets an explicit slot override the pin", async () => { + // given + const store = new InMemoryCredentialStore(); + await store.modify(provider.id, async () => pooledCredential(type)); + // when + const resolved = await resolveProviderAuth(provider, store, authContext, { slotName: "default" }); + // then + expect(resolved?.auth.apiKey).toBe("default-key"); + }); + + it("lets an explicit request key override the pin and slot", async () => { + // given + const store = new InMemoryCredentialStore(); + await store.modify(provider.id, async () => pooledCredential(type)); + // when + const resolved = await resolveProviderAuth(provider, store, authContext, { + apiKey: "request-key", + slotName: "default", + }); + // then + expect(resolved?.auth.apiKey).toBe("request-key"); + }); + + it("keeps the flat default when no pin is saved", async () => { + // given + const store = new InMemoryCredentialStore(); + const { pinned: _pinned, ...entry } = pooledCredential(type); + await store.modify(provider.id, async () => entry); + // when + const resolved = await resolveProviderAuth(provider, store, authContext); + // then + expect(resolved?.auth.apiKey).toBe("default-key"); + }); + + it("does not fall back to default or ambient auth for a missing pin", async () => { + // given + const store = new InMemoryCredentialStore(); + await store.modify(provider.id, async () => ({ ...pooledCredential(type), pinned: "missing" })); + // when + const resolved = await resolveProviderAuth(provider, store, authContext); + // then + expect(resolved).toBeUndefined(); + }); + }); + } + + it("refreshes the pinned OAuth slot without changing the default account", async () => { + // given + const store = new InMemoryCredentialStore(); + const entry = pooledCredential("oauth"); + await store.modify(provider.id, async () => ({ + ...entry, + accounts: entry.accounts?.map((slot) => (slot.name === "login-6" ? { ...slot, expires: NOW - 1 } : slot)), + })); + // when + const resolved = await resolveProviderAuth(provider, store, authContext); + // then + expect(resolved?.auth.apiKey).toBe("selected-key-renewed"); + const stored: PooledCredential | undefined = await store.read(provider.id); + expect(stored).toMatchObject({ + access: "default-key", + refresh: "default-refresh", + expires: FUTURE, + pinned: "login-6", + }); + expect(listSlots(stored).find((slot) => slot.name === "default")).toEqual(entry.accounts?.[0]); + expect(listSlots(stored).find((slot) => slot.name === "login-6")).toMatchObject({ + access: "selected-key-renewed", + refresh: "selected-refresh-renewed", + expires: FUTURE, + }); + }); +}); diff --git a/packages/ai/test/credential-pool-write-paths.test.ts b/packages/ai/test/credential-pool-write-paths.test.ts index 7a0f80fa26..a1724c57f7 100644 --- a/packages/ai/test/credential-pool-write-paths.test.ts +++ b/packages/ai/test/credential-pool-write-paths.test.ts @@ -112,12 +112,15 @@ describe("OAuth refresh keeps sibling slots", () => { api: "openai-responses" as never, }); - test("resolveProviderAuth refreshes the matching slot and leaves siblings byte-identical", async () => { + test("resolveProviderAuth refreshes an explicitly selected default slot and leaves siblings byte-identical", async () => { + // given const store = new InMemoryCredentialStore(); await store.modify("pooloauth", async () => pooledOAuthEntry()); - const resolved = await resolveProviderAuth(oauthProvider, store, authContext); + // when + const resolved = await resolveProviderAuth(oauthProvider, store, authContext, { slotName: "default" }); + // then expect(resolved?.auth.apiKey).toBe("refreshed-r1"); const stored = (await store.read("pooloauth")) as PooledCredential; expect(listSlots(stored).find((slot) => slot.name === "work")).toMatchObject({ diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 39feee5c00..fafab679ae 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -10,6 +10,8 @@ ### Fixed +- Context compaction now uses the pinned account instead of the flat default credential, preventing an exhausted default account from blocking compaction while ordinary conversation uses another account. + - A bare `.` submitted on a session that already has messages no longer renders as a user message in the TUI. It stays the manual-continue shortcut the session delivers as a hidden continuation, so nothing is painted for it while idle or while steering an active turn; a `.` on an empty session and a `.` carrying image attachments remain ordinary user input ([#1569](https://github.com/code-yeongyu/senpi/issues/1569)) - A model switch the session refuses is now recorded instead of vanishing: every From e62d9901729b56926f342aac2d235093e4e84633 Mon Sep 17 00:00:00 2001 From: feelsodev <59601439+feelsodev@users.noreply.github.com> Date: Fri, 11 Sep 2026 00:44:05 +0900 Subject: [PATCH 2/2] test(qa): reproduce pinned-account compaction through the CLI Drive the real source CLI with isolated synthetic OAuth accounts and a localhost quota fixture. Assert compaction account selection, committed summary replay, and complete sandbox cleanup without real provider calls. Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- .../senpi-qa/scripts/pinned-compaction-qa.mjs | 297 ++++++++++++++++++ 1 file changed, 297 insertions(+) create mode 100644 .agents/skills/senpi-qa/scripts/pinned-compaction-qa.mjs diff --git a/.agents/skills/senpi-qa/scripts/pinned-compaction-qa.mjs b/.agents/skills/senpi-qa/scripts/pinned-compaction-qa.mjs new file mode 100644 index 0000000000..58e335d9cc --- /dev/null +++ b/.agents/skills/senpi-qa/scripts/pinned-compaction-qa.mjs @@ -0,0 +1,297 @@ +#!/usr/bin/env node +/** Real-CLI OAuth pin regression: --self-test --expect red|green. */ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { createHash, randomUUID } from "node:crypto"; +import { EventEmitter, once } from "node:events"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { createServer } from "node:http"; +import { join } from "node:path"; +import { createInterface } from "node:readline"; +import { cliEntry, guardRealAuth, makeSandbox, repoRoot, tsxEntry } from "./lib/common.mjs"; +import { hermeticEnv } from "./lib/mock-loop-support.mjs"; + +const root = repoRoot(); +const evidence = join(root, "local-ignore/qa-evidence/20260910-pinned-compaction"); +const provider = "pinned-qa"; +const model = "pinned-model"; +const summary = "PINNED_QA_SUMMARY_OK"; +const exhausted = "QA_DEFAULT_USAGE_LIMIT_EXHAUSTED"; +const deadline = () => AbortSignal.timeout(25_000); +const payloads = [ + { id: "state", type: "get_state" }, + { id: "prompt-before", type: "prompt", message: "PINNED_QA_BEFORE" }, + { id: "text-before", type: "get_last_assistant_text" }, + { id: "compact", type: "compact", customInstructions: "PINNED_QA_COMPACT" }, + { id: "messages-compact", type: "get_messages" }, + { id: "prompt-after", type: "prompt", message: "PINNED_QA_AFTER" }, + { id: "text-after", type: "get_last_assistant_text" }, +]; + +function sourceReceipt() { + const source = readFileSync(join(root, "packages/ai/src/auth/resolve.ts"), "utf8"); + return { + path: "packages/ai/src/auth/resolve.ts", + sha256: createHash("sha256").update(source).digest("hex"), + implicitPinExpression: /overrides\?\.slotName\s*\?\?\s*stored\?\.pinned/.test(source), + }; +} + +function writeSse(res, text) { + res.writeHead(200, { "content-type": "text/event-stream" }); + const chunk = (delta, finishReason = null, usage) => res.write(`data: ${JSON.stringify({ + id: "pinned-qa-response", object: "chat.completion.chunk", created: 1, model, + choices: [{ index: 0, delta, finish_reason: finishReason }], + ...(usage ? { usage } : {}), + })}\n\n`); + chunk({ role: "assistant", content: text }); + chunk({}, "stop", { prompt_tokens: 2000, completion_tokens: 100, total_tokens: 2100 }); + res.end("data: [DONE]\n\n"); +} + +async function run(expectation) { + mkdirSync(evidence, { recursive: true }); + const guard = guardRealAuth(); + const box = makeSandbox("pinned-compaction-qa"); + const credentials = { default: randomUUID(), "login-6": randomUUID() }; + const receipt = { + command: `node .agents/skills/senpi-qa/scripts/pinned-compaction-qa.mjs --self-test --expect ${expectation}`, + expectation, startedAt: new Date().toISOString(), sourceBefore: sourceReceipt(), + rpcInputs: [], requests: [], checks: {}, cleanup: {}, + }; + let phase = "startup"; + let child; + let childClosed = false; + let server; + let lines; + let failure; + const bus = new EventEmitter(); + const lifetime = new AbortController(); + const onSignal = () => lifetime.abort(new Error("QA interrupted")); + process.once("SIGINT", onSignal); + process.once("SIGTERM", onSignal); + const signal = () => AbortSignal.any([deadline(), lifetime.signal]); + const safe = (value) => { + let text = String(value); + for (const secret of Object.values(credentials)) text = text.replaceAll(secret, "[redacted]"); + return text.replace(/(?:Bearer|Authorization|x-api-key)\s*[:=]?\s*[^\s,}\]]+/gi, "[redacted-header]"); + }; + const check = (name, condition) => { + receipt.checks[name] = Boolean(condition); + assert.ok(condition, name); + }; + try { + server = createServer((req, res) => { + const chunks = []; + req.on("error", () => lifetime.abort(new Error("local HTTP request failed"))); + req.on("data", (chunk) => chunks.push(chunk)); + req.on("end", () => { + try { + const body = JSON.parse(Buffer.concat(chunks).toString("utf8")); + const label = Object.keys(credentials).find((name) => req.headers.authorization === `Bearer ${credentials[name]}`) ?? "unknown"; + const status = label === "login-6" ? 200 : label === "default" ? 402 : 401; + receipt.requests.push({ + phase, account: label, method: req.method, path: req.url, status, + summaryReplayed: JSON.stringify(body.messages).includes(summary), + }); + assert.equal(req.url, "/v1/chat/completions"); + assert.equal(body.model, model); + if (status !== 200) { + res.writeHead(status, { "content-type": "application/json" }); + res.end(JSON.stringify({ error: { + message: label === "default" ? `${exhausted}: account usage limit exhausted` : "QA_UNKNOWN_ACCOUNT", + type: "insufficient_quota", code: "insufficient_quota", + } })); + return; + } + const text = phase === "compact" ? summary : phase === "after" ? "PINNED_QA_AFTER_OK" : "PINNED_QA_BEFORE_OK"; + writeSse(res, text); + } catch (error) { + lifetime.abort(error); + res.destroy(); + } + }); + }); + const listening = once(server, "listening", { signal: signal() }); + server.listen(0, "127.0.0.1"); + await listening; + const baseUrl = `http://127.0.0.1:${server.address().port}/v1`; + const expires = Date.now() + 86_400_000; + writeFileSync(join(box.agentDir, "auth.json"), JSON.stringify({ [provider]: { + type: "oauth", access: credentials.default, refresh: randomUUID(), expires, + accounts: Object.entries(credentials).map(([name, access]) => ({ name, access, refresh: randomUUID(), expires, source: "login" })), + pinned: "login-6", + } }), { mode: 0o600 }); + writeFileSync(join(box.agentDir, "settings.json"), JSON.stringify({ + compaction: { enabled: false, idleCompactionEnabled: false, keepRecentTokens: 40 }, + retry: { enabled: false }, disabledBuiltinExtensions: ["cache-keepalive"], + })); + const sessionPath = join(box.sessionDir, "history.jsonl"); + const timestamp = new Date().toISOString(); + const history = [{ type: "session", version: 3, id: randomUUID(), timestamp, cwd: box.cwd }]; + let parentId = null; + for (let index = 0; index < 4; index++) { + const id = randomUUID().slice(0, 8); + const text = Array.from({ length: 64 }, (_, part) => + `Archived record ${index}, detail ${part}: ${createHash("sha256").update(`${index}:${part}`).digest("hex")}`).join("\n"); + const message = index % 2 === 0 + ? { role: "user", content: [{ type: "text", text }], timestamp: Date.now() } + : { role: "assistant", content: [{ type: "text", text }], timestamp: Date.now(), + api: "openai-completions", provider, model, stopReason: "stop", + usage: { input: 1000, output: 1000, cacheRead: 0, cacheWrite: 0, totalTokens: 2000, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } } }; + history.push({ type: "message", id, parentId, timestamp, message }); + parentId = id; + } + writeFileSync(sessionPath, `${history.map((entry) => JSON.stringify(entry)).join("\n")}\n`); + const extension = join(box.dir, "oauth-fixture.mjs"); + writeFileSync(extension, `export default function(pi) { + pi.registerProvider(${JSON.stringify(provider)}, { + baseUrl: ${JSON.stringify(baseUrl)}, api: "openai-completions", + models: [{ id: ${JSON.stringify(model)}, name: "Pinned QA", reasoning: false, input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 128000, maxTokens: 4096 }], + oauth: { + name: "Synthetic QA OAuth", + async login() { throw new Error("QA_LOGIN_FORBIDDEN"); }, + async refreshToken() { throw new Error("QA_REFRESH_UNEXPECTED"); }, + getApiKey(credential) { return credential.access; } + } + }); +} +`); + const sandboxEnv = hermeticEnv(box.env); + // Allowlist prevents unknown provider keys, parent branding, proxies, NODE_OPTIONS, + // or shell hooks from crossing the sandbox boundary. + const env = Object.fromEntries([ + "PATH", "TMPDIR", "LANG", "LC_ALL", "HOME", "USERPROFILE", + "SENPI_CODING_AGENT_DIR", "SENPI_CODING_AGENT_SESSION_DIR", "SENPI_OMO_LOCAL_UPDATE", + "PI_OFFLINE", "PI_TELEMETRY", + ].filter((name) => sandboxEnv[name] !== undefined).map((name) => [name, sandboxEnv[name]])); + Object.assign(env, { XDG_CONFIG_HOME: box.dir, XDG_CACHE_HOME: box.dir, TSX_DISABLE_CACHE: "1" }); + const argv = [tsxEntry(root), "--tsconfig", join(root, "tsconfig.json"), cliEntry(root), + "--mode", "rpc", "--session", sessionPath, "--no-context-files", "--no-extensions", "--no-skills", + "--extension", extension, "--provider", provider, "--model", model]; + receipt.childCommand = [process.execPath, ...argv]; + child = spawn(process.execPath, argv, { cwd: box.cwd, env, detached: true, stdio: ["pipe", "pipe", "pipe"] }); + child.on("error", (error) => lifetime.abort(error)); + child.on("close", (code, exitSignal) => { + childClosed = true; + receipt.childExit = { code, signal: exitSignal }; + lifetime.abort(new Error("CLI closed")); + }); + receipt.stderrBytes = 0; + child.stderr.on("data", (chunk) => { receipt.stderrBytes += chunk.length; }); + lines = createInterface({ input: child.stdout }); + lines.on("line", (line) => { + if (!line.startsWith("{")) return; + try { + const message = JSON.parse(line); + bus.emit(message.type === "response" ? `response:${message.id}` : message.type, message); + } catch (error) { lifetime.abort(error); } + }); + const send = async (payload) => { + const response = once(bus, `response:${payload.id}`, { signal: signal() }); + receipt.rpcInputs.push(payload); + child.stdin.write(`${JSON.stringify(payload)}\n`); + return (await response)[0]; + }; + const prompt = async (payload) => { + const ended = once(bus, "agent_end", { signal: signal() }); + const [response, [event]] = await Promise.all([send(payload), ended]); + check(`${phase} prompt accepted`, response.success === true); + const assistants = (event.messages ?? []).filter((message) => message.role === "assistant"); + receipt[`${phase}Turn`] = { + aborted: event.aborted === true, + outcomes: assistants.map((message) => ({ stopReason: message.stopReason, error: message.errorMessage ? safe(message.errorMessage) : null })), + }; + check(`${phase} prompt finished without error`, assistants.length > 0 && assistants.every((message) => message.stopReason === "stop")); + }; + const state = await send(payloads[0]); + check("worktree CLI selected fixture model", state.success && state.data?.model?.provider === provider && state.data.model.id === model); + phase = "before"; + await prompt(payloads[1]); + const beforeText = await send(payloads[2]); + check("ordinary pinned prompt succeeded", beforeText.success && beforeText.data?.text?.includes("PINNED_QA_BEFORE_OK")); + phase = "compact"; + const compact = await send(payloads[3]); + receipt.compactResponse = JSON.parse(safe(JSON.stringify(compact))); + const messages = await send(payloads[4]); + const committed = (messages.data?.messages ?? []).some((message) => message.role === "compactionSummary" && message.summary?.includes(summary)); + receipt.compactionCommitted = committed; + check("compacted messages RPC succeeded", messages.success === true); + phase = "after"; + await prompt(payloads[5]); + const afterText = await send(payloads[6]); + check("resumed pinned prompt succeeded", afterText.success && afterText.data?.text === "PINNED_QA_AFTER_OK"); + const ordinary = receipt.requests.filter((request) => request.phase === "before" || request.phase === "after"); + check("ordinary requests only used login-6", ordinary.length === 2 && ordinary.every((request) => request.account === "login-6" && request.status === 200)); + const compactions = receipt.requests.filter((request) => request.phase === "compact"); + check("explicit compact reached localhost", compactions.length > 0); + if (expectation === "red") { + check("RED compaction used exhausted default", compactions.length === 1 && compactions[0].account === "default" && compactions[0].status === 402); + // Core deliberately collapses an extension's rejection reason into a + // generic RPC error. The HTTP receipt above proves quota exhaustion; + // assert the machine-consumed failure flag, not the error prose. + check("RED explicit compact failed after usage exhaustion", compact.success === false); + check("RED did not commit compaction", !committed); + } else { + check("GREEN compaction only used login-6", compactions.every((request) => request.account === "login-6" && request.status === 200)); + check("GREEN explicit compact succeeded", compact.success === true && compact.data?.details?.schema === "senpi.compaction.summary.v1" && typeof compact.data?.firstKeptEntryId === "string"); + check("GREEN compaction committed", committed); + check("GREEN compaction removed old context", compact.data.details.structuralYield.savedTokens > 0); + check("GREEN resumed request replayed summary", ordinary.at(-1).summaryReplayed); + } + } catch (error) { + failure = error; + receipt.failure = { name: error.name, message: safe(error.message) }; + } finally { + // Never leave the tsx supervisor or its CLI descendant alive. + if (child?.pid) { + try { + const closed = childClosed ? Promise.resolve() : once(child, "close", { signal: deadline() }); + try { process.kill(-child.pid, "SIGKILL"); } + catch (error) { if (error.code !== "ESRCH") throw error; } + await closed; + receipt.cleanup.cliClosed = childClosed; + try { process.kill(-child.pid, 0); receipt.cleanup.processGroupGone = false; } + catch (error) { if (error.code !== "ESRCH") throw error; receipt.cleanup.processGroupGone = true; } + } catch (error) { receipt.cleanup.cliError = error.name; failure ??= error; } + } else receipt.cleanup.cliClosed = receipt.cleanup.processGroupGone = true; + lines?.close(); + if (server?.listening) { + try { + const closed = once(server, "close", { signal: deadline() }); + server.closeAllConnections(); + server.close(); + await closed; + } catch (error) { receipt.cleanup.serverError = error.name; failure ??= error; } + } + receipt.cleanup.serverClosed = !server?.listening; + box.cleanup(); + receipt.cleanup.sandboxRemoved = !existsSync(box.dir); + try { receipt.cleanup.realAuthUntouched = guard.assertUnchanged(); } + catch (error) { receipt.cleanup.realAuthUntouched = false; failure ??= error; } + receipt.sourceAfter = sourceReceipt(); + receipt.checks["resolver source unchanged during run"] = receipt.sourceBefore.sha256 === receipt.sourceAfter.sha256; + process.removeListener("SIGINT", onSignal); + process.removeListener("SIGTERM", onSignal); + receipt.passed = !failure && Object.values(receipt.checks).every(Boolean) && + ["cliClosed", "processGroupGone", "serverClosed", "sandboxRemoved", "realAuthUntouched"].every((key) => receipt.cleanup[key] === true); + receipt.finishedAt = new Date().toISOString(); + writeFileSync(join(evidence, `cli-${expectation}.json`), `${JSON.stringify(receipt, null, 2)}\n`); + } + for (const [name, pass] of Object.entries(receipt.checks)) console.log(`[${pass ? "PASS" : "FAIL"}] ${name}`); + console.log(JSON.stringify({ expectation, passed: receipt.passed, cleanup: receipt.cleanup, failure: receipt.failure ?? null })); + return receipt.passed; +} + +const args = process.argv.slice(2); +const expectation = args.includes("--expect") ? args[args.indexOf("--expect") + 1] : "green"; +if (!args.includes("--self-test") || !["red", "green"].includes(expectation) || + args.some((arg, index) => !["--self-test", "--expect"].includes(arg) && args[index - 1] !== "--expect")) { + console.error("usage: node pinned-compaction-qa.mjs --self-test [--expect red|green]"); + process.exitCode = 2; +} else { + try { process.exitCode = await run(expectation) ? 0 : 1; } + catch (error) { console.error(`QA setup failed (${error.name})`); process.exitCode = 1; } +}