From 7b82b1654e1b4929ca4b77acf365f54c932e1d98 Mon Sep 17 00:00:00 2001 From: Ingwannu Date: Mon, 3 Aug 2026 00:56:28 +0000 Subject: [PATCH 001/317] fix(google): validate Vertex location before ADC auth --- src/adapters/google.ts | 3 ++ src/providers/google-vertex-location.ts | 14 +++++++ src/server/auth-cors.ts | 7 +++- tests/gcp-adc.test.ts | 49 +++++++++++++++++++++++++ 4 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 src/providers/google-vertex-location.ts diff --git a/src/adapters/google.ts b/src/adapters/google.ts index c9d7aedfcf..00ae93c1b5 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -23,6 +23,7 @@ import { compileGoogleWireBody } from "./google-wire-compiler"; import { identifyRoutedModel } from "./identity"; import { antigravityUsesReplayCache, applyAntigravityReplay, clearAntigravityReplay, observeAntigravityReplay } from "./google-antigravity-replay"; import { resolveAntigravityEffortWireModel } from "../providers/antigravity-models"; +import { googleVertexLocationConfigError } from "../providers/google-vertex-location"; import { isTranslatorBudgetExceededError, retainTranslatedEventBatch, @@ -404,6 +405,8 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte if (!project) throw new Error("Vertex AI requires a project id (provider.project or GOOGLE_CLOUD_PROJECT/GCLOUD_PROJECT)."); const location = provider.location || process.env.GOOGLE_CLOUD_LOCATION; if (!location) throw new Error("Vertex AI requires a location (provider.location or GOOGLE_CLOUD_LOCATION)."); + const locationError = googleVertexLocationConfigError(location); + if (locationError) throw new Error(locationError); const host = location === "global" ? "aiplatform.googleapis.com" : `${location}-aiplatform.googleapis.com`; const url = `https://${host}/v1/projects/${project}/locations/${location}/publishers/google/models/${parsed.modelId}:${method}${streamParam}`; const token = await getVertexAccessToken(); diff --git a/src/providers/google-vertex-location.ts b/src/providers/google-vertex-location.ts new file mode 100644 index 0000000000..fb0656f1bc --- /dev/null +++ b/src/providers/google-vertex-location.ts @@ -0,0 +1,14 @@ +/** + * Vertex regional hosts are formed as `-aiplatform.googleapis.com`. + * Restricting the location to one lowercase DNS label keeps user configuration + * from changing the request authority while remaining forward-compatible with + * new Google regions and multi-regions. + */ +const GOOGLE_VERTEX_LOCATION_LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; + +export function googleVertexLocationConfigError(location: unknown): string | null { + if (typeof location !== "string" || !GOOGLE_VERTEX_LOCATION_LABEL.test(location)) { + return "Vertex AI location must be a single lowercase Google Cloud location label (for example, us-central1 or global)"; + } + return null; +} diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 79b02c3292..7b707cdf36 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -13,10 +13,11 @@ import { reasoningSummaryDeliveryRecordConfigError, } from "../config"; import { providerDestinationConfigError } from "../lib/destination-policy"; -import { getProviderRegistryEntry, providerCodexAccountMode, providerMatchesRegistryTransport, registryEntryForProviderDestination } from "../providers/registry"; +import { effectiveGoogleMode, getProviderRegistryEntry, providerCodexAccountMode, providerMatchesRegistryTransport, registryEntryForProviderDestination } from "../providers/registry"; import { providerConfigSeed } from "../providers/derive"; import type { OcxConfig, OcxProviderConfig } from "../types"; import { openRouterRoutingConfigError } from "../providers/openrouter-routing"; +import { googleVertexLocationConfigError } from "../providers/google-vertex-location"; let _corsOrigin = "http://localhost:10100"; export function setCorsOrigin(port: number): void { _corsOrigin = `http://localhost:${port}`; } @@ -416,6 +417,10 @@ export function providerManagementConfigError(name: unknown, provider: unknown): const typed = provider as unknown as OcxProviderConfig; const baseUrlError = providerBaseUrlConfigError(typed.baseUrl); if (baseUrlError) return `provider ${name} ${baseUrlError}`; + if (effectiveGoogleMode(name, typed) === "vertex" && typed.location !== undefined) { + const locationError = googleVertexLocationConfigError(typed.location); + if (locationError) return `provider ${name} ${locationError}`; + } const destinationError = providerDestinationConfigError(name, typed); if (destinationError) return `provider ${name} ${destinationError}`; const headersError = providerHeadersConfigError(typed.headers); diff --git a/tests/gcp-adc.test.ts b/tests/gcp-adc.test.ts index 387fa47fcf..c23b11df23 100644 --- a/tests/gcp-adc.test.ts +++ b/tests/gcp-adc.test.ts @@ -5,6 +5,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { getVertexAccessToken, __resetVertexTokenCache } from "../src/lib/gcp-adc"; import { createGoogleAdapter } from "../src/adapters/google"; +import { providerManagementConfigError } from "../src/server/auth-cors"; import type { OcxParsedRequest, OcxProviderConfig } from "../src/types"; import { STATE_STORE_REGISTRATIONS } from "../src/lib/state-store-registrations"; @@ -149,6 +150,54 @@ describe("google adapter vertex mode", () => { expect(req.url).toBe("https://aiplatform.googleapis.com/v1/projects/proj-1/locations/global/publishers/google/models/gemini-3-pro:streamGenerateContent?alt=sse"); }); + test("vertex + ADC rejects a location that can alter the request authority before fetching a token", async () => { + setEnv("GOOGLE_APPLICATION_CREDENTIALS", saPath); + const provider = { + adapter: "google", + baseUrl: "https://x", + googleMode: "vertex", + project: "proj-1", + location: "attacker.example:443/capture#", + } as OcxProviderConfig; + await expect(createGoogleAdapter(provider).buildRequest(parsed())).rejects.toThrow( + "Vertex AI location must be a single lowercase Google Cloud location label", + ); + expect(oauthCalls).toBe(0); + }); + + test("provider management rejects unsafe Vertex locations, including registry-backfilled mode", () => { + const explicit = { + adapter: "google", + baseUrl: "https://aiplatform.googleapis.com", + googleMode: "vertex", + location: "attacker.example/path", + } as OcxProviderConfig; + expect(providerManagementConfigError("custom-vertex", explicit)).toContain( + "Vertex AI location must be a single lowercase Google Cloud location label", + ); + + const registryBackfilled = { + adapter: "google", + baseUrl: "https://aiplatform.googleapis.com", + location: "attacker.example/path", + } as OcxProviderConfig; + expect(providerManagementConfigError("google-vertex", registryBackfilled)).toContain( + "Vertex AI location must be a single lowercase Google Cloud location label", + ); + }); + + test("provider management preserves legitimate Vertex locations", () => { + for (const location of ["global", "us-central1", "europe-west4", "us"]) { + const provider = { + adapter: "google", + baseUrl: "https://aiplatform.googleapis.com", + googleMode: "vertex", + location, + } as OcxProviderConfig; + expect(providerManagementConfigError("custom-vertex", provider)).toBeNull(); + } + }); + test("ai-studio default mode is unchanged (no regression)", async () => { const provider = { adapter: "google", baseUrl: "https://generativelanguage.googleapis.com", apiKey: "ai-key" } as OcxProviderConfig; const req = await createGoogleAdapter(provider).buildRequest(parsed()); From ee14555f4a8533b9b8780f885fc9f7c15a67c017 Mon Sep 17 00:00:00 2001 From: Ingwannu Date: Mon, 3 Aug 2026 01:47:28 +0000 Subject: [PATCH 002/317] fix: harden local credential and runtime boundaries --- .github/workflows/ci.yml | 5 +- bin/ocx.mjs | 27 ++++-- src/cli/claude.ts | 47 +++++----- src/cli/index.ts | 8 +- src/cli/launcher-context.ts | 77 ++++++++++++++++ src/config.ts | 7 +- src/lib/bun-runtime.ts | 38 ++++---- src/lib/local-management-attestation.ts | 51 ++++++++++ src/oauth/health.ts | 48 +++++++++- src/server/index.ts | 20 +++- structure/00_overview.md | 2 +- structure/01_runtime.md | 12 ++- structure/05_gui-and-management-api.md | 14 +++ structure/06_docs-and-release.md | 19 +++- tests/bun-runtime.test.ts | 48 ++++++---- tests/claude-auth-mode.test.ts | 66 +++++++++---- tests/claude-cli.test.ts | 2 +- ...claude-dotenv-provenance-transport.test.ts | 92 ++++++++++--------- tests/cli-catalog-prewarm.test.ts | 2 +- tests/config.test.ts | 10 +- tests/local-management-attestation.test.ts | 29 ++++++ tests/oauth-health.test.ts | 49 +++++++++- tests/ocx-launcher-source.test.ts | 11 ++- tests/server-management-auth.test.ts | 20 ++++ tests/service.test.ts | 50 ++++++---- tests/update-notify.test.ts | 2 +- tests/update-stop-first.test.ts | 2 +- 27 files changed, 584 insertions(+), 174 deletions(-) create mode 100644 src/cli/launcher-context.ts create mode 100644 src/lib/local-management-attestation.ts create mode 100644 tests/local-management-attestation.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 51014e332d..9f66b247f8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,8 +72,9 @@ jobs: # only one available here. GitHub's own guidance is to avoid self-hosted # runners on public repositories for this reason. # - # So read the routing below as a COST control that keeps honest pull requests - # on GitHub-hosted runners, not as a guarantee about hostile ones. + # So read the routing below as a STABILITY/OPERATIONS control that keeps + # honest pull requests on GitHub-hosted runners and lets trusted branch runs + # avoid the hosted-Windows Bun crashes. It is not the security boundary. # # `push` on dev/main/preview requires the push permission, and # `workflow_dispatch` requires write access, so both carry a trusted author. diff --git a/bin/ocx.mjs b/bin/ocx.mjs index c4fd076808..bccc74813c 100755 --- a/bin/ocx.mjs +++ b/bin/ocx.mjs @@ -9,6 +9,7 @@ * src/cli/index.ts — only the published npm `bin` routes through here.) */ import { spawn, spawnSync } from "node:child_process"; +import { randomBytes } from "node:crypto"; import { createRequire } from "node:module"; import { existsSync, readFileSync, readdirSync } from "node:fs"; import { homedir } from "node:os"; @@ -22,6 +23,8 @@ const PKG = "@bitkyc08/opencodex"; const require = createRequire(import.meta.url); const here = dirname(fileURLToPath(import.meta.url)); const cliPath = join(here, "..", "src", "cli", "index.ts"); +const NODE_LAUNCH_CONTEXT_ENV = "OCX_NODE_LAUNCH_CONTEXT"; +const NODE_LAUNCH_PROOF_PREFIX = "--ocx-internal-launch-proof="; function isNodeModulesInstall() { return here.split(/[\\/]/).includes("node_modules"); @@ -407,22 +410,28 @@ const bun = bunRuntime.path; // Provenance seam for issue #701: THIS launcher runs under Node, which does not // auto-load a project `.env`/`.env.local`; the Bun child does, before any opencodex // code evaluates. So this is the last point that can still tell a real shell export -// from a working-directory dotenv value, and we record which Anthropic credential -// slots already existed. `src/cli/claude.ts` then treats anything present in the Bun -// child but missing from this list as ambient project pollution rather than user auth, +// from a working-directory dotenv value, and we record which Anthropic credential or +// destination slots already existed. The context is paired with a random proof carried +// in argv, which project dotenv cannot modify during an ordinary `ocx` invocation. +// `src/cli/claude.ts` treats anything present in the Bun child but missing from this +// list as ambient project pollution rather than user auth or destination, // which stopped a project dotenv from silently moving a claude.ai subscriber onto API -// billing. An EMPTY value is meaningful (the launcher ran and saw no slots) and is -// distinct from the variable being absent (no launcher at all — change nothing), so -// this must stay a plain assignment and never be collapsed to a falsy check. +// billing and prevents it from redirecting the subscriber's OAuth bearer. // Disabling Bun's dotenv wholesale with --no-env-file is NOT an option: config // interpolation and provider settings legitimately read the project environment. -const preBunAnthropicSlots = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"] +const preBunAnthropicSlots = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL"] .filter(name => typeof process.env[name] === "string" && process.env[name] !== ""); -const child = spawn(bun, [cliPath, ...process.argv.slice(2)], { +const launchProof = randomBytes(32).toString("base64url"); +const launchContext = JSON.stringify({ + version: 1, + proof: launchProof, + anthropicEnvSlots: preBunAnthropicSlots, +}); +const child = spawn(bun, [cliPath, `${NODE_LAUNCH_PROOF_PREFIX}${launchProof}`, ...process.argv.slice(2)], { stdio: "inherit", env: { ...process.env, - OCX_PRE_BUN_ANTHROPIC_ENV: preBunAnthropicSlots.join(","), + [NODE_LAUNCH_CONTEXT_ENV]: launchContext, [BUN_RUNTIME_SOURCE_ENV]: bunRuntime.source, [BUN_RUNTIME_PATH_ENV]: bunRuntime.path, }, diff --git a/src/cli/claude.ts b/src/cli/claude.ts index 02d6751d20..66de3d340a 100644 --- a/src/cli/claude.ts +++ b/src/cli/claude.ts @@ -18,6 +18,7 @@ import { configuredAdminToken } from "../lib/admin-secrets"; import { PROXY_MARKER, ownAdmissionTokens, defaultAuthDetectDeps, detectClaudeAuth, type AuthDetectDeps } from "../claude/auth-detect"; import { resolveClaudeAuthMode } from "../claude/auth-mode"; import { withProcessRuntimeProvenance } from "../lib/bun-runtime"; +import { ANTHROPIC_PARENT_ENV_SLOTS, trustedNodeLauncherContext, type AnthropicParentEnvSlot } from "./launcher-context"; export interface ClaudeLaunchEnv { [key: string]: string | undefined; @@ -27,13 +28,18 @@ export interface ClaudeLaunchEnv { * Injectable IO for tests. `env` is deliberately NOT injectable: it is bound to the * launch base so detection and the spawned process can never disagree (audit R3-3). */ -export type ClaudeEnvDeps = { authDetect?: Omit, "env" | "ownTokens"> }; +export type ClaudeEnvDeps = { + authDetect?: Omit, "env" | "ownTokens">; + /** Test seam; production uses the authenticated Node-launcher context. */ + preBunAnthropicSlots?: readonly AnthropicParentEnvSlot[] | null; +}; /** * Pure env assembly (unit-tested): never sets ANTHROPIC_API_KEY (setting both * token vars triggers Claude Code's auth-conflict warning, 003 E1), and never - * overrides variables the user already exported, apart from stale loopback - * ANTHROPIC_BASE_URL values owned by a previous opencodex launch. + * preserves Anthropic variables proven to exist in the parent Node launcher, + * apart from stale loopback ANTHROPIC_BASE_URL values owned by a previous + * opencodex launch. Unproven ambient values fail closed as project dotenv. */ export function buildClaudeEnv( config: OcxConfig, @@ -49,27 +55,24 @@ export function buildClaudeEnv( // leaving the child with no token at all (audit R2-1). It is opencodex state, never // user auth, so dropping it unconditionally is safe. if (env.ANTHROPIC_AUTH_TOKEN === PROXY_MARKER) delete env.ANTHROPIC_AUTH_TOKEN; - // Step 1b — drop Anthropic credentials that the bundled Bun runtime synthesized from a - // project `.env`/`.env.local` (issue #701). Claude Code disables claude.ai connectors the - // moment either token slot is populated, so an ambient project file silently moved a - // subscriber onto API billing while their OAuth login stayed healthy. The npm launcher - // runs under Node, which does NOT auto-load dotenv, so it records the slots that existed - // before Bun started; anything populated now but absent then came from the working - // directory, not from the user. A genuine shell export is still honored, which keeps - // auto-mode API-key auth working. An ABSENT marker means provenance is unknowable - // (a direct `bun src/cli/index.ts` run, a test, or an older launcher), and then we - // change nothing rather than guess — an EMPTY marker is different: the launcher ran - // and saw no pre-existing slots. - const preBunSlots = base.OCX_PRE_BUN_ANTHROPIC_ENV; - if (preBunSlots !== undefined) { - const exported = new Set(preBunSlots.split(",").filter(name => name.length > 0)); - for (const name of ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"] as const) { - const value = env[name]; - if (value !== undefined && value !== "" && !exported.has(name)) delete env[name]; - } + // Step 1b — drop Anthropic credentials AND destinations that Bun synthesized from a + // project `.env`/`.env.local`. Preserving a dotenv-only ANTHROPIC_BASE_URL while + // selecting subscription auth sends Claude's OAuth bearer and prompt to that host. + // The plain-Node launcher records genuine parent exports before Bun starts and pairs + // that context with an argv proof. Without a trusted context (direct Bun or an older + // launcher) we fail closed and treat all three ambient slots as project-controlled. + const explicitSlots = deps.preBunAnthropicSlots; + const trustedSlots = explicitSlots === undefined + ? trustedNodeLauncherContext()?.anthropicEnvSlots ?? [] + : explicitSlots ?? []; + const exported = new Set(trustedSlots); + for (const name of ANTHROPIC_PARENT_ENV_SLOTS) { + const value = env[name]; + if (value !== undefined && value !== "" && !exported.has(name)) delete env[name]; } - // Never forward the seam itself to Claude Code. + // Never forward old or current provenance seams to Claude Code. delete env.OCX_PRE_BUN_ANTHROPIC_ENV; + delete env.OCX_NODE_LAUNCH_CONTEXT; const setDefault = (name: string, value: string | undefined) => { if (value === undefined || value.length === 0) return; if (env[name] !== undefined && env[name] !== "") return; // user wins diff --git a/src/cli/index.ts b/src/cli/index.ts index 54e3ff8221..03d5fc7adc 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -45,7 +45,10 @@ import { normalizeUpdateChannel, runGuiUpdateWorker } from "../update/job"; import { collectOrcaCodexHomeDiagnostic } from "../codex/home"; import { removeOwnedConfigState } from "../lib/config-ownership"; import { withProcessRuntimeProvenance } from "../lib/bun-runtime"; +import { initializeNodeLauncherContext } from "./launcher-context"; +import { createLocalAttestationSecret } from "../lib/local-management-attestation"; +initializeNodeLauncherContext(); const args = process.argv.slice(2); const command = args[0]; @@ -194,9 +197,10 @@ async function handleStart(options: { block?: boolean } = {}) { // the same port only (never hop — that was the remaining PR #152 gap). let port = await chooseListenPort(requestedPort); let server: ReturnType; + const localAttestationSecret = createLocalAttestationSecret(); for (let attempt = 0; ; attempt++) { try { - server = startServer(port); + server = startServer(port, localAttestationSecret); // Prewarm the live provider model cache as soon as the port is bound so the // first GUI /v1/models (and syncModelsToCodex below) share one discovery flight // instead of racing duplicate upstream /models fetches. @@ -224,7 +228,7 @@ async function handleStart(options: { block?: boolean } = {}) { writePid(process.pid); const config = loadConfig(); - writeRuntimePort({ pid: process.pid, port, hostname: config.hostname }); + writeRuntimePort({ pid: process.pid, port, hostname: config.hostname, attestationSecret: localAttestationSecret }); // No pre-emptive snapshot here. `injectCodexConfig` journals the exact bytes it // is about to transform; snapshotting earlier only captured a baseline that could // already be stale by the time injection ran (#477). diff --git a/src/cli/launcher-context.ts b/src/cli/launcher-context.ts new file mode 100644 index 0000000000..7395a9677d --- /dev/null +++ b/src/cli/launcher-context.ts @@ -0,0 +1,77 @@ +/** + * Trusted facts captured by the plain-Node npm launcher before Bun auto-loads + * project dotenv files. The random proof travels in argv while the context + * travels in the environment, so a project `.env` cannot forge the pair during + * an ordinary `ocx ...` invocation. + */ +export const NODE_LAUNCH_CONTEXT_ENV = "OCX_NODE_LAUNCH_CONTEXT"; +export const NODE_LAUNCH_PROOF_PREFIX = "--ocx-internal-launch-proof="; + +export const ANTHROPIC_PARENT_ENV_SLOTS = [ + "ANTHROPIC_API_KEY", + "ANTHROPIC_AUTH_TOKEN", + "ANTHROPIC_BASE_URL", +] as const; + +export type AnthropicParentEnvSlot = typeof ANTHROPIC_PARENT_ENV_SLOTS[number]; + +export type TrustedNodeLaunchContext = { + anthropicEnvSlots: readonly AnthropicParentEnvSlot[]; +}; + +let trustedContext: TrustedNodeLaunchContext | null = null; + +function isLaunchProof(value: string): boolean { + return /^[A-Za-z0-9_-]{43}$/.test(value); +} + +/** Consume the internal proof before normal CLI argument parsing. */ +export function initializeNodeLauncherContext( + argv: string[] = process.argv, + env: NodeJS.ProcessEnv = process.env, +): TrustedNodeLaunchContext | null { + const proofArgs: string[] = []; + for (let index = argv.length - 1; index >= 2; index -= 1) { + const value = argv[index]; + if (!value?.startsWith(NODE_LAUNCH_PROOF_PREFIX)) continue; + proofArgs.push(value.slice(NODE_LAUNCH_PROOF_PREFIX.length)); + argv.splice(index, 1); + } + + const raw = env[NODE_LAUNCH_CONTEXT_ENV]; + delete env[NODE_LAUNCH_CONTEXT_ENV]; + // Older launchers used this unauthenticated marker. Never let a project + // dotenv resurrect it as a trusted provenance channel. + delete env.OCX_PRE_BUN_ANTHROPIC_ENV; + trustedContext = null; + + if (proofArgs.length !== 1 || !raw || raw.length > 2048) return null; + const proof = proofArgs[0]!; + if (!isLaunchProof(proof)) return null; + + try { + const parsed = JSON.parse(raw) as { + version?: unknown; + proof?: unknown; + anthropicEnvSlots?: unknown; + }; + if (parsed.version !== 1 || parsed.proof !== proof || !Array.isArray(parsed.anthropicEnvSlots)) { + return null; + } + const allowed = new Set(ANTHROPIC_PARENT_ENV_SLOTS); + const slots = parsed.anthropicEnvSlots.filter( + (slot): slot is AnthropicParentEnvSlot => typeof slot === "string" && allowed.has(slot), + ); + if (slots.length !== parsed.anthropicEnvSlots.length || new Set(slots).size !== slots.length) { + return null; + } + trustedContext = { anthropicEnvSlots: slots }; + return trustedContext; + } catch { + return null; + } +} + +export function trustedNodeLauncherContext(): TrustedNodeLaunchContext | null { + return trustedContext; +} diff --git a/src/config.ts b/src/config.ts index fe523323ad..1385a0f5e3 100644 --- a/src/config.ts +++ b/src/config.ts @@ -22,6 +22,7 @@ import { } from "./lib/windows-secret-acl"; import { recordOwnedConfigPath } from "./lib/config-ownership"; import { assertNotRealHomeUnderTest } from "./lib/test-home-guard"; +import { isLocalAttestationSecret } from "./lib/local-management-attestation"; import { providerDestinationConfigError } from "./lib/destination-policy"; import { openRouterRoutingConfigError } from "./providers/openrouter-routing"; import { @@ -2265,18 +2266,22 @@ export type RuntimePortState = { pid: number; port: number; hostname?: string; + /** Per-process proof key; protected by the config directory and never served. */ + attestationSecret?: string; }; function isValidRuntimePortState(value: unknown): value is RuntimePortState { if (!value || typeof value !== "object") return false; const state = value as Record; const hostnameOk = state.hostname === undefined || typeof state.hostname === "string"; + const attestationOk = state.attestationSecret === undefined || isLocalAttestationSecret(state.attestationSecret); return Number.isSafeInteger(state.pid) && Number(state.pid) > 0 && Number.isInteger(state.port) && Number(state.port) > 0 && Number(state.port) <= 65535 - && hostnameOk; + && hostnameOk + && attestationOk; } export function writeRuntimePort(state: RuntimePortState): void { diff --git a/src/lib/bun-runtime.ts b/src/lib/bun-runtime.ts index 8990e660ae..b8a09149a8 100644 --- a/src/lib/bun-runtime.ts +++ b/src/lib/bun-runtime.ts @@ -12,7 +12,7 @@ */ import { createRequire } from "node:module"; import { realpathSync } from "node:fs"; -import { dirname, join, resolve } from "node:path"; +import { dirname, join } from "node:path"; import { isRealBunBinary } from "./bun-binary-validator.mjs"; export { isRealBunBinary }; @@ -108,19 +108,23 @@ export function withProcessRuntimeProvenance( * exact executable, otherwise what this executable actually is. */ function currentRuntimeProvenance(env: NodeJS.ProcessEnv): DurableBunRuntime { - const claimed = reportedBunRuntimeSource(env); - const claimedPath = env[BUN_RUNTIME_PATH_ENV]?.trim(); - if (claimed && claimedPath && samePath(claimedPath, process.execPath)) { - return { path: process.execPath, source: claimed, overrideEnv: BUN_OVERRIDE_ENV }; - } + const recorded = recordedCurrentRuntime(env); + if (recorded) return recorded; // No marker that describes this binary: report what is running. One resolution // supplies both halves so the pair can never disagree. - const runtime = durableBunRuntime(); + const runtime = unmarkedDurableBunRuntime(); return samePath(runtime.path, process.execPath) ? runtime : { path: process.execPath, source: "process", overrideEnv: BUN_OVERRIDE_ENV }; } +function recordedCurrentRuntime(env: NodeJS.ProcessEnv): DurableBunRuntime | null { + const source = reportedBunRuntimeSource(env); + const path = env[BUN_RUNTIME_PATH_ENV]?.trim(); + if (!source || !path || !samePath(path, process.execPath)) return null; + return { path, source, overrideEnv: BUN_OVERRIDE_ENV }; +} + /** * Same file, allowing for the aliases a path can pick up between launch and relaunch: * symlinks/junctions, mapped drives, and Windows case differences. Falls back to a @@ -154,21 +158,21 @@ export function bundledBunPath(): string | null { } } -export function overrideBunPath(): string | null { - const value = process.env[BUN_OVERRIDE_ENV]?.trim(); - if (!value) return null; - const resolved = resolve(value); - return isRealBunBinary(resolved) ? resolved : null; -} - -export function durableBunRuntime(): DurableBunRuntime { - const override = overrideBunPath(); - if (override) return { path: override, source: "override", overrideEnv: BUN_OVERRIDE_ENV }; +function unmarkedDurableBunRuntime(): DurableBunRuntime { const bundled = bundledBunPath(); if (bundled) return { path: bundled, source: "bundled", overrideEnv: BUN_OVERRIDE_ENV }; return { path: process.execPath, source: "process", overrideEnv: BUN_OVERRIDE_ENV }; } +export function durableBunRuntime(): DurableBunRuntime { + // A durable artifact must use the runtime selected BEFORE Bun auto-loaded a + // project dotenv. The Node launcher and owned service/shim launchers stamp the + // selected source/path pair; it is accepted only when it names this exact + // running executable. Re-reading OPENCODEX_BUN_PATH here would let a project + // `.env` persist an arbitrary executable into a shim or service. + return recordedCurrentRuntime(process.env) ?? unmarkedDurableBunRuntime(); +} + /** * Bun path to bake into durable artifacts (launchd/systemd/Task Scheduler and * the Codex auto-start shim). Prefer the bundled binary — it lives under the diff --git a/src/lib/local-management-attestation.ts b/src/lib/local-management-attestation.ts new file mode 100644 index 0000000000..25eb283d0a --- /dev/null +++ b/src/lib/local-management-attestation.ts @@ -0,0 +1,51 @@ +import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; + +export const LOCAL_ATTESTATION_CHALLENGE_HEADER = "x-opencodex-attestation-challenge"; +export const LOCAL_ATTESTATION_PROOF_HEADER = "x-opencodex-attestation-proof"; + +const BASE64URL_256 = /^[A-Za-z0-9_-]{43}$/; + +export function isLocalAttestationSecret(value: unknown): value is string { + return typeof value === "string" && BASE64URL_256.test(value); +} + +export function createLocalAttestationSecret(): string { + return randomBytes(32).toString("base64url"); +} + +export function createLocalAttestationChallenge(): string { + return randomBytes(32).toString("base64url"); +} + +function attestationPayload(challenge: string, pid: number, port: number): string | null { + if (!BASE64URL_256.test(challenge)) return null; + if (!Number.isSafeInteger(pid) || pid <= 0) return null; + if (!Number.isInteger(port) || port <= 0 || port > 65535) return null; + return `opencodex-local-management-v1\n${challenge}\n${pid}\n${port}`; +} + +export function createLocalAttestationProof( + secret: string, + challenge: string, + pid: number, + port: number, +): string | null { + if (!isLocalAttestationSecret(secret)) return null; + const payload = attestationPayload(challenge, pid, port); + if (!payload) return null; + return createHmac("sha256", secret).update(payload).digest("base64url"); +} + +export function verifyLocalAttestationProof( + secret: string, + challenge: string, + pid: number, + port: number, + proof: string | null, +): boolean { + const expected = createLocalAttestationProof(secret, challenge, pid, port); + if (!expected || !proof || !BASE64URL_256.test(proof)) return false; + const expectedBytes = Buffer.from(expected); + const actualBytes = Buffer.from(proof); + return expectedBytes.length === actualBytes.length && timingSafeEqual(expectedBytes, actualBytes); +} diff --git a/src/oauth/health.ts b/src/oauth/health.ts index aaf5ebd5ff..9b7da74e78 100644 --- a/src/oauth/health.ts +++ b/src/oauth/health.ts @@ -4,6 +4,13 @@ import { isAccountNeedsReauth } from "../codex/account-runtime-state"; import { getCodexAccountCredential, listCodexAccountIds } from "../codex/account-store"; import { MAIN_CODEX_ACCOUNT_ID } from "../codex/main-account"; import { configuredAdminToken } from "../lib/admin-secrets"; +import { readRuntimePort } from "../config"; +import { + LOCAL_ATTESTATION_CHALLENGE_HEADER, + LOCAL_ATTESTATION_PROOF_HEADER, + createLocalAttestationChallenge, + verifyLocalAttestationProof, +} from "../lib/local-management-attestation"; import { maskAccountId } from "../lib/privacy"; import { findLiveProxy, probeHostname } from "../server/proxy-liveness"; import { loadAuthStore, peekAuthStore, peekOAuthRefreshIntent, readOAuthRefreshIntent } from "./store"; @@ -328,6 +335,7 @@ type LiveProxyCodexHealthResult = { async function fetchCodexHealthFromLiveProxy( fetchImpl: typeof fetch = fetch, findLiveProxyImpl: typeof findLiveProxy = findLiveProxy, + readRuntimePortImpl: typeof readRuntimePort = readRuntimePort, ): Promise { const live = await findLiveProxyImpl(); if (!live) return { source: "unavailable", entries: null }; @@ -335,8 +343,39 @@ async function fetchCodexHealthFromLiveProxy( // interchangeable with the admin credential even on loopback. const token = configuredAdminToken(); const headers: Record = {}; - if (token) headers.Authorization = `Bearer ${token}`; try { + if (token) { + // Public /healthz identity is intentionally forgeable enough for liveness, not + // strong enough to receive a bearer. Prove the listener knows the per-process + // secret stored in the protected runtime record before attaching the admin token. + if (live.source !== "runtime" || live.pid === null) { + return { source: "management-api-unavailable", entries: null }; + } + const attestedPid = live.pid; + const runtime = readRuntimePortImpl(attestedPid); + if (!runtime?.attestationSecret || runtime.port !== live.port) { + return { source: "management-api-unavailable", entries: null }; + } + const challenge = createLocalAttestationChallenge(); + const proofResponse = await fetchImpl( + `http://${probeHostname(live.hostname)}:${live.port}/healthz`, + { + headers: { [LOCAL_ATTESTATION_CHALLENGE_HEADER]: challenge }, + signal: AbortSignal.timeout(4000), + }, + ); + const proof = proofResponse.headers.get(LOCAL_ATTESTATION_PROOF_HEADER); + if (!proofResponse.ok || !verifyLocalAttestationProof( + runtime.attestationSecret, + challenge, + attestedPid, + live.port, + proof, + )) { + return { source: "management-api-unavailable", entries: null }; + } + headers.Authorization = `Bearer ${token}`; + } const res = await fetchImpl( `http://${probeHostname(live.hostname)}:${live.port}/api/codex-auth/accounts`, { headers, signal: AbortSignal.timeout(4000) }, @@ -387,10 +426,15 @@ export async function collectOAuthHealthEntriesForCli( deps: { fetchImpl?: typeof fetch; findLiveProxyImpl?: typeof findLiveProxy; + readRuntimePortImpl?: typeof readRuntimePort; } = {}, ): Promise { const entries = collectOAuthHealthEntries(now, { observeOnly: true, includeLocalCodex: false }); - const remote = await fetchCodexHealthFromLiveProxy(deps.fetchImpl, deps.findLiveProxyImpl); + const remote = await fetchCodexHealthFromLiveProxy( + deps.fetchImpl, + deps.findLiveProxyImpl, + deps.readRuntimePortImpl, + ); if (remote.entries) { for (const entry of remote.entries) entries.push(entry); return { entries, codexHealthSource: "management-api" }; diff --git a/src/server/index.ts b/src/server/index.ts index 5220a4d17b..2d526e1af3 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -158,6 +158,12 @@ import { handleLive, logLiveSidebandFrame, parseLiveSidebandTarget, resolveLiveS import { handleSearch } from "./search"; import { fetchAllModels, handleManagementAPI, VERSION } from "./management-api"; import { initializeManagementAuthState, issueGuiSession, managementPrincipal, requireManagementAuth } from "./management-auth"; +import { + LOCAL_ATTESTATION_CHALLENGE_HEADER, + LOCAL_ATTESTATION_PROOF_HEADER, + createLocalAttestationProof, + createLocalAttestationSecret, +} from "../lib/local-management-attestation"; const MAX_WS_FRAME_BYTES = 50 * 1024 * 1024; const WEBSOCKET_IDLE_TIMEOUT_SECONDS = 0; @@ -271,7 +277,10 @@ function attachLiveSidebandUpstream(ws: ServerWebSocket): void { // trackSseForRequestLog( // export function relaySseWithHeartbeat -export function startServer(port?: number) { +export function startServer( + port?: number, + localAttestationSecret = createLocalAttestationSecret(), +) { const config = runAlibabaRegionStartupMigration(runOpenAiTierStartupMigration(loadConfig())); setLiveStateStoreConfig(config); applyProxyEnv(config); @@ -442,7 +451,14 @@ export function startServer(port?: number) { if (url.pathname === "/healthz" && req.method === "GET") { // service/pid/port let CLI liveness reject foreign 200s and verify pid identity. - return jsonResponse({ status: "ok", service: "opencodex", version: VERSION, uptime: process.uptime(), pid: process.pid, port: listenPort }, 200, req, config); + const healthPort = server.port ?? listenPort; + const response = jsonResponse({ status: "ok", service: "opencodex", version: VERSION, uptime: process.uptime(), pid: process.pid, port: healthPort }, 200, req, config); + const challenge = req.headers.get(LOCAL_ATTESTATION_CHALLENGE_HEADER); + if (challenge) { + const proof = createLocalAttestationProof(localAttestationSecret, challenge, process.pid, healthPort); + if (proof) response.headers.set(LOCAL_ATTESTATION_PROOF_HEADER, proof); + } + return response; } if (url.pathname.startsWith("/api/")) { diff --git a/structure/00_overview.md b/structure/00_overview.md index 121f396995..bb204211ea 100644 --- a/structure/00_overview.md +++ b/structure/00_overview.md @@ -77,7 +77,7 @@ opencodex state root does not undo those writes. Putting native Codex back is th | `~/.opencodex/codex-accounts.json` | opencodex | Hardened main-plus-added credential store used by `openai` in Pool mode. | | `~/.opencodex/catalog-backup.json` | opencodex | One-time pristine Codex catalog backup for restore; per-catalog copies are hashed variants (see [`03_catalog-and-subagents.md`](03_catalog-and-subagents.md)). | | `~/.opencodex/usage.jsonl` | opencodex | Append-only request usage log (0o600); request metadata + token counts only, never prompts or auth. | -| `~/.opencodex/ocx.pid`, `runtime-port.json`, `system-env-port` | opencodex runtime | Live process identity and the port a client should reach; rewritten on start. | +| `~/.opencodex/ocx.pid`, `runtime-port.json`, `system-env-port` | opencodex runtime | Live process identity and the port a client should reach; rewritten on start. `runtime-port.json` also carries the protected per-process listener-attestation key used before CLI diagnostics attach a management bearer. | | `~/.opencodex/codex-runtime.json`, `codex-runtime-clamp.json` | opencodex Codex runtime | Selected Codex executable/version state and effort-clamp diagnostics. Not process identity: these persist a resolved choice and a diagnostic, so losing them changes behavior until re-resolved. | | `~/.opencodex/service-state.json`, `service.log`, `service-api-token`, `opencodex-service-launcher.vbs`, `opencodex-service-task.xml`, `opencodex-service.cmd`, `winsw`, `tray-state.json`, `tray-heartbeat.json`, `opencodex-tray.ps1`, `opencodex-tray-*.ico`, `update-job.json` | opencodex operators | Installed-service, Windows tray, and self-update artifacts and bookkeeping. The update record carries its worker PID so a dead worker recovers instead of blocking later runs. | | `~/.opencodex/responses-state.json`, `usage-debug.jsonl`, `crash.log`, `artifacts/` | opencodex diagnostics and artifacts | Bounded caches, diagnostics, and generated image/video artifacts served locally. | diff --git a/structure/01_runtime.md b/structure/01_runtime.md index 07e733b76f..1c74fb3115 100644 --- a/structure/01_runtime.md +++ b/structure/01_runtime.md @@ -4,8 +4,8 @@ | Path | Responsibility | | --- | --- | -| `bin/ocx.mjs` | Published npm `bin` entry (Node shim). Resolves the bundled Bun binary (`bun` dependency), lazy-runs its `install.js` if only the placeholder stub is present, then execs `src/cli/index.ts` under Bun. Lets `npm install -g` work without a separately-installed Bun. | -| `src/lib/bun-runtime.ts` | Bundled-Bun resolution: `isRealBunBinary()` (size gate vs the ~450-byte placeholder stub), `bundledBunPath()`, `durableBunPath()` (path baked into service/shim artifacts). | +| `bin/ocx.mjs` | Published npm `bin` entry (Node shim). Resolves the bundled or explicit Bun binary before project dotenv can load, stamps its runtime provenance plus a proof-bound Anthropic parent-env snapshot, lazy-runs `bun/install.js` if only the placeholder stub is present, then execs `src/cli/index.ts` under Bun. Lets `npm install -g` work without a separately-installed Bun. | +| `src/lib/bun-runtime.ts` | Bundled-Bun resolution: `isRealBunBinary()` (size gate vs the ~450-byte placeholder stub), `bundledBunPath()`, and `durableBunPath()` (path baked into service/shim artifacts). Durable selection accepts only the source/path pair already stamped for the running executable; it never re-reads a project-dotenv `OPENCODEX_BUN_PATH`. | | `src/cli/index.ts` | `ocx` / `opencodex` CLI. Lifecycle: init, start, stop, restart, status, sync, restore/eject, gui, service, update. Configuration: provider, account, models, combo/route, access, integrations, v2. Diagnostics: doctor, debug, observe, health. Windows adds tray. The full command surface is `src/cli/help.ts`; this table names the groups, not every verb. After help/version early exits, ordinary commands run the bounded best-effort Codex-shim auto-restore policy before dispatch. Keeps the `#!/usr/bin/env bun` shebang for from-source dev (`bun run src/cli/index.ts`). | | `src/server/index.ts` | Bun server entrypoint: `startServer`, `/v1/responses` HTTP + WebSocket routing (compact handled before generic Responses), exact `POST /v1/images/generations` and `POST /v1/images/edits` routing, `/v1/models`, the Anthropic-shaped `/v1/messages` and OpenAI-shaped `/v1/chat/completions` compatibility surfaces, the Live/Realtime surface, the hosted-search relay, artifact serving, `/healthz`, the `/api/*` auth gate, the `/v1/*` JSON 404 guard, GUI fallback, and facade re-exports for split server modules. | | `src/server/images.ts` | Standalone Images data plane: default OpenAI or explicit custom-provider selection, Codex account affinity, bounded opaque request relay, single-attempt upstream fetch, pool health recording, and safe response/cancellation relay. | @@ -70,6 +70,14 @@ the client never sees a completed call ahead of `response.failed` / `response.in The server exposes `POST /api/stop` which restores native Codex config, stops any installed service (to prevent respawn), and exits the process. The GUI sidebar stop button calls this endpoint. +[Decision Log] +- 목적과 의도: Prevent repository dotenv data from becoming a durable executable or an OAuth-bearing Claude destination. +- 기존 구현 및 제약 조건: Bun auto-loads project dotenv before OpenCodex TypeScript evaluates, while provider interpolation still depends on that behavior and cannot be disabled globally. +- 검토한 주요 대안: Reject only relative Bun paths; disable Bun dotenv; trust a plain environment marker; capture provenance in the Node launcher and bind it to an argv proof. +- 선택한 방식: The Node launcher selects Bun and snapshots Anthropic credential/destination slots before Bun starts. Durable runtime selection uses only the stamped current executable, while Claude accepts the snapshot only when its random argv proof matches. +- 다른 대안 대신 이 방식을 선택한 이유: Absolute dotenv expansion bypasses a relative-path check, global dotenv removal breaks supported configuration, and an environment-only marker can itself come from dotenv. +- 장점, 단점 및 영향: Normal npm launches preserve genuine shell overrides; direct Bun or legacy launches fail closed for ambient Anthropic auth/destination values and use the running or bundled Bun for durable artifacts. + ## Providers and adapters | Path | Responsibility | diff --git a/structure/05_gui-and-management-api.md b/structure/05_gui-and-management-api.md index 3b9e155adc..3213247e6c 100644 --- a/structure/05_gui-and-management-api.md +++ b/structure/05_gui-and-management-api.md @@ -28,6 +28,20 @@ management credential for `/api/codex-auth/accounts`, never the service/data-pla output distinguishes a missing proxy, rejected management authentication, and an unexpected management response so a reachable `401` cannot be reported as "proxy not running." +Before either CLI command attaches the management bearer, it challenges the listener and verifies +an HMAC proof bound to the proxy PID and port. The per-process proof key lives only in the protected +`runtime-port.json`; the public `/healthz` identity marker alone is never sufficient to receive a +management credential. Legacy or configured-port-only listeners still satisfy ordinary liveness, +but their account-health detail remains unavailable until an attested runtime record exists. + +[Decision Log] +- 목적과 의도: Keep a lower-privileged local process from collecting the management bearer by impersonating `/healthz` on an unused port. +- 기존 구현 및 제약 조건: Liveness must remain public and backward-compatible, but its service string and reported PID are assertions made by the listener itself. +- 검토한 주요 대안: Require only a runtime source and non-null PID; stop showing account health; authenticate the listener with a protected per-process challenge secret. +- 선택한 방식: Store a random secret in the mode-protected runtime record and require a challenge/PID/port HMAC before the CLI sends Authorization. +- 다른 대안 대신 이 방식을 선택한 이유: PID and command-line checks are not cryptographic listener identity, while removing live account health would regress diagnostics unnecessarily. +- 장점, 단점 및 영향: The long-lived token never reaches a listener without the runtime secret; an old running proxy remains visible but cannot provide detailed CLI account health until restarted on the new version. + Management authentication never has a loopback bypass. If no management credential is available, or management token creation, validation, or permission hardening fails, every `/api/*` request returns 503 while `/v1/*` and unauthenticated `/healthz` continue to operate. Windows ACL hardening results diff --git a/structure/06_docs-and-release.md b/structure/06_docs-and-release.md index b357bd0ef6..dc39603e6e 100644 --- a/structure/06_docs-and-release.md +++ b/structure/06_docs-and-release.md @@ -40,7 +40,7 @@ bun run build | Workflow | Trigger | Purpose | | --- | --- | --- | -| `.github/workflows/ci.yml` | `pull_request` to `main`/`dev`, `push` to `main`/`preview`/`dev`, or manual dispatch when runtime/package paths change | Cross-platform runtime/package quality gate on Linux, Windows, and macOS. The `test` job (Bun) runs typecheck, `bun test --isolate tests`, the GUI suite (`cd gui && bun test tests`), the privacy scan, release-helper syntax check, GUI lint/build, and `ocx help`; `npm-global-smoke` (Node only, **no setup-bun**) builds package assets, packs the tarball, installs it globally, and runs `ocx help` to prove the bundled-Bun launcher works without a separate Bun install. | +| `.github/workflows/ci.yml` | `pull_request` to `main`/`dev`, `push` to `main`/`preview`/`dev`, or manual dispatch when runtime/package paths change | Cross-platform runtime/package quality gate on Linux, Windows, and macOS. The Bun `test` job keeps pull requests on GitHub-hosted Windows while trusted `push`/manual runs may use the `ocx-home` self-hosted Windows runner when the repository switch is enabled; this preserves the hosted-Windows Bun crash workaround. `npm-global-smoke` always remains GitHub-hosted because it mutates the global package prefix. | | `.github/workflows/release.yml` | Manual dispatch only | npm publish/dry-run workflow. It requires the exact `GITHUB_SHA` to have a successful Cross-platform CI run before publish or dry-run. | | `.github/workflows/deploy-docs.yml` | `push` to `main` touching `docs-site/**` or the workflow, or manual dispatch | Build and publish the Astro/Starlight docs site to GitHub Pages. | | `.github/workflows/service-lifecycle.yml` | `pull_request` to `main`/`dev` and `push`, both filtered on the service path set (`src/service.ts`, `src/cli.ts`, `src/cli/index.ts`, `src/lib/bun-runtime.ts`, `package.json`, `bun.lock`, the workflow), or manual dispatch | Service-lifecycle smoke on three platforms: Linux systemd, macOS launchd, and Windows Scheduled Tasks. Each installs, verifies, stops via `ocx stop`, and uninstalls. The path list is kept in sync with the `release.yml` service-gate regex. | @@ -56,6 +56,15 @@ bun run build branch, not from `dev`. Landing a change to one of them on `dev` does not change live behavior until it is promoted, so those files follow the promotion model rather than ordinary integration. +The Windows selector is an operational stability control, not a security boundary. A pull request +controls the `pull_request` workflow body and can rewrite an event-name check, repository variable, +or selector output. Because this is a public user-owned repository and runner groups are unavailable, +the repository setting **Fork pull request workflows from outside collaborators: Require approval +for all outside collaborators** (`all_external_contributors`) must remain enabled before any self- +hosted runner is registered. Maintainers must inspect workflow changes before approving an external +run. If that setting cannot be verified, unset `OCX_SELF_HOSTED_WINDOWS` and deregister the runner; +the workflow then fails back to `windows-latest` rather than exposing a persistent maintainer host. + Docs-only changes intentionally route through the docs workflow instead of the runtime CI gate. If a docs change also edits runtime/package/release files, run the relevant local runtime checks before push and let `ci.yml` provide the Linux/Windows confirmation. Service-related changes @@ -130,9 +139,11 @@ Invariants: lazy-runs `install.js` and execs `src/cli/index.ts` under Bun, propagating exit code and signal. - `package.json` carries `"trustedDependencies": ["bun"]` so `bun install` runs the dependency's postinstall, and `"engines": { "node": ">=18" }` (Bun is no longer a user prerequisite). -- `src/service.ts` and `src/codex/shim.ts` bake `durableBunPath()` (the bundled binary, stable under - the npm global prefix) into launchd/systemd/Task Scheduler and the Codex autostart shim, so those - durable artifacts keep resolving across `ocx update`. +- The plain-Node launcher owns `OPENCODEX_BUN_PATH` selection before Bun can load project dotenv and + stamps the chosen source/path pair. `src/service.ts` and `src/codex/shim.ts` bake that already- + selected executable (normally the bundled binary, stable under the npm global prefix) into + launchd/systemd/Task Scheduler and the Codex autostart shim. Bun-side code never re-selects a + durable executable from the post-dotenv environment. - Public docs (root READMEs + `docs-site` installation pages, all locales) state Node 18+ as the only prerequisite. Do not reintroduce "install Bun first" / "bun must be on PATH" guidance for npm users. diff --git a/tests/bun-runtime.test.ts b/tests/bun-runtime.test.ts index e012fff2fe..5cb6b3297c 100644 --- a/tests/bun-runtime.test.ts +++ b/tests/bun-runtime.test.ts @@ -1,16 +1,24 @@ -import { describe, it, expect, afterAll } from "bun:test"; +import { describe, it, expect, afterAll, afterEach } from "bun:test"; import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { BUN_RUNTIME_PATH_ENV, BUN_RUNTIME_SOURCE_ENV, isRealBunBinary, bundledBunPath, durableBunPath, durableBunRuntime, overrideBunPath, reportedBunRuntimeSource, withProcessRuntimeProvenance } from "../src/lib/bun-runtime"; +import { BUN_RUNTIME_PATH_ENV, BUN_RUNTIME_SOURCE_ENV, isRealBunBinary, bundledBunPath, durableBunPath, durableBunRuntime, reportedBunRuntimeSource, withProcessRuntimeProvenance } from "../src/lib/bun-runtime"; // realpath the temp root: on macOS /var is a symlink to /private/var, so a path built // from mkdtemp compares unequal to the same path resolved through process.cwd(). const tmp = realpathSync(mkdtempSync(join(tmpdir(), "ocx-bun-runtime-"))); const previousOverride = process.env.OPENCODEX_BUN_PATH; -afterAll(() => { +const previousRuntimeSource = process.env[BUN_RUNTIME_SOURCE_ENV]; +const previousRuntimePath = process.env[BUN_RUNTIME_PATH_ENV]; +afterEach(() => { if (previousOverride === undefined) delete process.env.OPENCODEX_BUN_PATH; else process.env.OPENCODEX_BUN_PATH = previousOverride; + if (previousRuntimeSource === undefined) delete process.env[BUN_RUNTIME_SOURCE_ENV]; + else process.env[BUN_RUNTIME_SOURCE_ENV] = previousRuntimeSource; + if (previousRuntimePath === undefined) delete process.env[BUN_RUNTIME_PATH_ENV]; + else process.env[BUN_RUNTIME_PATH_ENV] = previousRuntimePath; +}); +afterAll(() => { rmSync(tmp, { recursive: true, force: true }); }); @@ -40,29 +48,25 @@ describe("isRealBunBinary (size gate vs placeholder stub)", () => { }); describe("bundledBunPath / durableBunPath", () => { - it("uses OPENCODEX_BUN_PATH only when it points to a real Bun binary", () => { + it("does not reselect a dotenv Bun override after the runtime has started", () => { const real = join(tmp, "override-bun.exe"); const stub = join(tmp, "override-stub.exe"); writeFileSync(real, Buffer.alloc(1_000_000)); writeFileSync(stub, "stub"); process.env.OPENCODEX_BUN_PATH = stub; - expect(overrideBunPath()).toBeNull(); expect(durableBunRuntime().source).not.toBe("override"); process.env.OPENCODEX_BUN_PATH = real; - expect(overrideBunPath()).toBe(real); - expect(durableBunRuntime()).toEqual({ - path: real, - source: "override", - overrideEnv: "OPENCODEX_BUN_PATH", - }); - expect(durableBunPath()).toBe(real); + delete process.env[BUN_RUNTIME_SOURCE_ENV]; + delete process.env[BUN_RUNTIME_PATH_ENV]; + expect(durableBunRuntime().path).not.toBe(real); + expect(durableBunRuntime().source).not.toBe("override"); if (previousOverride === undefined) delete process.env.OPENCODEX_BUN_PATH; else process.env.OPENCODEX_BUN_PATH = previousOverride; }); - it("resolves a relative override against the launcher cwd", () => { + it("preserves a launcher-selected runtime and ignores a later relative override", () => { const launcherCwd = join(tmp, "launcher-cwd"); const real = join(launcherCwd, "relative-bun.exe"); const previousCwd = process.cwd(); @@ -73,15 +77,20 @@ describe("bundledBunPath / durableBunPath", () => { try { process.chdir(launcherCwd); process.env.OPENCODEX_BUN_PATH = " relative-bun.exe "; - expect(overrideBunPath()).toBe(real); + process.env[BUN_RUNTIME_SOURCE_ENV] = "override"; + process.env[BUN_RUNTIME_PATH_ENV] = process.execPath; expect(durableBunRuntime()).toEqual({ - path: real, + path: process.execPath, source: "override", overrideEnv: "OPENCODEX_BUN_PATH", }); - expect(durableBunPath()).toBe(real); + expect(durableBunPath()).toBe(process.execPath); } finally { process.chdir(previousCwd); + if (previousRuntimeSource === undefined) delete process.env[BUN_RUNTIME_SOURCE_ENV]; + else process.env[BUN_RUNTIME_SOURCE_ENV] = previousRuntimeSource; + if (previousRuntimePath === undefined) delete process.env[BUN_RUNTIME_PATH_ENV]; + else process.env[BUN_RUNTIME_PATH_ENV] = previousRuntimePath; if (inheritedOverride === undefined) delete process.env.OPENCODEX_BUN_PATH; else process.env.OPENCODEX_BUN_PATH = inheritedOverride; } @@ -152,8 +161,11 @@ describe("reportedBunRuntimeSource (#848 launch-time provenance)", () => { writeFileSync(real, "x".repeat(2 * 1024 * 1024)); process.env.OPENCODEX_BUN_PATH = real; try { - // durableBunRuntime would say "override" here; the reporter must still say unknown. - expect(durableBunRuntime().source).toBe("override"); + // The durable selector ignores this late value, and the reporter must also + // stay unknown without a source/path pair naming the running executable. + delete process.env[BUN_RUNTIME_SOURCE_ENV]; + delete process.env[BUN_RUNTIME_PATH_ENV]; + expect(durableBunRuntime().source).not.toBe("override"); expect(reportedBunRuntimeSource({})).toBeUndefined(); } finally { if (inherited === undefined) delete process.env.OPENCODEX_BUN_PATH; diff --git a/tests/claude-auth-mode.test.ts b/tests/claude-auth-mode.test.ts index 81829b4b18..0e052425a4 100644 --- a/tests/claude-auth-mode.test.ts +++ b/tests/claude-auth-mode.test.ts @@ -95,7 +95,7 @@ test("a stale marker is re-established when the mode still resolves proxy", () = cfg(), 10100, { ANTHROPIC_AUTH_TOKEN: PROXY_MARKER }, {}, - { authDetect: fileAuth("absent") }, + { authDetect: fileAuth("absent"), preBunAnthropicSlots: ["ANTHROPIC_API_KEY"] }, ); expect(env.ANTHROPIC_AUTH_TOKEN).toBe(PROXY_MARKER); }); @@ -131,7 +131,7 @@ test("an exported ANTHROPIC_API_KEY keeps the token slot untouched", () => { cfg(), 10100, { ANTHROPIC_API_KEY: "sk-ant-user" }, {}, - { authDetect: fileAuth("absent") }, + { authDetect: fileAuth("absent"), preBunAnthropicSlots: ["ANTHROPIC_API_KEY"] }, ); expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined(); expect(env.ANTHROPIC_API_KEY).toBe("sk-ant-user"); @@ -152,17 +152,17 @@ test("manual subscription withholds the marker even when auth is absent", () => // // Bun auto-loads `.env`/`.env.local` before any opencodex code runs, so process.env alone // cannot tell ambient pollution from a real shell export. The Node launcher runs BEFORE -// that and records which slots already existed; these tests drive that marker directly. -// An absent marker means provenance is unknowable, so behavior must not change. +// that and supplies a proof-bound list through launcher-context.ts. Without a trusted +// context the security boundary fails closed. const PRE_BUN = "OCX_PRE_BUN_ANTHROPIC_ENV"; // The reported failure: auto mode, healthy claude.ai login, key only from the dotenv. test("auto mode drops an Anthropic key that only Bun's dotenv introduced", () => { const env = buildClaudeEnv( cfg(), 10100, - { ANTHROPIC_API_KEY: "sk-ant-dotenv", [PRE_BUN]: "" }, + { ANTHROPIC_API_KEY: "sk-ant-dotenv" }, {}, - { authDetect: fileAuth("present") }, + { authDetect: fileAuth("present"), preBunAnthropicSlots: [] }, ); expect(env.ANTHROPIC_API_KEY).toBeUndefined(); expect(env[PRE_BUN]).toBeUndefined(); @@ -172,9 +172,9 @@ test("auto mode drops an Anthropic key that only Bun's dotenv introduced", () => test("a shell-exported Anthropic key survives the dotenv strip", () => { const env = buildClaudeEnv( cfg(), 10100, - { ANTHROPIC_API_KEY: "sk-ant-user", [PRE_BUN]: "ANTHROPIC_API_KEY" }, + { ANTHROPIC_API_KEY: "sk-ant-user" }, {}, - { authDetect: fileAuth("present") }, + { authDetect: fileAuth("present"), preBunAnthropicSlots: ["ANTHROPIC_API_KEY"] }, ); expect(env.ANTHROPIC_API_KEY).toBe("sk-ant-user"); expect(env[PRE_BUN]).toBeUndefined(); @@ -183,9 +183,9 @@ test("a shell-exported Anthropic key survives the dotenv strip", () => { test("explicit subscription mode also drops a dotenv-only credential", () => { const env = buildClaudeEnv( cfg({ authMode: "subscription" }), 10100, - { ANTHROPIC_API_KEY: "sk-ant-dotenv", ANTHROPIC_AUTH_TOKEN: "token-from-dotenv", [PRE_BUN]: "" }, + { ANTHROPIC_API_KEY: "sk-ant-dotenv", ANTHROPIC_AUTH_TOKEN: "token-from-dotenv" }, {}, - { authDetect: fileAuth("present") }, + { authDetect: fileAuth("present"), preBunAnthropicSlots: [] }, ); expect(env.ANTHROPIC_API_KEY).toBeUndefined(); expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined(); @@ -195,25 +195,55 @@ test("explicit subscription mode also drops a dotenv-only credential", () => { test("the configured admission key survives the dotenv strip", () => { const env = buildClaudeEnv( cfg(undefined, [{ key: "admission-key" }]), 10100, - { ANTHROPIC_API_KEY: "sk-ant-dotenv", [PRE_BUN]: "" }, + { ANTHROPIC_API_KEY: "sk-ant-dotenv" }, {}, - { authDetect: fileAuth("present") }, + { authDetect: fileAuth("present"), preBunAnthropicSlots: [] }, ); expect(env.ANTHROPIC_API_KEY).toBeUndefined(); expect(env.ANTHROPIC_AUTH_TOKEN).toBe("admission-key"); expect(env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST).toBe("1"); }); -// Without the launcher marker (direct `bun src/cli/index.ts`, or an older launcher) -// provenance is unknowable, so an inherited key keeps its current meaning. -test("without the launcher marker an inherited key is left alone", () => { +test("without trusted launcher context an ambient key is removed", () => { const env = buildClaudeEnv( cfg(), 10100, { ANTHROPIC_API_KEY: "sk-ant-user" }, {}, { authDetect: fileAuth("present") }, ); - expect(env.ANTHROPIC_API_KEY).toBe("sk-ant-user"); + expect(env.ANTHROPIC_API_KEY).toBeUndefined(); +}); + +test("a dotenv-only base URL cannot receive subscription OAuth", () => { + const env = buildClaudeEnv( + cfg(), 10100, + { ANTHROPIC_BASE_URL: "https://attacker.example" }, + {}, + { authDetect: fileAuth("present"), preBunAnthropicSlots: [] }, + ); + expect(env.ANTHROPIC_BASE_URL).toBe("http://127.0.0.1:10100"); + expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined(); +}); + +test("a proof-bound parent base URL remains supported", () => { + const env = buildClaudeEnv( + cfg(), 10100, + { ANTHROPIC_BASE_URL: "https://trusted-gateway.example" }, + {}, + { authDetect: fileAuth("present"), preBunAnthropicSlots: ["ANTHROPIC_BASE_URL"] }, + ); + expect(env.ANTHROPIC_BASE_URL).toBe("https://trusted-gateway.example"); +}); + +test("the legacy dotenv marker cannot forge parent provenance", () => { + const env = buildClaudeEnv( + cfg(), 10100, + { ANTHROPIC_BASE_URL: "https://attacker.example", [PRE_BUN]: "ANTHROPIC_BASE_URL" }, + {}, + { authDetect: fileAuth("present") }, + ); + expect(env.ANTHROPIC_BASE_URL).toBe("http://127.0.0.1:10100"); + expect(env[PRE_BUN]).toBeUndefined(); }); // Stripping the key must ALSO flip detection to absent so the proxy marker is injected. @@ -221,9 +251,9 @@ test("without the launcher marker an inherited key is left alone", () => { test("a stripped dotenv key lets detection fall through to the proxy marker", () => { const env = buildClaudeEnv( cfg(), 10100, - { ANTHROPIC_API_KEY: "sk-ant-dotenv", [PRE_BUN]: "" }, + { ANTHROPIC_API_KEY: "sk-ant-dotenv" }, {}, - { authDetect: fileAuth("absent") }, + { authDetect: fileAuth("absent"), preBunAnthropicSlots: [] }, ); expect(env.ANTHROPIC_API_KEY).toBeUndefined(); expect(env.ANTHROPIC_AUTH_TOKEN).toBe(PROXY_MARKER); diff --git a/tests/claude-cli.test.ts b/tests/claude-cli.test.ts index b34b8e4b1e..b46911505b 100644 --- a/tests/claude-cli.test.ts +++ b/tests/claude-cli.test.ts @@ -220,7 +220,7 @@ describe("ocx claude env assembly", () => { ANTHROPIC_BASE_URL: "http://my-own-gateway:9", ANTHROPIC_MODEL: "my-model", PATH: "/usr/bin", - }); + }, {}, { preBunAnthropicSlots: ["ANTHROPIC_BASE_URL"] }); expect(env.ANTHROPIC_BASE_URL).toBe("http://my-own-gateway:9"); expect(env.ANTHROPIC_MODEL).toBe("my-model"); expect(env.PATH).toBe("/usr/bin"); diff --git a/tests/claude-dotenv-provenance-transport.test.ts b/tests/claude-dotenv-provenance-transport.test.ts index afd9824e7c..8373732b6e 100644 --- a/tests/claude-dotenv-provenance-transport.test.ts +++ b/tests/claude-dotenv-provenance-transport.test.ts @@ -1,60 +1,70 @@ -import { describe, expect, test } from "bun:test"; +import { afterAll, describe, expect, test } from "bun:test"; import { spawnSync } from "node:child_process"; -import { mkdtempSync, writeFileSync } from "node:fs"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { pathToFileURL } from "node:url"; /** - * #701 transport proof. - * - * The dotenv-provenance fix rests on one runtime assumption: an EMPTY-STRING environment - * value survives a spawn and stays distinguishable from an absent variable. The whole - * design hinges on it, because "the launcher ran and saw zero pre-existing Anthropic - * slots" is encoded as `OCX_PRE_BUN_ANTHROPIC_ENV=""` while "no launcher at all, change - * nothing" is encoded as the variable being absent. - * - * The unit tests in claude-auth-mode.test.ts inject that marker directly into a plain - * object, so they would stay green even if a platform collapsed `""` to unset in real - * process spawning — and the production fix would silently become a no-op for exactly - * the case that matters most. This test spawns real processes instead, so the assumption - * is proven by execution on whatever platform CI runs (Linux, Windows, macOS). + * Project dotenv can write environment variables before OpenCodex evaluates, + * but it cannot add the random proof argument emitted by the plain-Node npm + * launcher. Exercise that split through a real Bun child on every CI platform. */ -describe("empty-string env transport across a real spawn (#701)", () => { - const dir = mkdtempSync(join(tmpdir(), "ocx-dotenv-provenance-")); - const probe = join(dir, "probe.mjs"); +describe("Node launcher context transport", () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-launch-context-")); + const probe = join(dir, "probe.ts"); + const moduleUrl = pathToFileURL(join(import.meta.dir, "..", "src", "cli", "launcher-context.ts")).href; writeFileSync( probe, - 'const v = process.env.OCX_PRE_BUN_ANTHROPIC_ENV;\n' - + 'process.stdout.write(JSON.stringify({ type: typeof v, value: v ?? null, own: "OCX_PRE_BUN_ANTHROPIC_ENV" in process.env }));\n', + `import { initializeNodeLauncherContext } from ${JSON.stringify(moduleUrl)};\n` + + "const context = initializeNodeLauncherContext();\n" + + "process.stdout.write(JSON.stringify({ context, args: process.argv.slice(2), contextEnv: process.env.OCX_NODE_LAUNCH_CONTEXT ?? null }));\n", ); - function probeWith(env: NodeJS.ProcessEnv | undefined): { type: string; value: string | null; own: boolean } { - const result = spawnSync(process.execPath, [probe], { - encoding: "utf8", - ...(env ? { env } : {}), - }); + afterAll(() => rmSync(dir, { recursive: true, force: true })); + + const proof = "A".repeat(43); + const context = JSON.stringify({ + version: 1, + proof, + anthropicEnvSlots: ["ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL"], + }); + + function run(args: string[], contextEnv: string | undefined) { + const env = { ...process.env }; + delete env.OCX_PRE_BUN_ANTHROPIC_ENV; + if (contextEnv === undefined) delete env.OCX_NODE_LAUNCH_CONTEXT; + else env.OCX_NODE_LAUNCH_CONTEXT = contextEnv; + const result = spawnSync(process.execPath, [probe, ...args], { encoding: "utf8", env }); expect(result.status).toBe(0); - return JSON.parse(result.stdout) as { type: string; value: string | null; own: boolean }; + return JSON.parse(result.stdout) as { + context: { anthropicEnvSlots: string[] } | null; + args: string[]; + contextEnv: string | null; + }; } - test("an empty marker arrives as an own property whose value is the empty string", () => { - const seen = probeWith({ ...process.env, OCX_PRE_BUN_ANTHROPIC_ENV: "" }); - expect(seen.type).toBe("string"); - expect(seen.value).toBe(""); - expect(seen.own).toBe(true); + test("matching argv proof authenticates and consumes the parent snapshot", () => { + const seen = run([`--ocx-internal-launch-proof=${proof}`, "claude"], context); + expect(seen.context?.anthropicEnvSlots).toEqual(["ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL"]); + expect(seen.args).toEqual(["claude"]); + expect(seen.contextEnv).toBeNull(); }); - test("a populated marker arrives verbatim", () => { - const seen = probeWith({ ...process.env, OCX_PRE_BUN_ANTHROPIC_ENV: "ANTHROPIC_API_KEY" }); - expect(seen.value).toBe("ANTHROPIC_API_KEY"); + test("a dotenv-forged context without the argv proof is rejected", () => { + const seen = run(["claude"], context); + expect(seen.context).toBeNull(); + expect(seen.args).toEqual(["claude"]); + expect(seen.contextEnv).toBeNull(); }); - // The distinction the fix depends on: absent is NOT the same as empty. - test("an absent marker stays absent rather than becoming an empty string", () => { - const inherited = { ...process.env }; - delete inherited.OCX_PRE_BUN_ANTHROPIC_ENV; - const seen = probeWith(inherited); - expect(seen.type).toBe("undefined"); - expect(seen.own).toBe(false); + test("duplicate internal proofs fail closed and are removed from user argv", () => { + const seen = run([ + `--ocx-internal-launch-proof=${proof}`, + `--ocx-internal-launch-proof=${proof}`, + "claude", + ], context); + expect(seen.context).toBeNull(); + expect(seen.args).toEqual(["claude"]); }); }); diff --git a/tests/cli-catalog-prewarm.test.ts b/tests/cli-catalog-prewarm.test.ts index 1043354a48..de740ba31d 100644 --- a/tests/cli-catalog-prewarm.test.ts +++ b/tests/cli-catalog-prewarm.test.ts @@ -53,7 +53,7 @@ describe("catalog prewarm on handleStart bind", () => { test("handleStart schedules catalog prewarm immediately after a successful bind", async () => { const cli = (await readText("src/cli/index.ts")).replace(/\r\n/g, "\n"); - const bindIdx = cli.indexOf("server = startServer(port);"); + const bindIdx = cli.indexOf("server = startServer(port, localAttestationSecret);"); const prewarmIdx = cli.indexOf("scheduleCatalogPrewarm()"); const breakIdx = cli.indexOf("\n break;", bindIdx); diff --git a/tests/config.test.ts b/tests/config.test.ts index e5855aa49b..f417e95df9 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -1683,10 +1683,11 @@ describe("opencodex config defaults", () => { }); test("runtime port metadata round-trips and validates expected pid", () => { - writeRuntimePort({ pid: 1234, port: 58195, hostname: "0.0.0.0" }); + const attestationSecret = "A".repeat(43); + writeRuntimePort({ pid: 1234, port: 58195, hostname: "0.0.0.0", attestationSecret }); - expect(readRuntimePort()).toEqual({ pid: 1234, port: 58195, hostname: "0.0.0.0" }); - expect(readRuntimePort(1234)).toEqual({ pid: 1234, port: 58195, hostname: "0.0.0.0" }); + expect(readRuntimePort()).toEqual({ pid: 1234, port: 58195, hostname: "0.0.0.0", attestationSecret }); + expect(readRuntimePort(1234)).toEqual({ pid: 1234, port: 58195, hostname: "0.0.0.0", attestationSecret }); expect(readRuntimePort(9999)).toBeNull(); }); @@ -1704,6 +1705,9 @@ describe("opencodex config defaults", () => { writeFileSync(getRuntimePortPath(), JSON.stringify({ pid: 1234, port: 99999 }), "utf-8"); expect(readRuntimePort()).toBeNull(); + + writeFileSync(getRuntimePortPath(), JSON.stringify({ pid: 1234, port: 58195, attestationSecret: "too-short" }), "utf-8"); + expect(readRuntimePort()).toBeNull(); }); }); diff --git a/tests/local-management-attestation.test.ts b/tests/local-management-attestation.test.ts new file mode 100644 index 0000000000..0df59a8601 --- /dev/null +++ b/tests/local-management-attestation.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from "bun:test"; +import { + createLocalAttestationChallenge, + createLocalAttestationProof, + createLocalAttestationSecret, + verifyLocalAttestationProof, +} from "../src/lib/local-management-attestation"; + +describe("local management listener attestation", () => { + test("a proof authenticates one challenge, pid, and port", () => { + const secret = createLocalAttestationSecret(); + const challenge = createLocalAttestationChallenge(); + const proof = createLocalAttestationProof(secret, challenge, 4242, 19191); + expect(proof).not.toBeNull(); + expect(verifyLocalAttestationProof(secret, challenge, 4242, 19191, proof)).toBe(true); + expect(verifyLocalAttestationProof(secret, challenge, 4243, 19191, proof)).toBe(false); + expect(verifyLocalAttestationProof(secret, challenge, 4242, 19192, proof)).toBe(false); + expect(verifyLocalAttestationProof(secret, createLocalAttestationChallenge(), 4242, 19191, proof)).toBe(false); + }); + + test("malformed secrets, challenges, and proofs fail closed", () => { + const secret = createLocalAttestationSecret(); + const challenge = createLocalAttestationChallenge(); + expect(createLocalAttestationProof("short", challenge, 4242, 19191)).toBeNull(); + expect(createLocalAttestationProof(secret, "short", 4242, 19191)).toBeNull(); + expect(verifyLocalAttestationProof(secret, challenge, 4242, 19191, null)).toBe(false); + expect(verifyLocalAttestationProof(secret, challenge, 4242, 19191, "not-a-proof")).toBe(false); + }); +}); diff --git a/tests/oauth-health.test.ts b/tests/oauth-health.test.ts index 920f3d40b9..e8dd3eb246 100644 --- a/tests/oauth-health.test.ts +++ b/tests/oauth-health.test.ts @@ -22,6 +22,11 @@ import { } from "../src/codex/routing"; import type { OcxConfig } from "../src/types"; import { formatOAuthHealthForStatus } from "../src/cli/status-oauth"; +import { + LOCAL_ATTESTATION_CHALLENGE_HEADER, + LOCAL_ATTESTATION_PROOF_HEADER, + createLocalAttestationProof, +} from "../src/lib/local-management-attestation"; const origHome = process.env.HOME; const origOcxHome = process.env.OPENCODEX_HOME; @@ -174,10 +179,17 @@ describe("collectOAuthHealthEntriesForCli", () => { test("uses management API Codex health and does not read CLI process maps", async () => { markCodexAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); process.env.OPENCODEX_ADMIN_AUTH_TOKEN = "ocx-admin-health-test"; + const attestationSecret = "A".repeat(43); let authorization: string | null = null; const report = await collectOAuthHealthEntriesForCli(Date.now(), { - findLiveProxyImpl: async () => ({ hostname: "127.0.0.1", port: 19191, pid: null }), - fetchImpl: async (_input, init) => { + findLiveProxyImpl: async () => ({ hostname: "127.0.0.1", port: 19191, pid: 4242, source: "runtime" }), + readRuntimePortImpl: () => ({ pid: 4242, port: 19191, attestationSecret }), + fetchImpl: async (input, init) => { + if (String(input).endsWith("/healthz")) { + const challenge = new Headers(init?.headers).get(LOCAL_ATTESTATION_CHALLENGE_HEADER)!; + const proof = createLocalAttestationProof(attestationSecret, challenge, 4242, 19191)!; + return new Response("ok", { headers: { [LOCAL_ATTESTATION_PROOF_HEADER]: proof } }); + } authorization = new Headers(init?.headers).get("authorization"); return new Response(JSON.stringify({ accounts: [{ @@ -203,6 +215,39 @@ describe("collectOAuthHealthEntriesForCli", () => { expect(remote?.action).toContain("wait until"); }); + test("never sends the admin token to a configured-port listener without runtime attestation", async () => { + process.env.OPENCODEX_ADMIN_AUTH_TOKEN = "ocx-admin-health-test"; + let fetchCalls = 0; + const report = await collectOAuthHealthEntriesForCli(Date.now(), { + findLiveProxyImpl: async () => ({ hostname: "127.0.0.1", port: 19191, pid: 4242, source: "config" }), + readRuntimePortImpl: () => null, + fetchImpl: async (_input, init) => { + fetchCalls += 1; + expect(new Headers(init?.headers).get("authorization")).toBeNull(); + return new Response("fake"); + }, + }); + expect(fetchCalls).toBe(0); + expect(report.codexHealthSource).toBe("management-api-unavailable"); + }); + + test("an invalid listener proof cannot unlock the bearer-bearing request", async () => { + process.env.OPENCODEX_ADMIN_AUTH_TOKEN = "ocx-admin-health-test"; + const attestationSecret = "A".repeat(43); + let apiCalls = 0; + const report = await collectOAuthHealthEntriesForCli(Date.now(), { + findLiveProxyImpl: async () => ({ hostname: "127.0.0.1", port: 19191, pid: 4242, source: "runtime" }), + readRuntimePortImpl: () => ({ pid: 4242, port: 19191, attestationSecret }), + fetchImpl: async (input, init) => { + expect(new Headers(init?.headers).get("authorization")).toBeNull(); + if (!String(input).endsWith("/healthz")) apiCalls += 1; + return new Response("fake", { headers: { [LOCAL_ATTESTATION_PROOF_HEADER]: "B".repeat(43) } }); + }, + }); + expect(apiCalls).toBe(0); + expect(report.codexHealthSource).toBe("management-api-unavailable"); + }); + test("labels unavailable fallback and omits process-local Codex maps", async () => { markCodexAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); const report = await collectOAuthHealthEntriesForCli(Date.now(), { diff --git a/tests/ocx-launcher-source.test.ts b/tests/ocx-launcher-source.test.ts index 15f5f9a612..f634bf19bf 100644 --- a/tests/ocx-launcher-source.test.ts +++ b/tests/ocx-launcher-source.test.ts @@ -55,10 +55,13 @@ describe("ocx.mjs npm launcher (source invariants)", () => { // auto-load `.env` while the Bun child does. Losing this half silently returns the // proxy to billing a subscriber's API key from an ambient file, and the runtime half in // src/cli/claude.ts would keep passing its own unit tests while doing nothing. - test("the Bun child receives the pre-Bun Anthropic provenance marker", () => { - expect(source).toContain("const preBunAnthropicSlots = [\"ANTHROPIC_API_KEY\", \"ANTHROPIC_AUTH_TOKEN\"]"); - expect(source).toContain("OCX_PRE_BUN_ANTHROPIC_ENV: preBunAnthropicSlots.join(\",\")"); - // The marker must be computed from the launcher's OWN env, before Bun's dotenv load. + test("the Bun child receives proof-bound pre-Bun Anthropic provenance", () => { + expect(source).toContain("const preBunAnthropicSlots = [\"ANTHROPIC_API_KEY\", \"ANTHROPIC_AUTH_TOKEN\", \"ANTHROPIC_BASE_URL\"]"); + expect(source).toContain("const launchProof = randomBytes(32).toString(\"base64url\")"); + expect(source).toContain("[NODE_LAUNCH_CONTEXT_ENV]: launchContext"); + expect(source).toContain("`${NODE_LAUNCH_PROOF_PREFIX}${launchProof}`"); + expect(source).not.toContain("OCX_PRE_BUN_ANTHROPIC_ENV: preBunAnthropicSlots"); + // The snapshot must be computed from the launcher's OWN env, before Bun's dotenv load. expect(source).toContain("typeof process.env[name] === \"string\" && process.env[name] !== \"\""); }); diff --git a/tests/server-management-auth.test.ts b/tests/server-management-auth.test.ts index 076e84f0fa..54f5c79fa3 100644 --- a/tests/server-management-auth.test.ts +++ b/tests/server-management-auth.test.ts @@ -23,6 +23,11 @@ import { timedOutSecretPathCountForTests, hardenSecretDir, } from "../src/lib/windows-secret-acl"; +import { + LOCAL_ATTESTATION_CHALLENGE_HEADER, + LOCAL_ATTESTATION_PROOF_HEADER, + verifyLocalAttestationProof, +} from "../src/lib/local-management-attestation"; const previousHome = process.env.OPENCODEX_HOME; const previousDataToken = process.env.OPENCODEX_API_AUTH_TOKEN; @@ -89,6 +94,21 @@ afterEach(() => { }); describe("management and data-plane credential separation", () => { + test("healthz proves the listener owns the protected runtime secret", async () => { + const secret = "A".repeat(43); + const challenge = "B".repeat(43); + const server = startServer(0, secret); + try { + const health = await fetch(new URL("/healthz", server.url), { + headers: { [LOCAL_ATTESTATION_CHALLENGE_HEADER]: challenge }, + }); + const proof = health.headers.get(LOCAL_ATTESTATION_PROOF_HEADER); + expect(verifyLocalAttestationProof(secret, challenge, process.pid, server.port, proof)).toBe(true); + } finally { + await server.stop(true); + } + }); + test("management-token temp cleanup forgets successful ACL memos and retains failed removals", () => { const temporary = join(testHome, ".admin-token.tmp"); const previousUsername = process.env.USERNAME; diff --git a/tests/service.test.ts b/tests/service.test.ts index a2ebc5838c..aa224a5265 100644 --- a/tests/service.test.ts +++ b/tests/service.test.ts @@ -577,37 +577,47 @@ describe("Windows service task", () => { describe("launchd service plist", () => { test("every durable launcher stamps the Bun provenance paired with the binary it baked (#848)", () => { - const inherited = process.env.OPENCODEX_BUN_PATH; + const inheritedOverride = process.env.OPENCODEX_BUN_PATH; + const inheritedSource = process.env.OCX_BUN_RUNTIME_SOURCE; + const inheritedPath = process.env.OCX_BUN_RUNTIME_PATH; const overrideBun = join(TEST_DIR, "provenance-override-bun.exe"); mkdirSync(TEST_DIR, { recursive: true }); writeFileSync(overrideBun, "x".repeat(2 * 1024 * 1024)); try { - // With a valid override active, every launcher must bake THAT binary and - // label it `override` — a marker that disagreed with the baked path would be - // worse than no marker at all. + // OPENCODEX_BUN_PATH is consumed by the Node launcher before Bun can load a + // project dotenv. Once Bun is running, an unpaired value is untrusted and + // must never be persisted into a durable launcher. + delete process.env.OCX_BUN_RUNTIME_SOURCE; + delete process.env.OCX_BUN_RUNTIME_PATH; process.env.OPENCODEX_BUN_PATH = overrideBun; const plist = buildPlist(); - expect(plist).toContain("OCX_BUN_RUNTIME_SOURCEoverride"); - expectTextToContainPath(plist, overrideBun); + expect(plist).not.toContain("OCX_BUN_RUNTIME_SOURCEoverride"); + expect(plist).not.toContain(overrideBun); const unit = buildUnit(); - expect(unit).toContain('Environment="OCX_BUN_RUNTIME_SOURCE=override"'); - expectTextToContainPath(unit, overrideBun); + expect(unit).not.toContain('Environment="OCX_BUN_RUNTIME_SOURCE=override"'); + expect(unit).not.toContain(overrideBun); const script = buildWindowsServiceScript(); - expect(script).toContain('set "OCX_BUN_RUNTIME_SOURCE=override"'); - expect(script).toContain('echo bun_source="override"'); - - // No override: the same three fall back to the bundled/process runtime and say so. - delete process.env.OPENCODEX_BUN_PATH; - const bundledPlist = buildPlist(); - expect(bundledPlist).toMatch(/OCX_BUN_RUNTIME_SOURCE<\/key>(bundled|process)<\/string>/); - expect(bundledPlist).not.toContain(">override<"); - expect(buildUnit()).toMatch(/Environment="OCX_BUN_RUNTIME_SOURCE=(bundled|process)"/); - expect(buildWindowsServiceScript()).toMatch(/set "OCX_BUN_RUNTIME_SOURCE=(bundled|process)"/); + expect(script).not.toContain('set "OCX_BUN_RUNTIME_SOURCE=override"'); + expect(script).not.toContain(overrideBun); + + // A source/path pair stamped by the Node launcher is accepted only when it + // names the Bun executable that is actually running this process. + process.env.OCX_BUN_RUNTIME_SOURCE = "override"; + process.env.OCX_BUN_RUNTIME_PATH = process.execPath; + const trustedPlist = buildPlist(); + expect(trustedPlist).toContain("OCX_BUN_RUNTIME_SOURCEoverride"); + expectTextToContainPath(trustedPlist, process.execPath); + expect(buildUnit()).toContain('Environment="OCX_BUN_RUNTIME_SOURCE=override"'); + expect(buildWindowsServiceScript()).toContain('set "OCX_BUN_RUNTIME_SOURCE=override"'); } finally { - if (inherited === undefined) delete process.env.OPENCODEX_BUN_PATH; - else process.env.OPENCODEX_BUN_PATH = inherited; + if (inheritedOverride === undefined) delete process.env.OPENCODEX_BUN_PATH; + else process.env.OPENCODEX_BUN_PATH = inheritedOverride; + if (inheritedSource === undefined) delete process.env.OCX_BUN_RUNTIME_SOURCE; + else process.env.OCX_BUN_RUNTIME_SOURCE = inheritedSource; + if (inheritedPath === undefined) delete process.env.OCX_BUN_RUNTIME_PATH; + else process.env.OCX_BUN_RUNTIME_PATH = inheritedPath; } }); diff --git a/tests/update-notify.test.ts b/tests/update-notify.test.ts index a6f200cd51..627a417953 100644 --- a/tests/update-notify.test.ts +++ b/tests/update-notify.test.ts @@ -124,7 +124,7 @@ describe("cli wiring", () => { const cli = await readText("src/cli/index.ts"); const promptIndex = cli.indexOf("await maybeShowUpdatePrompt()"); const portIndex = cli.indexOf("let port = await chooseListenPort"); - const serverIndex = cli.indexOf("startServer(port)"); + const serverIndex = cli.indexOf("startServer(port, localAttestationSecret)"); expect(promptIndex).toBeGreaterThan(-1); expect(portIndex).toBeGreaterThan(-1); expect(promptIndex).toBeLessThan(portIndex); diff --git a/tests/update-stop-first.test.ts b/tests/update-stop-first.test.ts index e1c902a087..8fa65b9adc 100644 --- a/tests/update-stop-first.test.ts +++ b/tests/update-stop-first.test.ts @@ -132,6 +132,6 @@ describe("/healthz identity fields", () => { test("healthz advertises service identity, pid, and port", () => { expect(serverSource).toContain('service: "opencodex"'); expect(serverSource).toContain("pid: process.pid"); - expect(serverSource).toContain("port: listenPort"); + expect(serverSource).toContain("port: healthPort"); }); }); From d8d0b2ffe798f44d3ad40d641577f766f6240e2a Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Mon, 3 Aug 2026 16:18:47 +0900 Subject: [PATCH 003/317] fix(claude): narrow the no-context fallback to the destination slot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of this branch rejected three things. Two are docs; this is the behavior one. With no trusted launcher context, the branch deleted all three ambient Anthropic slots. That breaks a documented entry point: `bun src/cli/index.ts` is supported (structure/01_runtime.md:9) and has no launcher context, so a user who exported ANTHROPIC_API_KEY in their shell simply loses it. The two slot classes are not symmetric. ANTHROPIC_BASE_URL stays fail-closed — a dotenv-only destination combined with subscription auth is exactly how Claude's OAuth bearer and prompt leave for a host the repository chose, and losing a legitimate custom destination costs a flag rather than an account. Credentials are preserved: the destination is already pinned by the time they are read, so stripping them defends against a project file that could equally well have supplied the key it is being blamed for. The test that mandated the old behavior is replaced by two: an ambient key survives without context, and an ambient base URL is still replaced. That pair is the contract. Also corrected two structure docs the review flagged as stale: the ci.yml row still described the pre-#899 hosted-Windows selector, and the runtime decision log recorded the rejected fail-closed-for-everything behavior. --- src/cli/claude.ts | 39 +++++++++++++++++++++++--------- structure/01_runtime.md | 2 +- structure/06_docs-and-release.md | 7 +++--- tests/claude-auth-mode.test.ts | 22 ++++++++++++++++-- 4 files changed, 53 insertions(+), 17 deletions(-) diff --git a/src/cli/claude.ts b/src/cli/claude.ts index 66de3d340a..87b3118dc5 100644 --- a/src/cli/claude.ts +++ b/src/cli/claude.ts @@ -55,20 +55,37 @@ export function buildClaudeEnv( // leaving the child with no token at all (audit R2-1). It is opencodex state, never // user auth, so dropping it unconditionally is safe. if (env.ANTHROPIC_AUTH_TOKEN === PROXY_MARKER) delete env.ANTHROPIC_AUTH_TOKEN; - // Step 1b — drop Anthropic credentials AND destinations that Bun synthesized from a - // project `.env`/`.env.local`. Preserving a dotenv-only ANTHROPIC_BASE_URL while - // selecting subscription auth sends Claude's OAuth bearer and prompt to that host. - // The plain-Node launcher records genuine parent exports before Bun starts and pairs - // that context with an argv proof. Without a trusted context (direct Bun or an older - // launcher) we fail closed and treat all three ambient slots as project-controlled. + // Step 1b — drop Anthropic slots that Bun may have synthesized from a project + // `.env`/`.env.local`. The plain-Node launcher records genuine parent exports before + // Bun starts and pairs that context with an argv proof, so with a trusted context we + // know exactly which slots the user really exported. + // + // Without a trusted context the two slot classes get different treatment, because the + // consequences are not symmetric: + // + // - ANTHROPIC_BASE_URL is fail-closed. A dotenv-only destination combined with + // subscription auth sends Claude's OAuth bearer and prompt to a host the repository + // chose, which is the attack this hardening exists to stop. Losing a legitimate + // custom destination costs the user a flag; leaking the bearer costs them the + // account. + // - Credentials are preserved. `bun src/cli/index.ts` is a documented entry point + // (structure/01_runtime.md:9), and a shell-exported ANTHROPIC_API_KEY there is + // ordinary usage. Deleting it breaks that path to defend against a project file + // that could equally well have set the key it is being blamed for supplying — the + // credential goes to the destination either way, so stripping it buys nothing once + // the destination is already pinned. const explicitSlots = deps.preBunAnthropicSlots; - const trustedSlots = explicitSlots === undefined - ? trustedNodeLauncherContext()?.anthropicEnvSlots ?? [] - : explicitSlots ?? []; - const exported = new Set(trustedSlots); + const trustedContext = explicitSlots === undefined ? trustedNodeLauncherContext() : undefined; + const trustedSlots = explicitSlots ?? trustedContext?.anthropicEnvSlots; + const exported = new Set(trustedSlots ?? []); + // No launcher context at all: only the destination is treated as untrusted. + const untrustedProvenance = trustedSlots === undefined; for (const name of ANTHROPIC_PARENT_ENV_SLOTS) { const value = env[name]; - if (value !== undefined && value !== "" && !exported.has(name)) delete env[name]; + if (value === undefined || value === "") continue; + if (exported.has(name)) continue; + if (untrustedProvenance && name !== "ANTHROPIC_BASE_URL") continue; + delete env[name]; } // Never forward old or current provenance seams to Claude Code. delete env.OCX_PRE_BUN_ANTHROPIC_ENV; diff --git a/structure/01_runtime.md b/structure/01_runtime.md index 1c74fb3115..267c02314c 100644 --- a/structure/01_runtime.md +++ b/structure/01_runtime.md @@ -76,7 +76,7 @@ The server exposes `POST /api/stop` which restores native Codex config, stops an - 검토한 주요 대안: Reject only relative Bun paths; disable Bun dotenv; trust a plain environment marker; capture provenance in the Node launcher and bind it to an argv proof. - 선택한 방식: The Node launcher selects Bun and snapshots Anthropic credential/destination slots before Bun starts. Durable runtime selection uses only the stamped current executable, while Claude accepts the snapshot only when its random argv proof matches. - 다른 대안 대신 이 방식을 선택한 이유: Absolute dotenv expansion bypasses a relative-path check, global dotenv removal breaks supported configuration, and an environment-only marker can itself come from dotenv. -- 장점, 단점 및 영향: Normal npm launches preserve genuine shell overrides; direct Bun or legacy launches fail closed for ambient Anthropic auth/destination values and use the running or bundled Bun for durable artifacts. +- 장점, 단점 및 영향: Normal npm launches preserve genuine shell overrides. Direct Bun or legacy launches have no provenance signal, so the two slot classes diverge: `ANTHROPIC_BASE_URL` fails closed (a dotenv-only destination plus subscription auth is how the OAuth bearer leaves for a repository-chosen host), while ambient credentials are preserved because `bun src/cli/index.ts` is a documented entry point and the destination is already pinned. Durable artifacts use the running or bundled Bun. ## Providers and adapters diff --git a/structure/06_docs-and-release.md b/structure/06_docs-and-release.md index dc39603e6e..ea271b5c05 100644 --- a/structure/06_docs-and-release.md +++ b/structure/06_docs-and-release.md @@ -40,7 +40,7 @@ bun run build | Workflow | Trigger | Purpose | | --- | --- | --- | -| `.github/workflows/ci.yml` | `pull_request` to `main`/`dev`, `push` to `main`/`preview`/`dev`, or manual dispatch when runtime/package paths change | Cross-platform runtime/package quality gate on Linux, Windows, and macOS. The Bun `test` job keeps pull requests on GitHub-hosted Windows while trusted `push`/manual runs may use the `ocx-home` self-hosted Windows runner when the repository switch is enabled; this preserves the hosted-Windows Bun crash workaround. `npm-global-smoke` always remains GitHub-hosted because it mutates the global package prefix. | +| `.github/workflows/ci.yml` | `pull_request` to `main`/`dev`, `push` to `main`/`preview`/`dev`, or manual dispatch when runtime/package paths change | Cross-platform runtime/package quality gate. Linux runs the suite as four parallel shards (`test 1/4`–`4/4`) plus a consolidated `gates` job; macOS runs the full suite. Windows runs the full suite only on a `push` to `main`/`preview` or a manual dispatch — it is the shipping boundary, not the pull-request lane, because it was last to finish in every sampled run at roughly three times the Linux median. The aggregate `ci` job asserts `platform-windows` actually succeeded on those boundary events rather than accepting a skip. `npm-global-smoke` always remains GitHub-hosted because it mutates the global package prefix. | | `.github/workflows/release.yml` | Manual dispatch only | npm publish/dry-run workflow. It requires the exact `GITHUB_SHA` to have a successful Cross-platform CI run before publish or dry-run. | | `.github/workflows/deploy-docs.yml` | `push` to `main` touching `docs-site/**` or the workflow, or manual dispatch | Build and publish the Astro/Starlight docs site to GitHub Pages. | | `.github/workflows/service-lifecycle.yml` | `pull_request` to `main`/`dev` and `push`, both filtered on the service path set (`src/service.ts`, `src/cli.ts`, `src/cli/index.ts`, `src/lib/bun-runtime.ts`, `package.json`, `bun.lock`, the workflow), or manual dispatch | Service-lifecycle smoke on three platforms: Linux systemd, macOS launchd, and Windows Scheduled Tasks. Each installs, verifies, stops via `ocx stop`, and uninstalls. The path list is kept in sync with the `release.yml` service-gate regex. | @@ -187,8 +187,9 @@ version through `scripts/release.ts`. ## Cross-platform CI -`.github/workflows/ci.yml` is the ordinary quality gate for runtime/package changes. It runs on -Linux, Windows, and macOS with two job families: +`.github/workflows/ci.yml` is the ordinary quality gate for runtime/package changes. Linux runs +the suite in four shards with a separate `gates` job, macOS runs it whole, and Windows runs whole +but only at the shipping boundary (`push` to `main`/`preview`, or manual dispatch). Each lane runs: ```bash bun install --frozen-lockfile diff --git a/tests/claude-auth-mode.test.ts b/tests/claude-auth-mode.test.ts index 0e052425a4..135be18180 100644 --- a/tests/claude-auth-mode.test.ts +++ b/tests/claude-auth-mode.test.ts @@ -204,14 +204,32 @@ test("the configured admission key survives the dotenv strip", () => { expect(env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST).toBe("1"); }); -test("without trusted launcher context an ambient key is removed", () => { +test("without trusted launcher context an ambient key is preserved", () => { + // `bun src/cli/index.ts` is a documented entry point (structure/01_runtime.md:9) and + // has no launcher context, so a shell-exported key there is ordinary usage. Deleting + // it would break that path to defend against a project file that could equally well + // have supplied the key being blamed — and the destination is already pinned below, + // so stripping the credential buys nothing. const env = buildClaudeEnv( cfg(), 10100, { ANTHROPIC_API_KEY: "sk-ant-user" }, {}, { authDetect: fileAuth("present") }, ); - expect(env.ANTHROPIC_API_KEY).toBeUndefined(); + expect(env.ANTHROPIC_API_KEY).toBe("sk-ant-user"); +}); + +test("without trusted launcher context an ambient base URL is still replaced", () => { + // The asymmetry that makes the test above safe: the destination is fail-closed even + // with no context, because a dotenv-only base URL plus subscription auth is exactly + // how the OAuth bearer leaves for a host the repository chose. + const env = buildClaudeEnv( + cfg(), 10100, + { ANTHROPIC_BASE_URL: "https://attacker.example" }, + {}, + { authDetect: fileAuth("present") }, + ); + expect(env.ANTHROPIC_BASE_URL).toBe("http://127.0.0.1:10100"); }); test("a dotenv-only base URL cannot receive subscription OAuth", () => { From 727722cbadc88bc3cb9dd55e582c3e1b158922b9 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Mon, 3 Aug 2026 16:31:21 +0900 Subject: [PATCH 004/317] fix(claude): restore fail-closed credential stripping, and pin why MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The security review rejected my narrowing, correctly. I had preserved ambient credentials when no launcher context exists, reasoning that the destination is pinned before they are read so a dotenv key would only reach the local proxy. That reasoning does not survive the subscription path: CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST is only set when opencodex owns an auth token — asserting it otherwise logs a subscriber out (#253) — so Claude Code's settings.env merge can still replace ANTHROPIC_BASE_URL after buildClaudeEnv returns. A preserved key travels to that host. The repository documents the destination residual for subscription mode; preserving credentials would have widened it into a credential leak. So all three slots fail closed again without provenance. Direct `bun src/cli/index.ts` loses ambient Anthropic values, which is a real cost to a documented entry point; the escape hatch is the published `ocx` bin, where genuine shell exports survive by proof. The gap that let the bad revision pass: the suite tested no-context credential handling and settings-hijack separately, never combined. It does now — a no-context ambient key must be absent after the merge that hijacks the destination. Reintroducing the narrowing fails 6 tests. --- src/cli/claude.ts | 47 +++++++++++++++------------------- structure/01_runtime.md | 2 +- tests/claude-auth-mode.test.ts | 45 +++++++++++++++++--------------- 3 files changed, 47 insertions(+), 47 deletions(-) diff --git a/src/cli/claude.ts b/src/cli/claude.ts index 87b3118dc5..4284ea6ce7 100644 --- a/src/cli/claude.ts +++ b/src/cli/claude.ts @@ -55,37 +55,32 @@ export function buildClaudeEnv( // leaving the child with no token at all (audit R2-1). It is opencodex state, never // user auth, so dropping it unconditionally is safe. if (env.ANTHROPIC_AUTH_TOKEN === PROXY_MARKER) delete env.ANTHROPIC_AUTH_TOKEN; - // Step 1b — drop Anthropic slots that Bun may have synthesized from a project - // `.env`/`.env.local`. The plain-Node launcher records genuine parent exports before - // Bun starts and pairs that context with an argv proof, so with a trusted context we - // know exactly which slots the user really exported. + // Step 1b — drop Anthropic credentials AND destinations that Bun may have synthesized + // from a project `.env`/`.env.local`. The plain-Node launcher records genuine parent + // exports before Bun starts and pairs that context with an argv proof, so with a + // trusted context we know exactly which slots the user really exported. // - // Without a trusted context the two slot classes get different treatment, because the - // consequences are not symmetric: + // Without a trusted context all three slots are treated as project-controlled. An + // earlier revision of this branch preserved credentials here, reasoning that the + // destination is pinned below so a dotenv key would only ever reach the local proxy. + // That reasoning is wrong, and review caught it: `CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST` + // is only set when we own an auth token (see below, and #253 for why asserting it + // otherwise logs a subscriber out), so on a subscription launch Claude Code's + // settings.env merge can still replace `ANTHROPIC_BASE_URL` after we return. A + // preserved key then travels to that host. The repository documents that residual for + // subscription mode; it must not be widened into a credential leak. // - // - ANTHROPIC_BASE_URL is fail-closed. A dotenv-only destination combined with - // subscription auth sends Claude's OAuth bearer and prompt to a host the repository - // chose, which is the attack this hardening exists to stop. Losing a legitimate - // custom destination costs the user a flag; leaking the bearer costs them the - // account. - // - Credentials are preserved. `bun src/cli/index.ts` is a documented entry point - // (structure/01_runtime.md:9), and a shell-exported ANTHROPIC_API_KEY there is - // ordinary usage. Deleting it breaks that path to defend against a project file - // that could equally well have set the key it is being blamed for supplying — the - // credential goes to the destination either way, so stripping it buys nothing once - // the destination is already pinned. + // Direct `bun src/cli/index.ts` therefore loses ambient Anthropic values. That is a + // real cost to a documented entry point, and the escape hatch is the launcher: run + // through `ocx` (the published bin) and genuine shell exports are preserved by proof. const explicitSlots = deps.preBunAnthropicSlots; - const trustedContext = explicitSlots === undefined ? trustedNodeLauncherContext() : undefined; - const trustedSlots = explicitSlots ?? trustedContext?.anthropicEnvSlots; - const exported = new Set(trustedSlots ?? []); - // No launcher context at all: only the destination is treated as untrusted. - const untrustedProvenance = trustedSlots === undefined; + const trustedSlots = explicitSlots === undefined + ? trustedNodeLauncherContext()?.anthropicEnvSlots ?? [] + : explicitSlots ?? []; + const exported = new Set(trustedSlots); for (const name of ANTHROPIC_PARENT_ENV_SLOTS) { const value = env[name]; - if (value === undefined || value === "") continue; - if (exported.has(name)) continue; - if (untrustedProvenance && name !== "ANTHROPIC_BASE_URL") continue; - delete env[name]; + if (value !== undefined && value !== "" && !exported.has(name)) delete env[name]; } // Never forward old or current provenance seams to Claude Code. delete env.OCX_PRE_BUN_ANTHROPIC_ENV; diff --git a/structure/01_runtime.md b/structure/01_runtime.md index 267c02314c..e0977229a3 100644 --- a/structure/01_runtime.md +++ b/structure/01_runtime.md @@ -76,7 +76,7 @@ The server exposes `POST /api/stop` which restores native Codex config, stops an - 검토한 주요 대안: Reject only relative Bun paths; disable Bun dotenv; trust a plain environment marker; capture provenance in the Node launcher and bind it to an argv proof. - 선택한 방식: The Node launcher selects Bun and snapshots Anthropic credential/destination slots before Bun starts. Durable runtime selection uses only the stamped current executable, while Claude accepts the snapshot only when its random argv proof matches. - 다른 대안 대신 이 방식을 선택한 이유: Absolute dotenv expansion bypasses a relative-path check, global dotenv removal breaks supported configuration, and an environment-only marker can itself come from dotenv. -- 장점, 단점 및 영향: Normal npm launches preserve genuine shell overrides. Direct Bun or legacy launches have no provenance signal, so the two slot classes diverge: `ANTHROPIC_BASE_URL` fails closed (a dotenv-only destination plus subscription auth is how the OAuth bearer leaves for a repository-chosen host), while ambient credentials are preserved because `bun src/cli/index.ts` is a documented entry point and the destination is already pinned. Durable artifacts use the running or bundled Bun. +- 장점, 단점 및 영향: Normal npm launches preserve genuine shell overrides. Direct Bun or legacy launches have no provenance signal and fail closed for all three ambient Anthropic slots — credentials included, because subscription mode leaves `CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST` unset by design (#253) and a `settings.env` merge can still replace the destination after launch, so a preserved key would travel with it. The cost is that `bun src/cli/index.ts` loses ambient Anthropic values; the escape hatch is running through the published `ocx` bin, where genuine shell exports are preserved by proof. Durable artifacts use the running or bundled Bun. ## Providers and adapters diff --git a/tests/claude-auth-mode.test.ts b/tests/claude-auth-mode.test.ts index 135be18180..4504a055d7 100644 --- a/tests/claude-auth-mode.test.ts +++ b/tests/claude-auth-mode.test.ts @@ -204,32 +204,14 @@ test("the configured admission key survives the dotenv strip", () => { expect(env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST).toBe("1"); }); -test("without trusted launcher context an ambient key is preserved", () => { - // `bun src/cli/index.ts` is a documented entry point (structure/01_runtime.md:9) and - // has no launcher context, so a shell-exported key there is ordinary usage. Deleting - // it would break that path to defend against a project file that could equally well - // have supplied the key being blamed — and the destination is already pinned below, - // so stripping the credential buys nothing. +test("without trusted launcher context an ambient key is removed", () => { const env = buildClaudeEnv( cfg(), 10100, { ANTHROPIC_API_KEY: "sk-ant-user" }, {}, { authDetect: fileAuth("present") }, ); - expect(env.ANTHROPIC_API_KEY).toBe("sk-ant-user"); -}); - -test("without trusted launcher context an ambient base URL is still replaced", () => { - // The asymmetry that makes the test above safe: the destination is fail-closed even - // with no context, because a dotenv-only base URL plus subscription auth is exactly - // how the OAuth bearer leaves for a host the repository chose. - const env = buildClaudeEnv( - cfg(), 10100, - { ANTHROPIC_BASE_URL: "https://attacker.example" }, - {}, - { authDetect: fileAuth("present") }, - ); - expect(env.ANTHROPIC_BASE_URL).toBe("http://127.0.0.1:10100"); + expect(env.ANTHROPIC_API_KEY).toBeUndefined(); }); test("a dotenv-only base URL cannot receive subscription OAuth", () => { @@ -370,3 +352,26 @@ test("subscription mode has no hijack defence, by design", () => { // The leftover DOES win here. Choosing proxy mode explicitly is the escape hatch. expect(merged.ANTHROPIC_BASE_URL).toBe("https://hijacker.example.com"); }); + +// The two states above are individually documented; this pins what happens when they +// COMBINE, which is the gap that let a bad revision of this branch through review. +// +// A revision preserved no-context ambient credentials, reasoning that the destination +// is pinned before they are read. But subscription mode leaves +// CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST unset (by design, #253), so settings.env still +// replaces ANTHROPIC_BASE_URL after buildClaudeEnv returns. A preserved key would then +// travel to the hijacker's host. Stripping ambient credentials without provenance is +// what keeps the documented residual a destination problem instead of a credential leak. +test("a no-context ambient key cannot ride a settings-hijacked destination", () => { + const launch = buildClaudeEnv( + cfg(), 10100, + { ANTHROPIC_API_KEY: "sk-ant-user" }, + {}, + { authDetect: fileAuth("present") }, + ); + const merged = simulateClaudeCodeSettingsMerge(launch, CC_SWITCH_LEFTOVER); + // The destination is still hijackable — that residual is unchanged and documented. + expect(merged.ANTHROPIC_BASE_URL).toBe("https://hijacker.example.com"); + // But the user's key is not there to be sent with it. + expect(merged.ANTHROPIC_API_KEY).toBeUndefined(); +}); From a6dc37aee07aa22edcffd3e881edf9186e52de12 Mon Sep 17 00:00:00 2001 From: devmello Date: Mon, 3 Aug 2026 21:19:50 -0700 Subject: [PATCH 005/317] fix(claude): stop counting base64 attachments as raw characters in token estimates --- src/server/claude-messages.ts | 56 +++++++++++---- tests/claude-messages-endpoint.test.ts | 94 ++++++++++++++++++++++++++ 2 files changed, 138 insertions(+), 12 deletions(-) diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index 0d6711e00c..0114b5cec0 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -7,7 +7,7 @@ * unchanged. The Responses output (SSE or JSON) is converted back to Anthropic shape. */ import { FORWARD_HEADERS } from "../adapters/openai-responses"; -import { enforceAnthropicImageLimits } from "../adapters/anthropic-image-guard"; +import { enforceAnthropicImageLimits, sniffImageDimensions } from "../adapters/anthropic-image-guard"; import { normalizeAnthropicImages } from "../adapters/anthropic-image-normalize"; import { AnthropicRequestError, anthropicToResponsesTranslation, extractOcxEffortDirective, extractOcxRouteDirective, resolveInboundModel, type ClaudeCacheKeySource } from "../claude/inbound"; import { resolveDesktop3pAlias } from "../claude/desktop-3p"; @@ -645,12 +645,7 @@ async function handleClaudeMessagesWithBudget( // accurate-usage adapters — the request-log merge is max(reported, estimate) and // would overwrite real usage (audit 133 R1#7). if (route.provider.adapter === "cursor" || route.provider.adapter === "kiro") { - const raw = anthropicBody as Rec; - const parts: string[] = []; - if (raw.system !== undefined) parts.push(typeof raw.system === "string" ? raw.system : JSON.stringify(raw.system)); - if (raw.messages !== undefined) parts.push(JSON.stringify(raw.messages)); - if (raw.tools !== undefined) parts.push(JSON.stringify(raw.tools)); - logCtx.usageLogInputTokens = Math.max(1, estimateTokens(parts.join("\n"), requestedModel)); + logCtx.usageLogInputTokens = estimateClaudeRequestTokens(anthropicBody as Rec, requestedModel); } // Effort safety valve (devlog 136 B6, audit 139 R2#2): opus-shaped aliases make // every routed model look like a reasoning model to Claude clients, so a forced @@ -865,6 +860,47 @@ async function handleClaudeMessagesWithBudget( } /** Documented approximation: serialize system+messages+tools, run the char estimator. */ +/** Per-attachment token estimate for a base64 payload: real image dimensions when the + * header is sniffable (Anthropic prices images at ~pixels/750), else decoded bytes/512, + * min 256 — the same shape as the Kiro usage estimator (estimateKiroImageTokens). */ +function estimateBase64AttachmentTokens(data: string): number { + const dims = sniffImageDimensions(data); + if (dims) return Math.max(256, Math.ceil((dims.width * dims.height) / 750)); + return Math.max(256, Math.ceil((data.length * 3) / 4 / 512)); +} + +/** + * Char-based token estimate for an Anthropic-shaped request body. Base64 attachment + * payloads (image/document sources, wherever they nest — including tool_result content) + * are counted as a bounded per-attachment estimate instead of raw characters: one 2MB + * screenshot is ~2.7M base64 chars, which the plain chars/token divide reports as + * hundreds of thousands of tokens versus a real cost around 1.6k. That breaks the >2x + * drift bound the estimator is held to (devlog 260711_claude_inbound 040 §3). Text and + * url sources are left in place and counted as characters. + */ +export function estimateClaudeRequestTokens( + raw: { system?: unknown; messages?: unknown; tools?: unknown }, + modelId: string | undefined, +): number { + let attachmentTokens = 0; + const stripAttachments = (value: unknown): string => + JSON.stringify(value, (_key, entry: unknown) => { + if (entry && typeof entry === "object") { + const source = entry as { type?: unknown; data?: unknown }; + if (source.type === "base64" && typeof source.data === "string") { + attachmentTokens += estimateBase64AttachmentTokens(source.data); + return { ...(entry as Record), data: "" }; + } + } + return entry; + }); + const parts: string[] = []; + if (raw.system !== undefined) parts.push(typeof raw.system === "string" ? raw.system : stripAttachments(raw.system)); + if (raw.messages !== undefined) parts.push(stripAttachments(raw.messages)); + if (raw.tools !== undefined) parts.push(stripAttachments(raw.tools)); + return Math.max(1, estimateTokens(parts.join("\n"), modelId) + attachmentTokens); +} + export async function handleClaudeCountTokens(req: Request, config: OcxConfig): Promise { const disabled = claudeInboundDisabled(config); if (disabled) return disabled; @@ -901,11 +937,7 @@ export async function handleClaudeCountTokens(req: Request, config: OcxConfig): if (wantsNativePassthrough(req, config, model)) { return await anthropicNativePassthrough(req, config, { model, provider: "anthropic-native", surface: "claude" }, undefined, raw, "/v1/messages/count_tokens"); } - const parts: string[] = []; - if (raw.system !== undefined) parts.push(typeof raw.system === "string" ? raw.system : JSON.stringify(raw.system)); - if (raw.messages !== undefined) parts.push(JSON.stringify(raw.messages)); - if (raw.tools !== undefined) parts.push(JSON.stringify(raw.tools)); - const inputTokens = Math.max(1, estimateTokens(parts.join("\n"), model)); + const inputTokens = estimateClaudeRequestTokens(raw, model); return new Response(JSON.stringify({ input_tokens: inputTokens }), { status: 200, headers: { "Content-Type": "application/json" }, diff --git a/tests/claude-messages-endpoint.test.ts b/tests/claude-messages-endpoint.test.ts index 01a74e455b..8f16aee8d3 100644 --- a/tests/claude-messages-endpoint.test.ts +++ b/tests/claude-messages-endpoint.test.ts @@ -10,12 +10,14 @@ import { clearableDeadline } from "../src/lib/abort"; import type { RequestLogContext } from "../src/server/request-log"; import { startServer } from "../src/server"; import { + estimateClaudeRequestTokens, fetchWithHeaderDeadline, handleClaudeMessages, readBoundedPassthroughBody, resolvePassthroughBodyGuard, tapAnthropicSseForLog, } from "../src/server/claude-messages"; +import { estimateTokens } from "../src/lib/token-estimate"; import type { OcxConfig } from "../src/types"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "./helpers/isolated-codex-home"; import { SERVER_BUDGET_MS } from "./helpers/test-budget"; @@ -935,6 +937,98 @@ test("count_tokens returns a positive estimate in the exact contract shape", asy } }); +/** Minimal PNG header (signature + IHDR) so the attachment sniffer can read real dimensions. */ +function countTokensPngBase64(width: number, height: number): string { + const u32be = (n: number): number[] => [(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff]; + const bytes = [ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + ...u32be(13), 0x49, 0x48, 0x44, 0x52, // len + "IHDR" + ...u32be(width), ...u32be(height), + 8, 6, 0, 0, 0, // bit depth, color type, etc. + ]; + return Buffer.from(Uint8Array.from(bytes)).toString("base64"); +} + +test("count_tokens prices base64 attachments as attachments, not characters", async () => { + saveConfig(mockConfig("http://127.0.0.1:1/v1")); + const server = startServer(0); + try { + const data = "A".repeat(700_000); // ~512KB decoded; counting chars would report ~200k tokens + const response = await fetch(new URL("/v1/messages/count_tokens", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "mock/test-model", + messages: [{ + role: "user", + content: [ + { type: "text", text: "what is in this screenshot?" }, + { type: "image", source: { type: "base64", media_type: "image/png", data } }, + ], + }], + }), + }); + expect(response.status).toBe(200); + const json = await response.json() as { input_tokens: number }; + // ceil(700000 * 3/4 / 512) = 1026 attachment tokens plus a small text remainder. + expect(json.input_tokens).toBeGreaterThanOrEqual(1026); + expect(json.input_tokens).toBeLessThan(2000); + } finally { + await server.stop(true); + } +}); + +test("estimateClaudeRequestTokens matches the plain char estimate for text-only bodies", () => { + const raw = { + system: "be brief", + messages: [{ role: "user", content: "count me please, this is a sentence" }], + tools: [{ name: "Read", input_schema: { type: "object" } }], + }; + const parts = [raw.system, JSON.stringify(raw.messages), JSON.stringify(raw.tools)]; + expect(estimateClaudeRequestTokens(raw, "m")).toBe(Math.max(1, estimateTokens(parts.join("\n"), "m"))); +}); + +test("estimateClaudeRequestTokens prices sniffable images by pixel dimensions", () => { + const raw = { + messages: [{ + role: "user", + content: [{ type: "image", source: { type: "base64", media_type: "image/png", data: countTokensPngBase64(1500, 2000) } }], + }], + }; + const estimate = estimateClaudeRequestTokens(raw, "m"); + // ceil(1500 * 2000 / 750) = 4000 attachment tokens plus the JSON skeleton. + expect(estimate).toBeGreaterThanOrEqual(4000); + expect(estimate).toBeLessThan(4100); +}); + +test("estimateClaudeRequestTokens strips base64 documents nested in tool_result content", () => { + const raw = { + messages: [{ + role: "user", + content: [{ + type: "tool_result", + tool_use_id: "t1", + content: [{ type: "document", source: { type: "base64", media_type: "application/pdf", data: "Q".repeat(400_000) } }], + }], + }], + }; + const estimate = estimateClaudeRequestTokens(raw, "m"); + // ceil(400000 * 3/4 / 512) = 586 tokens, nowhere near the ~114k a char count would report. + expect(estimate).toBeGreaterThanOrEqual(586); + expect(estimate).toBeLessThan(1000); +}); + +test("estimateClaudeRequestTokens counts text-source documents as ordinary text", () => { + const text = "plain text document body ".repeat(40); + const raw = { + messages: [{ + role: "user", + content: [{ type: "document", source: { type: "text", media_type: "text/plain", data: text } }], + }], + }; + expect(estimateClaudeRequestTokens(raw, "m")).toBe(Math.max(1, estimateTokens(JSON.stringify(raw.messages), "m"))); +}); + test("claudeCode.enabled=false -> 403 permission_error on both routes", async () => { saveConfig(mockConfig("http://127.0.0.1:1/v1", { enabled: false })); const server = startServer(0); From 3b78fddf6bc5c7a3b23da14f80ba7ff7d94ff9c0 Mon Sep 17 00:00:00 2001 From: devmello Date: Mon, 3 Aug 2026 22:28:40 -0700 Subject: [PATCH 006/317] fix(claude): match only attachment blocks and ignore base64 padding --- src/server/claude-messages.ts | 20 ++++++++++++----- tests/claude-messages-endpoint.test.ts | 31 ++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index 0114b5cec0..e337dce4ad 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -866,7 +866,8 @@ async function handleClaudeMessagesWithBudget( function estimateBase64AttachmentTokens(data: string): number { const dims = sniffImageDimensions(data); if (dims) return Math.max(256, Math.ceil((dims.width * dims.height) / 750)); - return Math.max(256, Math.ceil((data.length * 3) / 4 / 512)); + const unpadded = data.endsWith("==") ? data.length - 2 : data.endsWith("=") ? data.length - 1 : data.length; + return Math.max(256, Math.ceil(Math.floor((unpadded * 3) / 4) / 512)); } /** @@ -885,11 +886,20 @@ export function estimateClaudeRequestTokens( let attachmentTokens = 0; const stripAttachments = (value: unknown): string => JSON.stringify(value, (_key, entry: unknown) => { + // Match only real attachment blocks. A bare {type:"base64", data} shape can also + // appear inside tool_use.input, and those arguments ARE sent to routed providers, + // so they must keep counting as text. if (entry && typeof entry === "object") { - const source = entry as { type?: unknown; data?: unknown }; - if (source.type === "base64" && typeof source.data === "string") { - attachmentTokens += estimateBase64AttachmentTokens(source.data); - return { ...(entry as Record), data: "" }; + const block = entry as { type?: unknown; source?: unknown }; + if (block.type === "image" || block.type === "document") { + const source = block.source as { type?: unknown; data?: unknown } | undefined; + if (source && typeof source === "object" && source.type === "base64" && typeof source.data === "string") { + attachmentTokens += estimateBase64AttachmentTokens(source.data); + return { + ...(entry as Record), + source: { ...(source as Record), data: "" }, + }; + } } } return entry; diff --git a/tests/claude-messages-endpoint.test.ts b/tests/claude-messages-endpoint.test.ts index 8f16aee8d3..1ae0ade255 100644 --- a/tests/claude-messages-endpoint.test.ts +++ b/tests/claude-messages-endpoint.test.ts @@ -1018,6 +1018,37 @@ test("estimateClaudeRequestTokens strips base64 documents nested in tool_result expect(estimate).toBeLessThan(1000); }); +test("estimateClaudeRequestTokens does not charge base64 padding as payload bytes", () => { + // Exactly 131072 decoded bytes: 174764 base64 chars ending in "=". Counting the padding + // would yield 131073 bytes and charge 257 tokens instead of 256. + const data = Buffer.from(new Uint8Array(131_072)).toString("base64"); + const raw = { + messages: [{ role: "user", content: [{ type: "image", source: { type: "base64", media_type: "image/png", data } }] }], + }; + const stripped = { + messages: [{ role: "user", content: [{ type: "image", source: { type: "base64", media_type: "image/png", data: "" } }] }], + }; + + expect(estimateClaudeRequestTokens(raw, "m")).toBe( + Math.max(1, estimateTokens(JSON.stringify(stripped.messages), "m") + 256), + ); +}); + +test("estimateClaudeRequestTokens keeps base64-shaped tool_use input counted as text", () => { + // tool_use.input is serialized into function_call arguments and sent upstream, so a + // {type:"base64", data} shape inside it is NOT an attachment and must count as text. + const raw = { + messages: [{ + role: "assistant", + content: [{ type: "tool_use", id: "t1", name: "upload", input: { type: "base64", data: "B".repeat(40_000) } }], + }], + }; + + expect(estimateClaudeRequestTokens(raw, "m")).toBe( + Math.max(1, estimateTokens(JSON.stringify(raw.messages), "m")), + ); +}); + test("estimateClaudeRequestTokens counts text-source documents as ordinary text", () => { const text = "plain text document body ".repeat(40); const raw = { From 1ad2be010e733b490d1a167316ed599728dd251e Mon Sep 17 00:00:00 2001 From: devmello Date: Mon, 3 Aug 2026 23:14:03 -0700 Subject: [PATCH 007/317] fix(claude): sanitize only protocol content blocks in token estimates --- src/server/claude-messages.ts | 67 +++++++++++++++----------- tests/claude-messages-endpoint.test.ts | 39 +++++++++++++++ 2 files changed, 78 insertions(+), 28 deletions(-) diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index e337dce4ad..34cdc91cd8 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -872,42 +872,53 @@ function estimateBase64AttachmentTokens(data: string): number { /** * Char-based token estimate for an Anthropic-shaped request body. Base64 attachment - * payloads (image/document sources, wherever they nest — including tool_result content) - * are counted as a bounded per-attachment estimate instead of raw characters: one 2MB - * screenshot is ~2.7M base64 chars, which the plain chars/token divide reports as - * hundreds of thousands of tokens versus a real cost around 1.6k. That breaks the >2x - * drift bound the estimator is held to (devlog 260711_claude_inbound 040 §3). Text and - * url sources are left in place and counted as characters. + * payloads (image/document blocks in message content, including blocks nested in + * tool_result.content) are counted as a bounded per-attachment estimate instead of raw + * characters: one 2MB screenshot is ~2.7M base64 chars, which the plain chars/token + * divide reports as hundreds of thousands of tokens versus a real cost around 1.6k. + * That breaks the >2x drift bound the estimator is held to (devlog 260711_claude_inbound + * 040 §3). Text and url sources are left in place and counted as characters, as is + * anything outside protocol content positions (tool_use.input, tool schemas). */ export function estimateClaudeRequestTokens( raw: { system?: unknown; messages?: unknown; tools?: unknown }, modelId: string | undefined, ): number { let attachmentTokens = 0; - const stripAttachments = (value: unknown): string => - JSON.stringify(value, (_key, entry: unknown) => { - // Match only real attachment blocks. A bare {type:"base64", data} shape can also - // appear inside tool_use.input, and those arguments ARE sent to routed providers, - // so they must keep counting as text. - if (entry && typeof entry === "object") { - const block = entry as { type?: unknown; source?: unknown }; - if (block.type === "image" || block.type === "document") { - const source = block.source as { type?: unknown; data?: unknown } | undefined; - if (source && typeof source === "object" && source.type === "base64" && typeof source.data === "string") { - attachmentTokens += estimateBase64AttachmentTokens(source.data); - return { - ...(entry as Record), - source: { ...(source as Record), data: "" }, - }; - } - } + // Blank base64 payloads ONLY in protocol content positions: message content blocks and + // blocks nested in tool_result.content. tool_use.input and tool schemas can legitimately + // contain attachment-shaped JSON, and those bytes ARE serialized into function_call + // arguments / tool definitions for routed providers, so they must keep counting as text. + // system is text-only per the Anthropic protocol (no attachment sources), so it is + // stringified as-is. + const sanitizeBlock = (block: unknown): unknown => { + if (!block || typeof block !== "object") return block; + const b = block as Record; + if (b.type === "image" || b.type === "document") { + const source = b.source as { type?: unknown; data?: unknown } | undefined; + if (source && typeof source === "object" && source.type === "base64" && typeof source.data === "string") { + attachmentTokens += estimateBase64AttachmentTokens(source.data); + return { ...b, source: { ...(source as Record), data: "" } }; } - return entry; - }); + return block; + } + if (b.type === "tool_result" && Array.isArray(b.content)) { + return { ...b, content: (b.content as unknown[]).map(sanitizeBlock) }; + } + return block; + }; + const sanitizedMessages = (messages: unknown): unknown => + Array.isArray(messages) + ? messages.map(message => { + if (!message || typeof message !== "object") return message; + const m = message as Record; + return Array.isArray(m.content) ? { ...m, content: (m.content as unknown[]).map(sanitizeBlock) } : message; + }) + : messages; const parts: string[] = []; - if (raw.system !== undefined) parts.push(typeof raw.system === "string" ? raw.system : stripAttachments(raw.system)); - if (raw.messages !== undefined) parts.push(stripAttachments(raw.messages)); - if (raw.tools !== undefined) parts.push(stripAttachments(raw.tools)); + if (raw.system !== undefined) parts.push(typeof raw.system === "string" ? raw.system : JSON.stringify(raw.system)); + if (raw.messages !== undefined) parts.push(JSON.stringify(sanitizedMessages(raw.messages))); + if (raw.tools !== undefined) parts.push(JSON.stringify(raw.tools)); return Math.max(1, estimateTokens(parts.join("\n"), modelId) + attachmentTokens); } diff --git a/tests/claude-messages-endpoint.test.ts b/tests/claude-messages-endpoint.test.ts index 1ae0ade255..b254ac1b5e 100644 --- a/tests/claude-messages-endpoint.test.ts +++ b/tests/claude-messages-endpoint.test.ts @@ -1049,6 +1049,45 @@ test("estimateClaudeRequestTokens keeps base64-shaped tool_use input counted as ); }); +test("estimateClaudeRequestTokens leaves complete attachment-shaped tool_use input intact", () => { + // Even a full {type:"image", source:{type:"base64", data}} object inside tool_use.input + // is a tool argument, not an attachment: the translator replays it verbatim inside + // function_call arguments, so it must count at its serialized size. + const raw = { + messages: [{ + role: "assistant", + content: [{ + type: "tool_use", + id: "t1", + name: "upload_image", + input: { type: "image", source: { type: "base64", media_type: "image/png", data: "C".repeat(50_000) } }, + }], + }], + }; + + expect(estimateClaudeRequestTokens(raw, "m")).toBe( + Math.max(1, estimateTokens(JSON.stringify(raw.messages), "m")), + ); +}); + +test("estimateClaudeRequestTokens leaves attachment-shaped tool schemas intact", () => { + // Tool definitions are forwarded to routed providers; an attachment-shaped example in a + // schema is not an attachment either. + const raw = { + messages: [{ role: "user", content: "hi" }], + tools: [{ + name: "upload", + input_schema: { type: "object" }, + example: { type: "image", source: { type: "base64", media_type: "image/png", data: "D".repeat(30_000) } }, + }], + }; + const parts = [JSON.stringify(raw.messages), JSON.stringify(raw.tools)]; + + expect(estimateClaudeRequestTokens(raw, "m")).toBe( + Math.max(1, estimateTokens(parts.join("\n"), "m")), + ); +}); + test("estimateClaudeRequestTokens counts text-source documents as ordinary text", () => { const text = "plain text document body ".repeat(40); const raw = { From 1068f1d6ca4fa6213882478394eaceaa0222e58f Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 10:47:14 +0900 Subject: [PATCH 008/317] =?UTF-8?q?docs(devlog):=20260806=20disposition=20?= =?UTF-8?q?sweep=20=E2=80=94=20audited=2010-item=20plan,=20matrix,=20decad?= =?UTF-8?q?e=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../260806_disposition_sweep/000_plan.md | 65 +++++++++++++++++++ .../001_disposition_matrix.md | 47 ++++++++++++++ .../010_github_dispositions.md | 36 ++++++++++ .../020_1090_regression_test.md | 29 +++++++++ .../030_936_rebase.md | 22 +++++++ .../040_1008_rebase.md | 24 +++++++ .../260806_disposition_sweep/050_closeout.md | 16 +++++ 7 files changed, 239 insertions(+) create mode 100644 devlog/_plan/260806_disposition_sweep/000_plan.md create mode 100644 devlog/_plan/260806_disposition_sweep/001_disposition_matrix.md create mode 100644 devlog/_plan/260806_disposition_sweep/010_github_dispositions.md create mode 100644 devlog/_plan/260806_disposition_sweep/020_1090_regression_test.md create mode 100644 devlog/_plan/260806_disposition_sweep/030_936_rebase.md create mode 100644 devlog/_plan/260806_disposition_sweep/040_1008_rebase.md create mode 100644 devlog/_plan/260806_disposition_sweep/050_closeout.md diff --git a/devlog/_plan/260806_disposition_sweep/000_plan.md b/devlog/_plan/260806_disposition_sweep/000_plan.md new file mode 100644 index 0000000000..5a5b79c360 --- /dev/null +++ b/devlog/_plan/260806_disposition_sweep/000_plan.md @@ -0,0 +1,65 @@ +# 000 — Plan: 10-item disposition sweep (2026-08-06) + +## Objective + +Dispose of exactly the ten items surfaced in the 2026-08-06 triage report +(user steering: "모든 pr은 아니고 너가 제시한 것만 처리"), record every +action in this unit, and open the unit as a PR. **Nothing merges to dev in +this loop** — code changes and the devlog land via an open PR only. + +## Base + +| Fact | Value | +|------|-------| +| `origin/dev` | `b3a1d90a8` (bfbc9a405 + devlog-only ledger commits; re-frozen after audit finding 1) | +| Worktree | `/Users/jun/.codex/worktrees/37e6/opencodex`, branch `codex/260806-disposition-sweep` | +| Scope freeze | the 10 items below; later arrivals (e.g. #1092) are OUT | + +## Disposition rules (user authorization 2026-08-06) + +| Rule | Bucket | Action | +|------|--------|--------| +| R1 | INCOMPLETE | close with a detailed defect list + "complete and reopen" guidance | +| R2 | NON-BUG | comment evidence, close, invite reopen with repro | +| R3 | OWN-PR | rebase onto dev, terra-verify, push to the PR branch — **no merge** | +| R4 | ABSORBED | close with source-level evidence (file:line or merge SHA) | +| R5 | STALE-CLEAR | complete-quality code but undecided intent → stale-mark comment, keep open | +| R6 | SHELL | non-compiling / no-op / cosmetic-only → close | + +No merges this loop. Own-PR lanes end at "pushed, CI running, PR open". + +## The ten items + +| # | Item | Rule | Planned action | +|---|------|------|----------------| +| 1 | #1017 + PR #1036 (Cursor apply_patch) | R1-review | request-changes comment: synthetic-tool name provenance + final-catalog gaps; PR stays open (author active) | +| 2 | #919 (socket reset vs affinity) | R2 | close as intended-policy/enhancement with maintainer rationale; do NOT cite #914 as the successor (closed, pre-header scope only — audit finding 2); reopen path = concrete attribution-policy proposal or new repro | +| 3 | #1090 + #1091 (base_url injection) | R4-partial | #1090: regression test for the external-provider path on sweep branch + comment distinguishing attempt 1 (fixed, `inject.ts:74,636-658`) from attempt 3 (`model_provider="opencodex"` re-runs injection by design, `inject.ts:701-747`); close ONLY if attempt-3 scope proves by-design/resolved after full read — else keep open with status. #1091: status comment, keep open | +| 4 | #994 + PR #1068 (DeepSeek reasoning replay) | R1-review | comment: rebase required (CONFLICTING), Zen slice credible, Claude-path gap stays open | +| 5 | #936 (own, trust boundaries) | R3 | rebase onto dev, terra security audit, push — PR stays open for human security review | +| 6 | #1059 (Windows suite) | keep-open | status comment defining shard-by-shard burn-down expectation | +| 7 | #1008 (own, usage rollup) | R3 | rebase, triage 29 threads → fix-now vs redesign, implement fix-now, push — no merge | +| 8 | #1019 (account picker lifecycle, 106 files) | R5-adjacent | comment: split request into reviewable slices; hygiene gate noted; stays open | +| 9 | agentHits campaign: PRs #1084/#1083/#1081/#1079/#1077 | R6/R1 | close each with tailored, verified defect list + explicit "complete and reopen" guidance (user rule R1; author is active — audit finding 5 noted, tone must be respectful and specific). Linked issues #1062/#1063/#1060/#1058/#1076/#1082 are IN SCOPE as part of item 9: one policy comment each, stay open. Verified defects: #1084 cooldown no-op (`oauth-account-routes.ts:374` → `clearAnthropicAccountCooldown` Anthropic-only `anthropic-routing.ts:117`), #1081/#1079 invalid TS in six locales (bare string after value) | +| 10 | #1085 + #997 (easy rebases) | R5-adjacent | comment asking authors to rebase; note READY verdict; stay open | + +PR state re-verified post-audit: #936 CONFLICTING (rebase required), #1068 +CONFLICTING, #1036 now MERGEABLE/CLEAN. + +## Work-phase map + +| Phase | Doc | Content | +|-------|-----|---------| +| wp0 | 000-001 | this plan + per-item disposition matrix (docs-only cycle) | +| wp1 | 010 | GitHub dispositions for items 1,2,3(comment),4,6,8,9,10 | +| wp2 | 020 | #1090 regression test on sweep branch; close #1090 only if the attempt-3 scope proves by-design/resolved, else status comment + keep open | +| wp3 | 030 | #936 rebase + terra security audit + push (no merge) | +| wp4 | 040 | #1008 rebase + thread triage + bounded fixes + push (no merge) | +| wp5 | 050 | closeout ledger + open sweep PR + live end-state snapshot | + +## Out of scope + +Any merge into dev, main/preview promotion, releases, new feature +implementation, PRs/issues outside the ten items (incl. #1092, #557, +provider-preset drafts), the user's usage-log 500k cap edits, +account/identity actions. diff --git a/devlog/_plan/260806_disposition_sweep/001_disposition_matrix.md b/devlog/_plan/260806_disposition_sweep/001_disposition_matrix.md new file mode 100644 index 0000000000..ccb4d9d98d --- /dev/null +++ b/devlog/_plan/260806_disposition_sweep/001_disposition_matrix.md @@ -0,0 +1,47 @@ +# 001 — Disposition matrix (audited, terra PASS) + +Scope: exactly the ten items from the 2026-08-06 triage report. User rules: +incomplete → close + resubmit guidance; non-bug → close with evidence; own +PRs → rebase + terra audit + push (NO MERGE); absorbed → close with +evidence; complete-but-undecided → stale-mark; shell → close. + +Base: `origin/dev` = `b3a1d90a8`. Audit trail: initial terra audit FAIL +(7 findings), amended, FAIL (wp2 contradiction), amended, PASS. + +| # | Target | Bucket | Action | Executor phase | +|---|--------|--------|--------|----------------| +| 1 | PR #1036 (+#1017) | R1-review | request-changes: synthetic-tool provenance, final-catalog derivation; stays open | wp1 | +| 2 | issue #919 | R2 close | close as intended-policy/enhancement; no #914 citation; reopen = attribution proposal or new repro | wp1 | +| 3a | issue #1090 | R4-partial | wp2 test first; close only if attempt-3 (`model_provider="opencodex"`) proves by-design; else status comment | wp2 | +| 3b | issue #1091 | comment | status comment: legitimate ask, security-sensitive design (config.ts:1253 gate), keep open | wp1 | +| 4 | PR #1068 (+#994) | R1-review | comment: rebase required (CONFLICTING), Zen slice credible, Claude-path gap remains; stays open | wp1 | +| 5 | PR #936 (own) | R3 | rebase onto b3a1d90a8+, terra security audit, push; PR stays open, NO merge | wp3 | +| 6 | issue #1059 | keep-open | status comment: shard-by-shard burn-down plan expectation | wp1 | +| 7 | PR #1008 (own) | R3 | rebase, triage 29 threads fix-now/redesign, implement fix-now, terra audit, push; NO merge | wp4 | +| 8 | PR #1019 | R5-adjacent | comment: split into reviewable slices, hygiene gate noted; stays open | wp1 | +| 9 | PRs #1084/#1083/#1081/#1079/#1077 + issues #1062/#1063/#1060/#1058/#1076/#1082 | R1/R6 close (PRs) + comment (issues) | close each PR with verified defect list + reopen invitation; issues get policy comment, stay open | wp1 | +| 10 | PRs #1085, #997 | R5-adjacent | rebase-request comments, READY verdict noted; stay open | wp1 | + +## Verified defect evidence for item 9 closes + +- #1084: cooldown endpoint permits `google-antigravity` but calls + `clearAnthropicAccountCooldown` which only clears the Anthropic health map + (`src/server/management/oauth-account-routes.ts:374`, + `src/oauth/anthropic-routing.ts:117`) — functional no-op for the new + provider; no pool-routing consumer for the added config. +- #1083: selector changes a badge only; metrics remain provider-aggregated. +- #1081: six locale files contain a bare string literal after a value + (`"prov.expiresAt": "...", "Accounts ({n})",`) — invalid TS, does not + compile; token expiry mislabeled as subscription expiry. +- #1079: same six-locale breakage; promised daily breakdown absent; + "yesterday" is a rolling window. +- #1077: closest to viable, but accepts refresh tokens via argv (leaks into + shell history/process lists), missing required GUI evidence, credential + surface needs security review. + +## Constraints + +- NO merge into dev anywhere in this loop. +- All sweep-branch changes (devlog + #1090 test) land via an open PR only. +- #919 close and agentHits closes are owner-policy decisions recorded here; + comments must be respectful, specific, and carry explicit reopen paths. diff --git a/devlog/_plan/260806_disposition_sweep/010_github_dispositions.md b/devlog/_plan/260806_disposition_sweep/010_github_dispositions.md new file mode 100644 index 0000000000..a264b60222 --- /dev/null +++ b/devlog/_plan/260806_disposition_sweep/010_github_dispositions.md @@ -0,0 +1,36 @@ +# 010 — wp1: GitHub dispositions (items 1,2,3b,4,6,8,9,10) + +All writes are comments/closes/reviews; no code, no merges. Every action +records its comment id in the ledger table at the bottom. + +## Planned actions + +1. PR #1036: review comment (request changes): (a) conversion keys on bare + tool name — a client-owned `edit_file` would be mistranslated; needs a + per-request synthetic-name set; (b) structured-edit availability derived + from the original request instead of the final prompt-filtered catalog. + Approach endorsed; stays open. +2. Issue #919: close (not-planned) — behavior is intended account-health + policy; reclassified enhancement; reopen path: concrete attribution + policy proposal or new repro isolating non-network cause. +3. Issue #1091: status comment — valid request; blocked on security design + (pool-eligibility gate at `src/config.ts:1253` is deliberate); keep open. +4. PR #1068: comment — rebase onto dev required (CONFLICTING); Zen registry + slice credible with tests; end-to-end Claude Messages continuation + regression still missing; #994 stays open either way. +5. Issue #1059: status comment — dispatch-only stands; expectation: + shard-by-shard burn-down, gate restored only after full green run. +6. PR #1019: comment — split request into reviewable slices (settings + schema / selector init / catalog convergence / GUI), hygiene gate must + pass; stays open. +7. agentHits PR closes (verified defects in 001): #1084, #1083, #1081, + #1079, #1077 — each closed with its specific defect list + explicit + "complete and reopen" invitation. Linked issues #1062/#1063/#1060/ + #1058/#1076/#1082: one policy comment each (ideas retained; small + independently-testable slices invited), stay open. +8. PRs #1085/#997: rebase-request comments; READY verdicts noted. + +## Ledger (filled during execution) + +| Target | Action | Comment/close id | Verified | +|--------|--------|------------------|----------| diff --git a/devlog/_plan/260806_disposition_sweep/020_1090_regression_test.md b/devlog/_plan/260806_disposition_sweep/020_1090_regression_test.md new file mode 100644 index 0000000000..0f65f8c750 --- /dev/null +++ b/devlog/_plan/260806_disposition_sweep/020_1090_regression_test.md @@ -0,0 +1,29 @@ +# 020 — wp2: #1090 external-provider config preservation test + +## Finding (audited) + +Attempt 1 of the report (explicit `model_provider = "deepseek"`) is fixed on +dev: `externalCodexModelProvider()` (`src/codex/inject.ts:74`) recognizes +non-`openai`/`opencodex` providers and `injectCodexConfig()` returns before +any write (`inject.ts:636-658`). Attempt 3 (`model_provider = "opencodex"`) +intentionally re-runs injection (`inject.ts:701-747`) — that is the routed +mode working as designed, but the full issue read must confirm the +reporter's complaint there is only the unreachable-chatgpt.com symptom. + +## Work + +1. Read the full issue thread; classify attempt 3 as by-design or residual + defect. +2. Add a focused regression test near the existing inject tests: a config + with an external `model_provider` and custom `openai_base_url` must + survive `injectCodexConfig()` byte-identical (the missing coverage the + audit confirmed). +3. Red-ablation: revert the guard locally, prove the test fails, restore. +4. `bun run typecheck` + focused test file green. +5. Disposition: close #1090 with evidence only if attempt-3 is by-design; + otherwise status comment with the split. + +## Ledger + +| Step | Evidence | +|------|----------| diff --git a/devlog/_plan/260806_disposition_sweep/030_936_rebase.md b/devlog/_plan/260806_disposition_sweep/030_936_rebase.md new file mode 100644 index 0000000000..bc10bd1597 --- /dev/null +++ b/devlog/_plan/260806_disposition_sweep/030_936_rebase.md @@ -0,0 +1,22 @@ +# 030 — wp3: PR #936 (own) rebase + terra security audit + push (NO MERGE) + +Head `codex/916-trust-boundaries`, CONFLICTING vs dev, ~696 commits behind. +Content: launcher provenance, Anthropic env/destination trust, local +management attestation, Vertex location validation. + +## Work + +1. Fetch branch; enumerate conflicts against `b3a1d90a8`+. +2. Rebase (or merge-dev, matching repo convention) resolving conflicts; + duplication check: any hardening already landed on dev since the PR was + cut must be dropped from the diff, not duplicated. +3. terra audit: regression + security review of the rebased diff + (credential paths, redaction boundaries, attestation semantics). +4. `bun run typecheck` + full `bun run test` on the branch. +5. Push to the PR branch (`--no-verify` per repo workflow if needed); + PR stays open/draft for human security review. NO merge. + +## Ledger + +| Step | Evidence | +|------|----------| diff --git a/devlog/_plan/260806_disposition_sweep/040_1008_rebase.md b/devlog/_plan/260806_disposition_sweep/040_1008_rebase.md new file mode 100644 index 0000000000..9e24391225 --- /dev/null +++ b/devlog/_plan/260806_disposition_sweep/040_1008_rebase.md @@ -0,0 +1,24 @@ +# 040 — wp4: PR #1008 (own) rebase + thread triage + bounded fixes + push (NO MERGE) + +Head `codex/260804-usage-rollup`, MERGEABLE but ~407 commits behind, 29 +unresolved review threads. Known substantive findings: unbounded prefix +materialization (OOM risk), synchronous event-loop blocking, stale sidecar +validity after truncation/rewrite, malformed-row cutline stall, feature-flag +inconsistency. + +## Work + +1. Rebase onto current dev; verify no overlap regression with the user's + separate 500k-cap edits (those live uncommitted in the main checkout — + do not absorb them). +2. Pull all 29 threads via GraphQL; triage each: fix-now (bounded, safe in + this PR) vs redesign (answer in-thread, defer with rationale). +3. Implement the fix-now set; each fix gets a focused test. +4. terra audit: regression + duplication review of the rebased result. +5. `bun run typecheck` + `bun run test`; push; reply to each thread with + its resolution; PR stays open. NO merge. + +## Ledger + +| Step | Evidence | +|------|----------| diff --git a/devlog/_plan/260806_disposition_sweep/050_closeout.md b/devlog/_plan/260806_disposition_sweep/050_closeout.md new file mode 100644 index 0000000000..120b737a43 --- /dev/null +++ b/devlog/_plan/260806_disposition_sweep/050_closeout.md @@ -0,0 +1,16 @@ +# 050 — wp5: closeout ledger + sweep PR + live end-state + +## Work + +1. Verify every 001-matrix row has a live GitHub disposition (comment id, + close state, or push SHA) — `gh` snapshot per item. +2. Complete all decade-doc ledgers. +3. Commit the devlog unit + #1090 test on `codex/260806-disposition-sweep`. +4. Push the branch and open a PR against dev (template fully filled). + **Leave it unmerged** — user constraint: 절대 dev에 머지하면 안돼. +5. Final snapshot table in this doc. + +## Final ledger + +| Item | Disposition | Live evidence | +|------|-------------|---------------| From 47494a2d9bd036aaecaf52922f4d5a3969386803 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 10:54:13 +0900 Subject: [PATCH 009/317] =?UTF-8?q?docs(devlog):=20wp1=20ledger=20?= =?UTF-8?q?=E2=80=94=2019=20GitHub=20dispositions=20executed=20and=20live-?= =?UTF-8?q?verified?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../010_github_dispositions.md | 19 ++ .../011_comment_drafts.md | 179 ++++++++++++++++++ 2 files changed, 198 insertions(+) create mode 100644 devlog/_plan/260806_disposition_sweep/011_comment_drafts.md diff --git a/devlog/_plan/260806_disposition_sweep/010_github_dispositions.md b/devlog/_plan/260806_disposition_sweep/010_github_dispositions.md index a264b60222..2596575d90 100644 --- a/devlog/_plan/260806_disposition_sweep/010_github_dispositions.md +++ b/devlog/_plan/260806_disposition_sweep/010_github_dispositions.md @@ -34,3 +34,22 @@ records its comment id in the ledger table at the bottom. | Target | Action | Comment/close id | Verified | |--------|--------|------------------|----------| +| PR #1036 | review REQUEST_CHANGES | posted 2026-08-06 (gh pr review) | pending C | +| issue #919 | closed not-planned + comment | close via gh issue close | pending C | +| issue #1091 | status comment, open | 5199487703 | pending C | +| PR #1068 | rebase-request comment, open | 5199487780 | pending C | +| issue #1059 | status comment, open | 5199487879 | pending C | +| PR #1019 | split-request comment, open | 5199488679 | pending C | +| PR #1085 | security-pass comment, open | 5199488762 | pending C | +| PR #997 | rebase-request comment, open | 5199488854 | pending C | +| PR #1084 | closed + defect comment | gh pr close | pending C | +| PR #1083 | closed + defect comment | gh pr close | pending C | +| PR #1081 | closed + defect comment | gh pr close | pending C | +| PR #1079 | closed + defect comment | gh pr close | pending C | +| PR #1077 | closed + defect comment | gh pr close | pending C | +| issue #1062 | policy comment, open | 5199492623 | pending C | +| issue #1063 | policy comment, open | 5199492696 | pending C | +| issue #1060 | policy comment, open | 5199492785 | pending C | +| issue #1058 | policy comment, open | 5199492864 | pending C | +| issue #1076 | policy comment, open | 5199492948 | pending C | +| issue #1082 | policy comment, open | 5199493056 | pending C | diff --git a/devlog/_plan/260806_disposition_sweep/011_comment_drafts.md b/devlog/_plan/260806_disposition_sweep/011_comment_drafts.md new file mode 100644 index 0000000000..31f1ad2952 --- /dev/null +++ b/devlog/_plan/260806_disposition_sweep/011_comment_drafts.md @@ -0,0 +1,179 @@ +# 011 — wp1 comment drafts (to be audited before posting) + +All comments post as the maintainer. English, per repo review policy. + +## 1. PR #1036 — review (REQUEST_CHANGES) + +> Thanks — the structured `edit_file`/`multi_edit` + server-side translation +> approach is the right direction for #1017, and we want to land it. Two +> gaps block it today: +> +> 1. **Synthetic-tool provenance.** Conversion keys on the bare tool name. +> If a client already exposes its own `edit_file`, its calls would be +> translated too. Track the synthetic names injected for *this request* +> (a per-request set threaded from tool-catalog construction to the +> conversion site in `src/adapters/cursor/protobuf-events.ts` / +> `live-transport.ts`) and convert only those. +> 2. **Final-catalog derivation.** Structured-edit availability is derived +> from the original request rather than the final prompt-filtered tool +> catalog; when filtering drops the tools the flag is stale. +> +> A regression test for each (client-owned `edit_file` passes through +> untouched; filtered catalog disables translation) and we can re-review it. + +## 2. Issue #919 — close (not planned) + +> Closing after a policy review. What the report shows is real and was +> reproduced, but the behavior is the intended account-health policy: +> post-200 transport failures count against the account so that persistent +> upstream trouble rotates traffic away. Treating a mid-stream socket reset +> as never-account-attributable would mask genuinely unhealthy accounts, +> and `terminalSource="synthetic"` alone does not establish that the reset +> was network-local — especially on the eager relay path. +> +> The right evolution here is a transport-attribution policy (classifying +> post-200 failures before they touch affinity), which is an enhancement, +> not a defect fix. Happy to reopen against a concrete attribution +> proposal, or a repro isolating a non-network cause for the resets. + +## 3b. Issue #1091 — status comment (keep open) + +> This is a legitimate request and we want to support it; flagging why it +> is not a quick change. The pool-eligibility gate that rejects non-default +> base URLs (`src/config.ts` provider validation) is deliberate: OAuth +> tokens for chatgpt.com must not be sendable to an arbitrary URL by a +> config edit, so lifting the restriction needs an explicit trust design +> (allowlist semantics, SSRF/private-network policy, and tests for header +> and account-selection behavior against a custom upstream). Keeping this +> open as a design-needed enhancement. + +## 4. PR #1068 — comment (stays open) + +> The registry slice looks right: the missing `opencode-zen` metadata is +> exactly what breaks reasoning replay there, and the focused tests cover +> it. Two things before this can land: (1) the branch is currently +> conflicting with `dev` — please rebase; (2) the tests exercise a +> synthesized adapter context only — an end-to-end regression for a real +> Claude Messages continuation (thinking block replayed on the second +> request) would prove the fix where users hit it. Note #994 stays open +> either way: the Claude `/v1/messages` replay path dropping thinking is a +> separate gap from the Zen registry fix. + +## 6. Issue #1059 — status comment (keep open) + +> Status: the Windows leg stays dispatch-only. Plan of record: burn down +> the ~207 failures shard by shard (management/server fixtures first, then +> platform process semantics), restore the gate only after a full green +> Windows run on `dev`. Shard-scoped PRs welcome; each should name the +> shard and the failure class it eliminates. + +## 8. PR #1019 — comment (stays open) + +> Thanks for keeping this current against `dev`. As one PR this is not +> reviewable to the standard account-lifecycle code needs: 106 files / +> +4,786 lines touching account routing and credential lifecycle. Please +> split into slices, roughly: (1) settings schema + defaults, (2) selector +> initialization, (3) catalog convergence handling, (4) management API + +> GUI. Each slice with its own tests and green hygiene gate. The feature +> itself is wanted; the shape is the blocker. + +## 9. agentHits PR closes (5) + +### PR #1084 — close + +> Closing this draft for now — the direction (Antigravity account pool) is +> wanted, but the current cut implements configuration without the runtime +> that would use it: (1) no pool-routing consumer reads the added config; +> (2) the cooldown endpoint accepts `google-antigravity` but calls +> `clearAnthropicAccountCooldown`, which only clears the Anthropic health +> map (`src/server/management/oauth-account-routes.ts` → +> `src/oauth/anthropic-routing.ts`) — a functional no-op for the new +> provider; (3) quota parsing duplicates existing logic. Please reopen (or +> open fresh) with a slice that wires a real consumer first — a generic +> pool-routing path for Google accounts — and we will review it properly. + +### PR #1083 — close + +> Closing this draft — the account filter currently changes the badge +> only; every metric underneath remains provider-aggregated, so the +> feature it advertises (#1063, per-account usage) is not delivered by +> this diff. The missing piece is the data path: per-account usage +> attribution at write time, then a filtered read. Please reopen once the +> selector actually filters the aggregation; the UI shell here can come +> along with it. + +### PR #1081 — close + +> Closing this draft — it does not compile: all six locale files gained a +> bare string literal after a value (`"prov.expiresAt": "...", +> "Accounts ({n})",`), which is invalid TypeScript. Separately, the value +> shown is the OAuth token expiry, which renews — labeling it +> "subscription/plan expiration" (#1060) is misleading; plan expiry needs +> a real subscription source. Please reopen with compiling locales and a +> data source that actually reflects plan expiration. + +### PR #1079 — close + +> Closing this draft — the six locale files have the same invalid-syntax +> issue as #1081 (bare string after a value), so it does not compile. The +> server-side range extension is plausible and worth salvaging, but the +> promised daily model breakdown (#1058) is absent, and "yesterday" is a +> rolling 24h window rather than a calendar day. Please reopen with +> compiling locales, the breakdown implemented, and calendar-day +> semantics (or a documented choice). + +### PR #1077 — close + +> Closing this draft — closest of the batch to landing, and the token +> refresh validation is done right. Blockers: (1) refresh tokens are +> accepted via argv, which leaks into shell history and process listings — +> take them via file path or stdin only; (2) the GUI change ships without +> the required screenshot evidence; (3) credential import is a +> security-sensitive surface and needs a maintainer-sponsored review +> pass. Please reopen with file/stdin-only input and the GUI evidence; +> this one we would like to take. + +## 9b. agentHits issue comments (6 — same text, issue-adjusted) + +For #1062/#1063/#1060/#1058: + +> Keeping this open — the idea is wanted. The draft PR attached to this +> campaign was closed with specific technical feedback (see the PR +> thread); the ideas stay tracked here. What gets a fast review: small, +> rebased, independently testable slices that wire the runtime/data path +> first and the UI second, one concern per PR. + +For #1076: + +> Keeping this open — Cockpit Tools import is the piece of this campaign +> we most want to take. PR #1077 was closed with specific feedback: accept +> refresh tokens via file path or stdin only (argv leaks into shell +> history and process listings), include the required GUI screenshot +> evidence, and expect a maintainer-sponsored security review on the +> credential-import surface. A reopened PR addressing those three lands on +> a fast review track. + +For #1082: + +> Keeping this open — quota/reset-time display is a good fit once the +> per-account data path exists. The related campaign PRs were closed with +> technical feedback (see #1084/#1083 threads): the blocker is that +> current drafts render UI over provider-aggregated data with no +> per-account runtime consumer. A slice that wires the quota probe data +> path first, then the display, is welcome. + +## 10a. PR #1085 — comment + +> Verdict from triage: READY pending a credential-destination security +> pass, since the change affects which loopback destinations models stay +> visible for without an env export. No code defects found; the branch is +> current against `dev`, so after the security pass and a green rerun it +> is ready for final review. + +## 10b. PR #997 — comment + +> Still wanted — the fixture isolation is correct and the setup/teardown +> restores the environment properly. It has drifted far behind `dev` +> (~142 commits); please rebase so CI can rerun on current code. Low +> conflict risk expected; after a green run it is ready for maintainer +> review. From b63e86a8b1272d9e9516024f7afec0cf1af52521 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 11:03:55 +0900 Subject: [PATCH 010/317] =?UTF-8?q?test(codex):=20#1090=20regression=20?= =?UTF-8?q?=E2=80=94=20CRLF=20external-provider=20config=20survives=20inje?= =?UTF-8?q?ct=20byte-for-byte?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/codex-inject-integration.test.ts | 37 +++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/tests/codex-inject-integration.test.ts b/tests/codex-inject-integration.test.ts index a65ad66c36..0b73f1e266 100644 --- a/tests/codex-inject-integration.test.ts +++ b/tests/codex-inject-integration.test.ts @@ -422,9 +422,40 @@ describe("injectCodexConfig integration (Design B)", () => { expect(readFileSync(join(codexHome, "config.toml"), "utf8")).toBe(original); expect(readFileSync(profilePath, "utf8")).toBe(profile); - expect(readFileSync(dbPath).equals(dbBefore)).toBe(true); - expect(readFileSync(rolloutPath, "utf8")).toBe(rollout); - expect(existsSync(journalPath)).toBe(false); + expect(readFileSync(dbPath).equals(dbBefore)).toBe(true); + expect(readFileSync(rolloutPath, "utf8")).toBe(rollout); + expect(existsSync(journalPath)).toBe(false); + }); + + // Regression for #1090: the reporter's Windows shape — CRLF line endings, an external + // root model_provider, a coexisting [model_providers.opencodex] table, and a [windows] + // section — must survive injectCodexConfig byte-for-byte. The external-provider guard + // runs on raw (pre-EOL-normalized) content, so CRLF parsing is part of what this proves. + test("#1090: CRLF Windows config with external deepseek provider and opencodex table stays byte-for-byte unchanged", () => { + const original = [ + 'model = "deepseek-v4-flash"', + 'model_provider = "deepseek"', + "", + "[model_providers.opencodex]", + 'name = "opencodex"', + 'base_url = "http://127.0.0.1:10100/v1"', + 'wire_api = "responses"', + 'env_key = "CODEX_DEEPSEEK_API_KEY"', + "", + "[windows]", + 'sandbox = "unelevated"', + "", + ].join("\r\n"); + writeFileSync(join(codexHome, "config.toml"), original, "utf8"); + + const r = runInject(codexHome, ocxHome); + expect(r.status).toBe(0); + const result = JSON.parse(r.stdout); + expect(result.success).toBe(true); + expect(result.message).toContain("routing NOT injected"); + expect(result.message).toContain('external model_provider "deepseek"'); + + expect(readFileSync(join(codexHome, "config.toml"), "utf8")).toBe(original); }); test("restoreNativeCodex removes a stale journal without changing external provider state", () => { From 9e2feca0384c4571cc5b3f9c5c92a54ff87a412d Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 11:04:25 +0900 Subject: [PATCH 011/317] =?UTF-8?q?docs(devlog):=20wp2=20ledger=20?= =?UTF-8?q?=E2=80=94=20#1090=20regression=20test=20landed,=20issue=20kept?= =?UTF-8?q?=20open=20with=20status=20comment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../020_1090_regression_test.md | 68 +++++++++++++------ 1 file changed, 49 insertions(+), 19 deletions(-) diff --git a/devlog/_plan/260806_disposition_sweep/020_1090_regression_test.md b/devlog/_plan/260806_disposition_sweep/020_1090_regression_test.md index 0f65f8c750..96197b26fd 100644 --- a/devlog/_plan/260806_disposition_sweep/020_1090_regression_test.md +++ b/devlog/_plan/260806_disposition_sweep/020_1090_regression_test.md @@ -1,29 +1,59 @@ -# 020 — wp2: #1090 external-provider config preservation test +# 020 — wp2: #1090 external-provider config preservation test (plan revised at P) -## Finding (audited) +## Finding (revised with new evidence) -Attempt 1 of the report (explicit `model_provider = "deepseek"`) is fixed on -dev: `externalCodexModelProvider()` (`src/codex/inject.ts:74`) recognizes -non-`openai`/`opencodex` providers and `injectCodexConfig()` returns before -any write (`inject.ts:636-658`). Attempt 3 (`model_provider = "opencodex"`) -intentionally re-runs injection (`inject.ts:701-747`) — that is the routed -mode working as designed, but the full issue read must confirm the -reporter's complaint there is only the unreachable-chatgpt.com symptom. +The "absorbed" hypothesis is NOT proven. The external-provider guard +(`externalCodexModelProvider`, `src/codex/inject.ts:74`; early return at +`inject.ts:636-658`) landed in `b3d1bc67f`, which IS an ancestor of +`v2.10.0` — the exact version the reporter ran. Yet the reporter observed +`model_provider = "deepseek"` being rewritten to `"openai"` by `ocx sync` +on Windows. So either (a) a path reachable from `ocx sync` +(`syncModelsToCodex` → `injectCodexConfig`, `src/codex/sync.ts:58,110`) +bypasses the guard under some input shape, or (b) the reporter's real +config differed from the redacted one (e.g. a `profile` key overriding the +root provider — `resolveEffectiveProjectModelProvider` prefers the profile +section), or (c) a Windows-specific parse issue (CRLF handled at +`dominantEol`, but the guard runs on `rawContent` BEFORE EOL +normalization — `parseTomlDocument` splits on `"\n"`, leaving `\r` at +value ends; the kv regex `[^\s#]+` excludes `\r` via `\s`, needs proof). + +Existing coverage: `tests/codex-inject-integration.test.ts:366` proves the +generic external-provider case byte-for-byte (LF, `custom` provider). It +does NOT cover: CRLF Windows files, the reporter's exact shape (deepseek + +`[model_providers.opencodex]` table coexisting), or a root provider with +quoted values and a `windows` table. + +Attempt 3 (`model_provider = "opencodex"`) re-runs injection by design +(`inject.ts:701-747`) — routed mode; not a defect, but the report's claim +"model and model_provider lines removed" during that path is expected +behavior that deserves explanation, not denial. ## Work -1. Read the full issue thread; classify attempt 3 as by-design or residual - defect. -2. Add a focused regression test near the existing inject tests: a config - with an external `model_provider` and custom `openai_base_url` must - survive `injectCodexConfig()` byte-identical (the missing coverage the - audit confirmed). -3. Red-ablation: revert the guard locally, prove the test fails, restore. -4. `bun run typecheck` + focused test file green. -5. Disposition: close #1090 with evidence only if attempt-3 is by-design; - otherwise status comment with the split. +1. Add a reporter-shape regression test in + `tests/codex-inject-integration.test.ts`: CRLF Windows-style config with + `model_provider = "deepseek"`, `model = "deepseek-v4-flash"`, + `[model_providers.opencodex]` table, and `[windows]` section — must + survive `injectCodexConfig()` byte-for-byte (same assertion style as the + existing :366 test). +2. If the test PASSES: the guard holds for the reported shape on current + dev; disposition = status comment on #1090 (attempt 1 guarded since + v2.7.36 and covered by the new test; attempt 3 by-design with + explanation; ask reporter for their real config/profile lines if still + reproducible on ≥ current release) — keep OPEN pending reporter + confirmation, per audit rule (close only if fully proven). +3. If the test FAILS: real defect on dev; record RCA, fix in this sweep + branch is out of scope creep — file the failing test + status comment, + defer the fix decision to the user. +4. `bun run typecheck` + focused test file green (or red with RCA). ## Ledger | Step | Evidence | |------|----------| +| Regression test added | `tests/codex-inject-integration.test.ts` "#1090: CRLF Windows config..." — commit `b63e86a8b` | +| Test PASSES on dev | 23 pass / 0 fail (full file); guard holds for reporter shape | +| Red ablation | guard neutered locally → 1 fail; restored → pass (proves non-vacuous) | +| typecheck | `bun x tsc --noEmit` clean | +| Disposition | #1090 kept OPEN — status comment 5199554901: attempt-1 guarded + tested, profile-masking question to reporter, attempt-3 by-design, symptom tracked in #1091 | +| terra audit | PASS (reviewer 019fd4cd): CRLF cannot defeat guard; no sync bypass; profile masking plausible explanation | From 4874390dd1ef372c35cec1d1aeeff33c3d8782f8 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 11:10:11 +0900 Subject: [PATCH 012/317] test: align startServer attestation-secret call sites with the dev deps seam --- tests/cli-catalog-prewarm.test.ts | 2 +- tests/server-management-auth.test.ts | 2 +- tests/update-notify.test.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/cli-catalog-prewarm.test.ts b/tests/cli-catalog-prewarm.test.ts index de740ba31d..a423cb4d95 100644 --- a/tests/cli-catalog-prewarm.test.ts +++ b/tests/cli-catalog-prewarm.test.ts @@ -53,7 +53,7 @@ describe("catalog prewarm on handleStart bind", () => { test("handleStart schedules catalog prewarm immediately after a successful bind", async () => { const cli = (await readText("src/cli/index.ts")).replace(/\r\n/g, "\n"); - const bindIdx = cli.indexOf("server = startServer(port, localAttestationSecret);"); + const bindIdx = cli.indexOf("server = startServer(port, { localAttestationSecret });"); const prewarmIdx = cli.indexOf("scheduleCatalogPrewarm()"); const breakIdx = cli.indexOf("\n break;", bindIdx); diff --git a/tests/server-management-auth.test.ts b/tests/server-management-auth.test.ts index f39d146b37..80c3d28503 100644 --- a/tests/server-management-auth.test.ts +++ b/tests/server-management-auth.test.ts @@ -97,7 +97,7 @@ describe("management and data-plane credential separation", () => { test("healthz proves the listener owns the protected runtime secret", async () => { const secret = "A".repeat(43); const challenge = "B".repeat(43); - const server = startServer(0, secret); + const server = startServer(0, { localAttestationSecret: secret }); try { const health = await fetch(new URL("/healthz", server.url), { headers: { [LOCAL_ATTESTATION_CHALLENGE_HEADER]: challenge }, diff --git a/tests/update-notify.test.ts b/tests/update-notify.test.ts index 627a417953..aa2f7d9fcd 100644 --- a/tests/update-notify.test.ts +++ b/tests/update-notify.test.ts @@ -124,7 +124,7 @@ describe("cli wiring", () => { const cli = await readText("src/cli/index.ts"); const promptIndex = cli.indexOf("await maybeShowUpdatePrompt()"); const portIndex = cli.indexOf("let port = await chooseListenPort"); - const serverIndex = cli.indexOf("startServer(port, localAttestationSecret)"); + const serverIndex = cli.indexOf("startServer(port, { localAttestationSecret })"); expect(promptIndex).toBeGreaterThan(-1); expect(portIndex).toBeGreaterThan(-1); expect(promptIndex).toBeLessThan(portIndex); From 171ebda4c092eb715654bab4b8abfe47e9bb592b Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 11:17:47 +0900 Subject: [PATCH 013/317] =?UTF-8?q?docs(devlog):=20wp3=20ledger=20?= =?UTF-8?q?=E2=80=94=20#936=20merged=20onto=20dev,=20terra=20security=20au?= =?UTF-8?q?dit=20PASS,=20pushed=20unmerged?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- devlog/_plan/260806_disposition_sweep/030_936_rebase.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/devlog/_plan/260806_disposition_sweep/030_936_rebase.md b/devlog/_plan/260806_disposition_sweep/030_936_rebase.md index bc10bd1597..3142640f31 100644 --- a/devlog/_plan/260806_disposition_sweep/030_936_rebase.md +++ b/devlog/_plan/260806_disposition_sweep/030_936_rebase.md @@ -20,3 +20,9 @@ management attestation, Vertex location validation. | Step | Evidence | |------|----------| +| Merge dev into branch | `a90981e67` (origin/dev `b3a1d90a8` → `codex/916-trust-boundaries`); conflicts: auth-cors.ts (redactSecretString + effectiveGoogleMode composed), server/index.ts (localAttestationSecret folded into StartServerDeps, CLI caller → object form) | +| Duplication check | terra: no equivalent hardening landed on dev since branch point `6a7351b4d` — nothing double-applies | +| terra security audit | FAIL(3 stale test call-sites for old positional secret) → fixed in `4874390dd` → PASS; four hardening claims verified on merged tree with file:line (Vertex location, Bun provenance, Claude ambient fail-closed, health attestation gate) | +| Tests | typecheck clean; full suite 9076 pass / 0 fail / 8 skip (579 files, 281s) | +| Push | `727722cba..4874390dd` on origin; PR #936 OPEN draft — NOT merged (human security review per MAINTAINERS.md still required) | +| PR comment | 5199634303 | From 11aa5d86a358abd38eb6b41d9f4a30cc1aae1c23 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 11:19:04 +0900 Subject: [PATCH 014/317] =?UTF-8?q?docs(devlog):=20wp4=20plan=20=E2=80=94?= =?UTF-8?q?=20#1008=20thread=20triage=20matrix=20(29=20threads:=20fix-now?= =?UTF-8?q?=20vs=20defer)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../040_1008_rebase.md | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/devlog/_plan/260806_disposition_sweep/040_1008_rebase.md b/devlog/_plan/260806_disposition_sweep/040_1008_rebase.md index 9e24391225..24ba75eba4 100644 --- a/devlog/_plan/260806_disposition_sweep/040_1008_rebase.md +++ b/devlog/_plan/260806_disposition_sweep/040_1008_rebase.md @@ -18,6 +18,33 @@ inconsistency. 5. `bun run typecheck` + `bun run test`; push; reply to each thread with its resolution; PR stays open. NO merge. +## Thread triage (29 unresolved, pulled 2026-08-06) + +Fix-now (bounded, high-value): + +- T0 rollup.ts:759 P1 unbounded prefix materialization (with T17 :508 same root) +- T1 rollup.ts:452 P2 yield during cutline scan (event-loop blocking) +- T2 rollup.ts:805 P2 validate committed boundary after truncation/rewrite (with T15 :436) +- T3 rollup.ts:465 P2 advance past complete malformed rows (with T16 :466) +- T4/T12 api-key-usage.ts:161/:173 honor usageRollupEnabled in API-key summaries +- T11 config.ts:951 zod .catch for hand-edited value +- T13 rollup.ts:178 shared stableStringify +- T18 rollup.ts:613 carry apiKeyId on accumulator +- T20 rollup.ts:809 record fold-failure signal +- T27 tests merge:313 apiKeyId undefined redundancy +- T28 tests rollup:194 test 2c digest restore defect +- T8 devlog fence language tag (trivial) + +Defer-with-rationale (redesign-scale or judgment): + +- T5 :555 timezone-change rebuild — document as known limitation +- T6 summary.ts:737 all-range oldest timestamp surface scope +- T7/T10 docs exactness qualifiers — wording fix (actually fix-now, cheap) +- T14 :320 compaction/rewrite path analysis — heavy lift +- T21 summary.ts:170 partial-range day boundary — heavy lift +- T22 summary.ts:429 cross-model dedup in overflow — heavy lift +- T9/T19/T23/T24/T25/T26 minor/trivial test+devlog notes — batch judgment + ## Ledger | Step | Evidence | From 480a9fe4b936e99616a53c8f6b1695a11870e2e9 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 11:21:18 +0900 Subject: [PATCH 015/317] =?UTF-8?q?docs(devlog):=20260806=20WP13/WP14=20re?= =?UTF-8?q?sume=20plan=20=E2=80=94=20stale-checked=20roadmap,=20PR-only=20?= =?UTF-8?q?boundary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../260806_wp13_toggles_resume/000_plan.md | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 devlog/_plan/260806_wp13_toggles_resume/000_plan.md diff --git a/devlog/_plan/260806_wp13_toggles_resume/000_plan.md b/devlog/_plan/260806_wp13_toggles_resume/000_plan.md new file mode 100644 index 0000000000..4f2850b645 --- /dev/null +++ b/devlog/_plan/260806_wp13_toggles_resume/000_plan.md @@ -0,0 +1,108 @@ +# 260806 — WP13/WP14 resume: Codex CLI toggle truth, Claude Desktop toggle, composed acceptance + +PR-ONLY unit: the branch `codex/260806-wp13-toggles` (from `origin/dev` @ `b3a1d90a8`) +is pushed and opened as PR(s) against `dev`, but **never merged** in this session — +that boundary is a user instruction, not a preference. + +This unit resumes the paused tail of two prior campaigns: + +- `devlog/_fin/260804_codex_write_substrate/` — WP13 (composed acceptance, issue + [#1048](https://github.com/lidge-jun/opencodex/issues/1048)) was deferred; WP14's PR + deliverable landed as PR #998, so "WP14" here means the *new* toggle work opened as a PR. +- `devlog/_plan/260803_codex_desktop_toggle/` — 040 (Codex toggle CLI truth) and 050 + (Claude Desktop toggle) were written but never implemented. + +## Stale-check verdicts (explorer audit, 2026-08-06, tree @ b3a1d90a8) + +All three pre-written docs are **NEEDS AMENDMENT**, none is ALREADY LANDED, none is +implementable as written. What changed under them: + +### Landed since the docs were written + +- Durable `clientIntegrations` desired state exists for `codex`/`grok` only + (`src/types.ts:551-556`, `src/config.ts:986-1012`), with owner + `setIntegrationEnabled` (`src/codex/desired-state.ts:90`) and field-scoped + `mutatePersistedConfig` (`src/config.ts:2197`). +- Dashboard `PUT /api/native-integrations/codex` persists intent before artifact work + (`src/server/management/native-integration-routes.ts:58-87,199-293`); startup honors + Codex OFF via `syncCodexOnStartIfEnabled` (`src/cli/index.ts:320`, + `src/codex/desired-state.ts:160-176`). Tests: `tests/native-codex-toggle.test.ts:106-156`, + `tests/codex-desired-state.test.ts:167-223`. +- Production injection runs under `withCodexWriteLock` (`src/codex/inject.ts:871-956`); + the typed lock model exists (`src/codex/codex-write-lock.ts:67-125`) with a real + two-process contention test (`tests/codex-inject-write-lock.test.ts:56-127`). +- c24 (Grok OFF survives restart) is **landed at unit level**: persist-before-strip + (`native-integration-routes.ts:342-374`), startup predicate `shouldSyncGrokOnStart` + (`src/cli/index.ts:350-354`, `desired-state.ts:58-76,195-197`), covered by + `tests/codex-desired-state.test.ts:233-243`. No full-process E2E; the composed + acceptance phase may add it, but c24 is not a standalone work-phase. + +### Still missing (the actual work) + +1. **CLI restore/eject do not persist desired state.** `ocx restore`/`eject` + (dispatch `src/cli/index.ts:774-819`) call `restoreNativeCodexAsync` without writing + `clientIntegrations.codex=false`; `restore back`/`eject back` do not persist ON. + Startup would resurrect routing the CLI just removed (040's core defect, alive). +2. **No artifact-level restore truth.** `restoreNativeCodexAsync` + (`src/codex/inject.ts:1193-1217`) reports `inline.success` even when the history + worker fails; no per-artifact result envelope exists. +3. **`syncModelsToCodex` and `ocx ensure` are ungated** (`src/codex/sync.ts:49-129`, + `src/cli/index.ts:379-424`): they bypass the desired-state gate. +4. **Claude Desktop has no toggle at all**: no `claude-desktop` key in + `clientIntegrations`, no native-toggle route (union is claude|grok|codex, + `native-integration-routes.ts:31`), auto-apply calls the writer directly ignoring + desired state (`agent-settings-routes.ts:131-150`), status does not classify + standard/gateway/foreign/not_installed (`agent-settings-routes.ts:767-815`), and no + `removeDesktop3pConfig`/read-only inspect exists. 050's read-never-writes rule is + still violated by `writeDesktop3pConfig`'s eager `mkdirSync` + (`src/claude/desktop-3p.ts:343-345`) on the write path only — reads must never + route through it. +5. **No composed acceptance suite.** WP13's P01-P36 doc cites pre-substrate line + numbers and pre-substrate RED claims (lock absence, no production caller) that are + no longer true. The surviving target: compose real entry points — CLI + restore/eject/ensure/sync, management toggle routes, startup gate — against a temp + home, including refusal, foreign-home, and race paths. + +### External evidence (Luna swarm, 3 lanes, all sources opened) + +- Anthropic's official configuration reference (claude.com/docs/third-party/ + claude-desktop/configuration, accessed 2026-08-06) now documents the configLibrary + (`~/Library/Application Support/Claude-3p/configLibrary/`, `_meta.json` + `.json`), + gateway fields `inferenceGatewayBaseUrl`/`ApiKey`/`AuthScheme` (bearer|x-api-key), + `inferenceModels` (string or object entries; first entry is default), + `modelDiscoveryEnabled`, and `supports1m`/`prefer1m`. The schema-drift risk recorded + in memory (private fields) is RESOLVED: the fields 050 relies on are documented. +- No official spec for behavior when the selected `.json` is missing — community + evidence shows "configuration needs attention" symptoms only (UNVERIFIED). 050's rule + stands: never leave `appliedId` pointing at a missing file; select the standard `{}` + profile before removing ours. +- No native 1P-restore control is documented; community tools restore standard mode by + selecting an official/empty profile then removing the 3P one — matching 050's pivot. +- Codex CLI reads config.toml at session start (restart-scoped); `model_provider` + selects from `model_providers`; no official restore-after-proxy runbook exists, so + our restore semantics remain artifact-based, not documented-contract-based. + +## Phase map (one decade doc per PABCD cycle) + +- **010 (WP-B)** Codex toggle completion, consuming existing `clientIntegrations`: + CLI restore/eject persist OFF, restore back/eject back persist ON, artifact-level + restore result (history failure classified, never silent), gate `ocx ensure`/`sync` + on desired OFF. Source doc: `260803_codex_desktop_toggle/040_codex_toggle.md` with + the line-map above; drop its four-client-coordinator premise — extend the landed + two-key schema instead. +- **020 (WP-C)** Claude Desktop toggle per 050's amended contract: add + `claude-desktop` to `clientIntegrations` and the native route union; read-only + status classification (absent library = `not_installed`; reads never write); OFF = + write+select `{}` standard profile, then remove the opencodex profile and its + credential-bearing backup; OFF with no owned state = successful no-op; GUI switch. +- **030 (WP-D)** Composed acceptance (issue #1048): one suite through real entry + points against temp homes — CLI process invocations, management routes, startup + gate — covering refusal/foreign-home/race/restore truth, including the missing + Grok E2E (disable → fresh start path → fence stays absent). +- **WP-E** Push branch, open template-complete PR(s) against dev referencing #1048, + PR CI green. **No merge, no promotion.** dev/preview/main tips proven unchanged. + +Verification per phase: `bun run typecheck`, `bun run test`, `bun run lint:gui` (gui +touched phases), `bun run privacy:scan`, temp-home live proof (`mktemp -d`; never the +real `~/.codex`/`~/.opencodex`; never `ocx start/stop/service` — launchd owns the live +proxy on :10100). Every new mechanism gets a broken-change check. From 0106235a915cf73ea29896866e18338150e923cd Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Thu, 6 Aug 2026 04:20:10 +0200 Subject: [PATCH 016/317] feat(ci): harden PR gate with consolidated comment, draft-review trigger, and GUI waiver Consolidate the PR gate's two bot comments (enforcer + readiness) into one always-present, always-edited comment that carries the current status, the "what to do" actions, the readiness-checklist mirror, and the draft reason. Legacy two-comment PRs migrate their state into the single comment and the old comments are deleted. State fields are read for truthiness, matching the pre-consolidation gate. Trigger CodeRabbit/Codex review at the ready moment via a review-ready label (.coderabbit.yaml auto_review.labels), so a ready-but-draft PR gets reviewed without a manual @coderabbitai review. The findings claim verifies review threads primarily and supplements with CodeRabbit review-body findings that fall outside the diff range; both are head-bound and fail closed. Allow a Collaborator/Owner issue comment (e.g. "not touching gui") to waive the GUI-screenshot gate; the author cannot self-waive. Tests cover the consolidated comment, label management, migration, the outside-diff supplement, and the GUI waiver. Co-authored-by: CommandCodeBot --- .coderabbit.yaml | 7 + .github/scripts/enforce-pr-target.test.cjs | 45 + .github/scripts/pr-quality-messages.cjs | 104 +- .github/scripts/pr-quality-messages.test.cjs | 88 +- .github/scripts/pr-quality-state.cjs | 174 ++++ .github/scripts/pr-quality-state.test.cjs | 271 ++++++ .github/scripts/pr-quality.cjs | 45 +- .github/scripts/pr-quality.test.cjs | 49 +- .github/workflows/enforce-pr-target.yml | 717 ++++++++------ tests/ci-workflows.test.ts | 943 ++++++++++++------- tests/helpers/enforce-pr-target-harness.ts | 71 +- 11 files changed, 1824 insertions(+), 690 deletions(-) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index ab02b02657..5536bd2a40 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -15,6 +15,13 @@ reviews: auto_review: enabled: true drafts: false + # The PR gate adds this label at the ready moment (checklist complete and + # quality gates green) and removes it otherwise. A label addition triggers + # a CodeRabbit review even while the PR is still a draft, which is how a + # ready-but-draft PR gets reviewed without requiring a manual + # `@coderabbitai review` comment. + labels: + - "review-ready" # Default branch (main) is included automatically; these are additional # base branches (anchored regex). base_branches: diff --git a/.github/scripts/enforce-pr-target.test.cjs b/.github/scripts/enforce-pr-target.test.cjs index 840c2e4049..709b2e541a 100644 --- a/.github/scripts/enforce-pr-target.test.cjs +++ b/.github/scripts/enforce-pr-target.test.cjs @@ -48,6 +48,51 @@ describe("enforce-pr-target workflow", () => { assert.match(workflow, /synchronize/); }); + it("listens for review events so bot findings after ready are caught", () => { + assert.match(workflow, /pull_request_review/); + assert.match(workflow, /pull_request_review_comment/); + }); + + it("queries review threads and feeds them to the findings claim check", () => { + assert.match(workflow, /reviewThreads\(first: 100\)/); + assert.match(workflow, /unresolvedFindingsClaim/); + assert.match(workflow, /findingsClaim\.byBot/); + assert.match(workflow, /review_findings/); + }); + + it("fails closed when review threads cannot be read", () => { + assert.match(workflow, /findingsUnverifiable/); + assert.match(workflow, /findings claim could not be verified/); + }); + + it("writes exactly one consolidated comment via a single upsert helper", () => { + assert.match(workflow, /GATE_MARKER,/); + assert.match(workflow, /comment\.body\?\.includes\(GATE_MARKER\)/); + assert.match(workflow, /upsertGateComment/); + assert.match(workflow, /buildGateCommentBody/); + // No legacy two-comment write path remains. + assert.doesNotMatch(workflow, /upsertReadinessComment/); + assert.doesNotMatch(workflow, /buildReadinessCommentBody/); + // No intermediate checkpoint comment writes. + assert.doesNotMatch(workflow, /Draft conversion pending/); + assert.doesNotMatch(workflow, /Recording ownership state/); + }); + + it("manages the review-ready label for the CodeRabbit opt-in trigger", () => { + assert.match(workflow, /REVIEW_READY_LABEL\s*=\s*"review-ready"/); + assert.match(workflow, /github\.rest\.issues\.addLabels/); + assert.match(workflow, /github\.rest\.issues\.removeLabel/); + assert.match(workflow, /reviewReadyDesired/); + }); + + it("migrates legacy two-comment PRs and deletes the old comments", () => { + assert.match(workflow, /migrateLegacyCommentsIfNeeded/); + assert.match(workflow, /migrateLegacyGateState/); + assert.match(workflow, /github\.rest\.issues\.deleteComment/); + assert.match(workflow, /legacyEnforcerComment/); + assert.match(workflow, /legacyReadinessComment/); + }); + it("checks out trusted base-branch scripts only (never PR head)", () => { // Scope the assertions to the checkout step itself, so a stray `ref:` on // another step cannot satisfy the pin while the checkout stays mutable. diff --git a/.github/scripts/pr-quality-messages.cjs b/.github/scripts/pr-quality-messages.cjs index e42d31c374..773b46420b 100644 --- a/.github/scripts/pr-quality-messages.cjs +++ b/.github/scripts/pr-quality-messages.cjs @@ -5,11 +5,14 @@ const { } = require("./pr-quality.cjs"); const { readinessStateMarker, + gateStateMarker, READINESS_LATEST_DEV_BEHIND_MAX } = require("./pr-quality-state.cjs"); -/** Marks the bot's review-readiness checklist message. */ +/** Marks the bot's consolidated PR gate message. */ const READINESS_MARKER = ""; +/** Marks the bot's consolidated PR gate message. */ +const GATE_MARKER = ""; function inlineCode(value) { const text = String(value); @@ -29,32 +32,57 @@ function readinessChecklistLines(readiness) { } /** - * The full readiness-message body: marker, serialized state, mirror lines for - * the tickable boxes, the tick count, and the path-specific extra lines. + * The consolidated PR-gate comment body. It is the single always-present bot + * message on a contributor PR and carries everything the author needs: current + * status, actionable next steps, the readiness-checklist mirror, and the draft + * reason. The whole body is rebuilt every run and written exactly once, so it + * always reflects the current state and can never be double-edited. + * + * @param {object} state serialized gate state (for the embedded marker). + * @param {object} opts + * @param {string} opts.status "DRAFT" or "READY". + * @param {string} opts.statusReason one-line why. + * @param {string[]} opts.actions actionable "What to do" lines (rendered as bullets). + * @param {object} opts.readiness extractReviewReadiness result (mirror + tick count). + * @param {boolean} opts.checklistRequired + * @param {string[]} opts.notices extra lines (claim/stale/review-requested). */ -function buildReadinessCommentBody(state, readiness, extra) { - const complete = readiness.present && readiness.complete; +function buildGateCommentBody(state, opts) { + const { + status, + statusReason, + actions = [], + readiness, + checklistRequired = true, + notices = [] + } = opts; + const complete = readiness?.present && readiness?.complete; + const statusEmoji = status === "READY" ? "✅" : "⏳"; return [ - READINESS_MARKER, - readinessStateMarker(state), + GATE_MARKER, + gateStateMarker(state), "", - "## Review readiness checklist", + `## ${statusEmoji} ${status}`, + statusReason ? `- ${statusReason}` : "", "", - readiness.present - ? "This PR is kept in **draft** until every requirement below is fulfilled. The tickable checklist has been added to your PR description — tick all four boxes there." - : "The review readiness checklist is not required for this author.", - "", - ...(readiness.present ? readinessChecklistLines(readiness) : []), - "", - readiness.present - ? complete - ? "✅ **4/4** boxes ticked." - : `**${readiness.checked}/${readiness.total}** boxes ticked.` - : "", - "", - ...extra - ]; + ...(actions.length > 0 + ? ["## What to do", "", ...actions.map(line => `- ${line}`), ""] + : []), + ...(checklistRequired && readiness?.present + ? [ + "## Review readiness checklist", + "", + ...readinessChecklistLines(readiness), + "", + complete + ? "✅ **4/4** boxes ticked." + : `**${readiness.checked}/${readiness.total}** boxes ticked.`, + "" + ] + : []), + ...notices + ].filter(line => line !== null && line !== undefined); } function descriptionFailureLines(reason) { @@ -178,6 +206,32 @@ function buildClaimCheckNotice(violations, liveHeadSha) { return lines; } +/** + * The notice shown when the gate's own findings check disproves the + * Codex/CodeRabbit findings box. `byBot` maps each review-bot login to its + * unresolved finding count (inline threads plus, for CodeRabbit, findings it + * posted only in its review body because they fell outside the diff range). + * The box is unticked and the PR stays a draft until every finding is + * resolved. + */ +function buildFindingsClaimNotice(byBot) { + const names = { + "chatgpt-codex-connector[bot]": "Codex", + "coderabbitai[bot]": "CodeRabbit" + }; + const lines = []; + for (const [login, count] of Object.entries(byBot)) { + const label = names[login] ?? login; + lines.push( + `${label} has ${count} unresolved finding${count === 1 ? "" : "s"}; the **Codex/CodeRabbit findings** box has been unticked.` + ); + } + lines.push( + "Resolve every open review conversation on this pull request, then re-tick the box." + ); + return lines; +} + /** The reset notice shown when a completion no longer covers the live head. */ function buildStaleNotice({ completionHeadSha, liveHeadSha, eventAction }) { let lead; @@ -196,12 +250,14 @@ function buildStaleNotice({ completionHeadSha, liveHeadSha, eventAction }) { module.exports = { READINESS_MARKER, + GATE_MARKER, inlineCode, readinessChecklistLines, - buildReadinessCommentBody, + buildGateCommentBody, descriptionFailureLines, buildFailureSections, failureSummary, buildStaleNotice, - buildClaimCheckNotice + buildClaimCheckNotice, + buildFindingsClaimNotice }; diff --git a/.github/scripts/pr-quality-messages.test.cjs b/.github/scripts/pr-quality-messages.test.cjs index 893f9173ec..0866a952d5 100644 --- a/.github/scripts/pr-quality-messages.test.cjs +++ b/.github/scripts/pr-quality-messages.test.cjs @@ -6,15 +6,16 @@ const { buildReviewReadinessSection } = require("./pr-quality.cjs"); const { - READINESS_MARKER, + GATE_MARKER, inlineCode, readinessChecklistLines, - buildReadinessCommentBody, + buildGateCommentBody, descriptionFailureLines, buildFailureSections, failureSummary, buildStaleNotice, - buildClaimCheckNotice + buildClaimCheckNotice, + buildFindingsClaimNotice } = require("./pr-quality-messages.cjs"); const PR = { @@ -45,7 +46,7 @@ describe("readinessChecklistLines", () => { }); }); -describe("buildReadinessCommentBody", () => { +describe("buildGateCommentBody", () => { const readiness = { present: true, complete: false, @@ -54,26 +55,57 @@ describe("buildReadinessCommentBody", () => { items: [{ checked: true }, { checked: false }, { checked: false }, { checked: false }] }; - it("carries the marker, serialized state, mirror, and tick count", () => { - const state = { version: 2, maintainersPinged: false }; - const body = buildReadinessCommentBody(state, readiness, ["extra line"]).join("\n"); - assert.ok(body.startsWith(READINESS_MARKER)); - assert.ok(body.includes('/; +/** + * Regex that finds the consolidated gate state marker. This is the only state + * marker the gate writes after the migration; the two legacy patterns above + * are read only to migrate pre-consolidation PRs. + */ +const GATE_STATE_PATTERN = + //; /** * v2 adds `completedAtHeadSha` so a completed checklist is bound to the exact @@ -70,6 +77,54 @@ function readinessStateMarker(state) { ); } +/** + * Parse the consolidated gate state marker, or `null` when absent or + * unreadable. + */ +function parseGateState(body, warn = () => {}) { + const match = body?.match(GATE_STATE_PATTERN); + + if (!match) { + return null; + } + + try { + return JSON.parse(match[1]); + } catch (error) { + warn(`Could not parse stored gate state: ${error.message}`); + + return null; + } +} + +/** Serialize the consolidated gate state into its comment marker. */ +function gateStateMarker(state) { + return ( + "" + ); +} + +/** + * Fresh consolidated gate state. It merges the old enforcer ownership fields + * (active / autoDraftedByBot / titlePrefixedByBot) with the readiness fields + * (maintainersPinged / completedAtHeadSha). `reviewReadyLabeled` records + * whether the gate currently owns the `review-ready` label, so a run that + * merely re-renders the comment does not re-fire the label webhook. + */ +function defaultGateState() { + return { + version: 1, + active: false, + autoDraftedByBot: false, + titlePrefixedByBot: false, + maintainersPinged: false, + completedAtHeadSha: null, + reviewReadyLabeled: false + }; +} + /** The enforcer comment state after every quality gate clears. */ function clearedEnforcerState() { return { @@ -106,6 +161,28 @@ function defaultReadinessState() { }; } +/** + * Migrate a pre-consolidation PR: merge the legacy enforcer and readiness + * states into the consolidated gate state. The legacy states are read from the + * two old bot comments; either may be absent (null). State fields are read + * for truthiness (not strict type), matching how the pre-consolidation gate + * read them — a legacy marker carrying `"active":"true"` still restores. + */ +function migrateLegacyGateState(enforcerState, readinessState) { + const gate = defaultGateState(); + if (enforcerState) { + gate.active = Boolean(enforcerState.active); + gate.autoDraftedByBot = Boolean(enforcerState.autoDraftedByBot); + gate.titlePrefixedByBot = Boolean(enforcerState.titlePrefixedByBot); + } + if (readinessState) { + gate.autoDraftedByBot = Boolean(readinessState.autoDraftedByBot); + gate.maintainersPinged = Boolean(readinessState.maintainersPinged); + gate.completedAtHeadSha = readinessState.completedAtHeadSha ?? null; + } + return gate; +} + /** * A completed checklist is an attestation about a specific head. The * attestation is stale when the recorded completion head differs from the @@ -141,6 +218,94 @@ function readinessClaimViolations({ return violations; } +/** + * The review bots whose findings threads the gate can verify. Codex posts + * under the ChatGPT Codex Connector app; CodeRabbit under coderabbitai. Both + * attach inline findings as pull-request review threads. + */ +const REVIEW_FINDINGS_BOT_LOGINS = [ + "chatgpt-codex-connector[bot]", + "coderabbitai[bot]" +]; + +/** + * CodeRabbit's review-body line that reports actionable inline findings. The + * gate reads this to count findings that CodeRabbit posts only as review-body + * text ("outside the diff range") rather than as inline review threads. + */ +const CODE_RABBIT_ACTIONABLE_RE = + /\*\*Actionable comments posted:\s*(\d+)\*\*/i; + +/** + * Pull-request reviews (from `pulls.listReviews`) that carry CodeRabbit + * findings. CodeRabbit posts some findings that cannot be attached inline + * ("outside the diff range") in the review body with the line + * `**Actionable comments posted: N**`; those never become review threads, so + * the thread check alone would miss them. This supplements the thread check: + * a CodeRabbit review of the live head whose body reports actionable comments + * counts as an unresolved finding. + * + * Only the most recent review for the live head is considered (the head a + * findings-review covers is the head that must be clean), so an older review + * of a superseded commit cannot keep the box unticked forever. + */ +function coderabbitOutsideDiffFindings({ reviews = [], liveHeadSha }) { + if (!liveHeadSha || !Array.isArray(reviews) || reviews.length === 0) { + return { code: null, unresolved: 0, byBot: {} }; + } + const latestForHead = reviews + .filter(review => review?.commit_id === liveHeadSha) + .sort( + (a, b) => + Date.parse(String(b?.submitted_at ?? "")) - + Date.parse(String(a?.submitted_at ?? "")) + )[0]; + const body = String(latestForHead?.body ?? ""); + const match = CODE_RABBIT_ACTIONABLE_RE.exec(body); + if (!match) return { code: null, unresolved: 0, byBot: {} }; + const count = Number(match[1]); + if (!(count > 0)) return { code: null, unresolved: 0, byBot: {} }; + return { + code: "review_findings", + unresolved: count, + byBot: { "coderabbitai[bot]": count } + }; +} + +/** + * Verify the Codex/CodeRabbit findings claim. The primary signal is the + * pull-request review threads the GraphQL `pullRequestReviewThreads` query + * returns: a thread authored by a review bot that is not explicitly resolved + * is an unresolved finding. CodeRabbit additionally reports some findings + * only in its review body (outside the diff range); those are added by the + * `coderabbitOutsideDiffFindings` supplement so they cannot slip through. + * The supplement is subordinate: it never subtracts, only adds unresolved + * counts for the live head, and once the reviewer resolves the threads the + * next run re-checks. + */ +function unresolvedFindingsClaim({ threads = [], reviews = [], liveHeadSha }) { + const byBot = {}; + let unresolved = 0; + for (const thread of threads) { + const login = thread?.author?.login; + if (!REVIEW_FINDINGS_BOT_LOGINS.includes(login)) continue; + if (thread.isResolved !== true) { + byBot[login] = (byBot[login] ?? 0) + 1; + unresolved += 1; + } + } + const outside = coderabbitOutsideDiffFindings({ reviews, liveHeadSha }); + if (outside.code) { + for (const [login, count] of Object.entries(outside.byBot)) { + byBot[login] = (byBot[login] ?? 0) + count; + unresolved += count; + } + } + return unresolved > 0 + ? { code: "review_findings", unresolved, byBot } + : { code: null, unresolved: 0, byBot }; +} + function completionIsStale({ checklistRequired, checklistComplete, @@ -178,13 +343,22 @@ function completionIsStale({ module.exports = { READINESS_LATEST_DEV_BEHIND_MAX, readinessClaimViolations, + unresolvedFindingsClaim, STATE_PATTERN, READINESS_STATE_PATTERN, + GATE_STATE_PATTERN, READINESS_STATE_VERSION, + REVIEW_FINDINGS_BOT_LOGINS, + CODE_RABBIT_ACTIONABLE_RE, + coderabbitOutsideDiffFindings, parseState, stateMarker, parseReadinessState, readinessStateMarker, + parseGateState, + gateStateMarker, + defaultGateState, + migrateLegacyGateState, clearedEnforcerState, defaultEnforcerState, defaultReadinessState, diff --git a/.github/scripts/pr-quality-state.test.cjs b/.github/scripts/pr-quality-state.test.cjs index e3b63fd5c2..33d6942ce3 100644 --- a/.github/scripts/pr-quality-state.test.cjs +++ b/.github/scripts/pr-quality-state.test.cjs @@ -7,11 +7,18 @@ const { stateMarker, parseReadinessState, readinessStateMarker, + parseGateState, + gateStateMarker, + defaultGateState, + migrateLegacyGateState, clearedEnforcerState, defaultEnforcerState, defaultReadinessState, completionIsStale, readinessClaimViolations, + unresolvedFindingsClaim, + coderabbitOutsideDiffFindings, + REVIEW_FINDINGS_BOT_LOGINS, READINESS_LATEST_DEV_BEHIND_MAX, READINESS_STATE_VERSION } = require("./pr-quality-state.cjs"); @@ -261,3 +268,267 @@ describe("readinessClaimViolations", () => { ); }); }); + +describe("unresolvedFindingsClaim", () => { + it("passes when there are no review threads at all", () => { + assert.deepEqual(unresolvedFindingsClaim({ threads: [] }), { + code: null, + unresolved: 0, + byBot: {}, + }); + }); + + it("passes when every bot thread is resolved", () => { + assert.deepEqual( + unresolvedFindingsClaim({ + threads: [ + { isResolved: true, author: { login: "chatgpt-codex-connector[bot]" } }, + { isResolved: true, author: { login: "coderabbitai[bot]" } }, + ], + }), + { code: null, unresolved: 0, byBot: {} }, + ); + }); + + it("flags one unresolved Codex thread and counts it per bot", () => { + assert.deepEqual( + unresolvedFindingsClaim({ + threads: [ + { isResolved: false, author: { login: "chatgpt-codex-connector[bot]" } }, + { isResolved: true, author: { login: "coderabbitai[bot]" } }, + ], + }), + { + code: "review_findings", + unresolved: 1, + byBot: { "chatgpt-codex-connector[bot]": 1 }, + }, + ); + }); + + it("flags unresolved threads from both bots and counts each", () => { + assert.deepEqual( + unresolvedFindingsClaim({ + threads: [ + { isResolved: false, author: { login: "chatgpt-codex-connector[bot]" } }, + { isResolved: false, author: { login: "chatgpt-codex-connector[bot]" } }, + { isResolved: false, author: { login: "coderabbitai[bot]" } }, + ], + }), + { + code: "review_findings", + unresolved: 3, + byBot: { + "chatgpt-codex-connector[bot]": 2, + "coderabbitai[bot]": 1, + }, + }, + ); + }); + + it("ignores unresolved threads from humans", () => { + assert.deepEqual( + unresolvedFindingsClaim({ + threads: [ + { isResolved: false, author: { login: "wibias" } }, + { isResolved: false, author: null }, + ], + }), + { code: null, unresolved: 0, byBot: {} }, + ); + }); + + it("fails closed on a thread with no resolution state", () => { + // A thread whose isResolved is missing cannot be claimed resolved. + assert.deepEqual( + unresolvedFindingsClaim({ + threads: [{ isResolved: null, author: { login: "coderabbitai[bot]" } }], + }), + { + code: "review_findings", + unresolved: 1, + byBot: { "coderabbitai[bot]": 1 }, + }, + ); + }); + + it("exposes the bot allowlist", () => { + assert.deepEqual(REVIEW_FINDINGS_BOT_LOGINS, [ + "chatgpt-codex-connector[bot]", + "coderabbitai[bot]", + ]); + }); +}); + +describe("coderabbitOutsideDiffFindings", () => { + const HEAD = "3f1c0de0a6a4d0a3f9a1b2c3d4e5f60718293a4b"; + + it("flags a CodeRabbit review of the live head with actionable comments", () => { + const claim = coderabbitOutsideDiffFindings({ + reviews: [ + { + body: "**Actionable comments posted: 3**\n\nSome walkthrough.", + commit_id: HEAD, + submitted_at: "2026-08-04T06:24:02Z", + }, + ], + liveHeadSha: HEAD, + }); + assert.deepEqual(claim, { + code: "review_findings", + unresolved: 3, + byBot: { "coderabbitai[bot]": 3 }, + }); + }); + + it("ignores a review of a different head", () => { + const claim = coderabbitOutsideDiffFindings({ + reviews: [ + { + body: "**Actionable comments posted: 3**", + commit_id: "1111111111111111111111111111111111111111", + submitted_at: "2026-08-04T06:24:02Z", + }, + ], + liveHeadSha: HEAD, + }); + assert.deepEqual(claim, { code: null, unresolved: 0, byBot: {} }); + }); + + it("ignores a review reporting zero actionable comments", () => { + const claim = coderabbitOutsideDiffFindings({ + reviews: [{ body: "**Actionable comments posted: 0**", commit_id: HEAD }], + liveHeadSha: HEAD, + }); + assert.deepEqual(claim, { code: null, unresolved: 0, byBot: {} }); + }); + + it("uses the most recent review of the live head", () => { + const claim = coderabbitOutsideDiffFindings({ + reviews: [ + { body: "**Actionable comments posted: 2**", commit_id: HEAD, submitted_at: "2026-08-04T06:00:00Z" }, + { body: "**Actionable comments posted: 5**", commit_id: HEAD, submitted_at: "2026-08-04T07:00:00Z" }, + ], + liveHeadSha: HEAD, + }); + assert.equal(claim.unresolved, 5); + }); + + it("returns clean for no reviews or no live head", () => { + assert.deepEqual(coderabbitOutsideDiffFindings({ reviews: [], liveHeadSha: HEAD }), { + code: null, + unresolved: 0, + byBot: {}, + }); + assert.deepEqual(coderabbitOutsideDiffFindings({ reviews: [{ body: "**Actionable comments posted: 1**", commit_id: HEAD }] }), { + code: null, + unresolved: 0, + byBot: {}, + }); + }); +}); + +describe("unresolvedFindingsClaim with outside-diff supplement", () => { + const HEAD = "3f1c0de0a6a4d0a3f9a1b2c3d4e5f60718293a4b"; + + it("adds the outside-diff count to a clean thread set", () => { + const claim = unresolvedFindingsClaim({ + threads: [], + reviews: [{ body: "**Actionable comments posted: 2**", commit_id: HEAD, submitted_at: "2026-08-04T06:24:02Z" }], + liveHeadSha: HEAD, + }); + assert.deepEqual(claim, { + code: "review_findings", + unresolved: 2, + byBot: { "coderabbitai[bot]": 2 }, + }); + }); + + it("adds the outside-diff count to an unresolved thread count", () => { + const claim = unresolvedFindingsClaim({ + threads: [ + { isResolved: false, author: { login: "coderabbitai[bot]" } }, + ], + reviews: [{ body: "**Actionable comments posted: 2**", commit_id: HEAD, submitted_at: "2026-08-04T06:24:02Z" }], + liveHeadSha: HEAD, + }); + assert.deepEqual(claim, { + code: "review_findings", + unresolved: 3, + byBot: { "coderabbitai[bot]": 3 }, + }); + }); + + it("keeps a resolved thread set clean even with a stale review", () => { + const claim = unresolvedFindingsClaim({ + threads: [ + { isResolved: true, author: { login: "coderabbitai[bot]" } }, + ], + reviews: [{ body: "**Actionable comments posted: 2**", commit_id: "1111111111111111111111111111111111111111", submitted_at: "2026-08-04T06:24:02Z" }], + liveHeadSha: HEAD, + }); + assert.deepEqual(claim, { code: null, unresolved: 0, byBot: {} }); + }); +}); + +describe("gate state", () => { + it("round-trips through gateStateMarker and parseGateState", () => { + const state = defaultGateState(); + assert.deepEqual(parseGateState(gateStateMarker(state)), state); + }); + + it("returns null for markerless or unreadable gate state and warns", () => { + assert.equal(parseGateState("plain comment"), null); + assert.equal(parseGateState(null), null); + const warnings = []; + assert.equal( + parseGateState("", m => + warnings.push(m), + ), + null, + ); + assert.match(warnings[0], /Could not parse stored gate state/); + }); + + it("builds a fresh gate state", () => { + assert.deepEqual(defaultGateState(), { + version: 1, + active: false, + autoDraftedByBot: false, + titlePrefixedByBot: false, + maintainersPinged: false, + completedAtHeadSha: null, + reviewReadyLabeled: false, + }); + }); + + it("merges legacy enforcer + readiness states", () => { + const merged = migrateLegacyGateState( + { version: 1, active: true, autoDraftedByBot: true, titlePrefixedByBot: true }, + { version: 2, autoDraftedByBot: true, maintainersPinged: true, completedAtHeadSha: "abc123" }, + ); + assert.equal(merged.active, true); + assert.equal(merged.autoDraftedByBot, true); + assert.equal(merged.titlePrefixedByBot, true); + assert.equal(merged.maintainersPinged, true); + assert.equal(merged.completedAtHeadSha, "abc123"); + assert.equal(merged.reviewReadyLabeled, false); + }); + + it("migrates with either legacy state absent", () => { + const onlyEnforcer = migrateLegacyGateState( + { version: 1, active: true, titlePrefixedByBot: true }, + null, + ); + assert.equal(onlyEnforcer.active, true); + assert.equal(onlyEnforcer.titlePrefixedByBot, true); + assert.equal(onlyEnforcer.completedAtHeadSha, null); + + const onlyReadiness = migrateLegacyGateState( + null, + { version: 2, maintainersPinged: true }, + ); + assert.equal(onlyReadiness.active, false); + assert.equal(onlyReadiness.maintainersPinged, true); + }); +}); diff --git a/.github/scripts/pr-quality.cjs b/.github/scripts/pr-quality.cjs index f401b90c68..16adf2ebc3 100644 --- a/.github/scripts/pr-quality.cjs +++ b/.github/scripts/pr-quality.cjs @@ -27,18 +27,19 @@ const REVIEW_READINESS_END = ""; const REVIEW_READINESS_ITEMS = [ "All CI tests are green on my local testing.", "I pushed my PR to the latest dev commit.", - "I fixed all correct Codex and CodeRabbit findings.", + "I resolved all correct Codex and CodeRabbit findings.", "My PR is ready for review.", ]; /** * Which checklist box each bot-verifiable claim maps to. The order must stay - * in sync with REVIEW_READINESS_ITEMS: index 0 is the CI claim and index 1 is - * the latest-dev claim. + * in sync with REVIEW_READINESS_ITEMS: index 0 is the CI claim, index 1 is + * the latest-dev claim, and index 2 is the Codex/CodeRabbit findings claim. */ const REVIEW_READINESS_CLAIM_INDEX = { ci_green: 0, - latest_dev: 1 + latest_dev: 1, + review_findings: 2 }; /** @@ -179,6 +180,33 @@ function hasGuiCue(title, body) { ); } +/** + * Phrases in a maintainer comment that waive the GUI-screenshot gate. A + * comment saying the change does not touch the GUI means the `gui` cue in the + * title/description is a false positive and a screenshot is not required. The + * negation word must appear within a short window before `gui`, so a comment + * like "this touches gui but only the config" (no negation) keeps the gate. + */ +const GUI_OVERRIDE_RE = + /\b(?:no|not|doesn'?t|does not|never|without)\b[\s\S]{0,40}?\bgui\b/i; + +/** + * True when a maintainer (OWNER / COLLABORATOR / MEMBER) issue comment waives + * the GUI-screenshot requirement. Only the comment author's association + * counts: the PR author (`CONTRIBUTOR`/`NONE`) cannot override their own + * screenshot requirement. + */ +function hasGuiOverride({ comments = [] }) { + return comments.some( + comment => + (comment?.author_association === "OWNER" || + comment?.author_association === "COLLABORATOR" || + comment?.author_association === "MEMBER") && + typeof comment?.body === "string" && + GUI_OVERRIDE_RE.test(comment.body) + ); +} + /** * Drop the regions GitHub does not render as Markdown: HTML comments and * fenced code blocks. Image syntax there is literal text, not evidence. @@ -416,6 +444,8 @@ function collectPrQualityFailures({ ancestryLookupFailed = false, /** True when baseRef is another open PR's head (stacked child). */ stackedBase = false, + /** Issue comments; a maintainer comment waives the GUI-screenshot gate. */ + guiOverrideComments = [] }) { const failures = []; const wrongBase = !allowedBases.includes(baseRef) && !stackedBase; @@ -444,13 +474,15 @@ function collectPrQualityFailures({ } // GUI-cued PRs must prove the UI change visually. The template's own - // screenshot instruction is boilerplate, so it cannot trigger this gate. + // screenshot instruction is boilerplate, so it cannot trigger this gate. A + // maintainer comment saying the change does not touch the GUI waives it. if ( hasGuiCue( title, typeof body === "string" ? stripPrTemplateBoilerplate(body) : "", ) && - !hasScreenshotEvidence(body) + !hasScreenshotEvidence(body) && + !hasGuiOverride({ comments: guiOverrideComments }) ) { failures.push({ code: "missing_ui_screenshot" }); } @@ -467,6 +499,7 @@ module.exports = { authorHasPushPermission, assessPrDescription, hasGuiCue, + hasGuiOverride, hasScreenshotEvidence, buildReviewReadinessSection, extractReviewReadiness, diff --git a/.github/scripts/pr-quality.test.cjs b/.github/scripts/pr-quality.test.cjs index 6bf40964eb..9b2455d2fd 100644 --- a/.github/scripts/pr-quality.test.cjs +++ b/.github/scripts/pr-quality.test.cjs @@ -9,6 +9,7 @@ const { authorHasPushPermission, assessPrDescription, hasGuiCue, + hasGuiOverride, hasScreenshotEvidence, buildReviewReadinessSection, extractReviewReadiness, @@ -148,6 +149,50 @@ describe("hasGuiCue", () => { }); }); +describe("hasGuiOverride", () => { + const owner = { author_association: "OWNER", body: "Not touching gui here." }; + const collaborator = { author_association: "COLLABORATOR", body: "no gui changes needed" }; + const member = { author_association: "MEMBER", body: "doesn't change the gui" }; + const author = { author_association: "CONTRIBUTOR", body: "Not touching gui here." }; + const outsider = { author_association: "NONE", body: "Not touching gui here." }; + + it("matches a maintainer comment with a negation phrase", () => { + assert.equal(hasGuiOverride({ comments: [owner] }), true); + assert.equal(hasGuiOverride({ comments: [collaborator] }), true); + assert.equal(hasGuiOverride({ comments: [member] }), true); + assert.equal( + hasGuiOverride({ comments: [{ author_association: "OWNER", body: "I did not change gui" }] }), + true, + ); + assert.equal( + hasGuiOverride({ comments: [{ author_association: "OWNER", body: "Without gui changes" }] }), + true, + ); + }); + + it("does not let the PR author or a non-collaborator waive the gate", () => { + assert.equal(hasGuiOverride({ comments: [author] }), false); + assert.equal(hasGuiOverride({ comments: [outsider] }), false); + }); + + it("does not match a comment that names gui without negating it", () => { + assert.equal( + hasGuiOverride({ comments: [{ author_association: "OWNER", body: "This touches gui but only config" }] }), + false, + ); + assert.equal( + hasGuiOverride({ comments: [{ author_association: "OWNER", body: "gui is involved here" }] }), + false, + ); + }); + + it("is clean for no comments or a comment without a body", () => { + assert.equal(hasGuiOverride({ comments: [] }), false); + assert.equal(hasGuiOverride({ comments: [{ author_association: "OWNER" }] }), false); + assert.equal(hasGuiOverride({}), false); + }); +}); + describe("hasScreenshotEvidence", () => { it("accepts embedded markdown images", () => { assert.equal( @@ -489,7 +534,7 @@ describe("uncheckReviewReadinessBoxes", () => { "", "- [x] All CI tests are green on my local testing.", "- [x] I pushed my PR to the latest dev commit.", - "- [x] I fixed all correct Codex and CodeRabbit findings.", + "- [x] I resolved all correct Codex and CodeRabbit findings.", "- [x] My PR is ready for review.", "", ].join("\n"); @@ -510,7 +555,7 @@ describe("uncheckReviewReadinessBoxes", () => { ]); assert.ok(body.includes("- [ ] All CI tests are green on my local testing.")); assert.ok(body.includes("- [ ] I pushed my PR to the latest dev commit.")); - assert.ok(body.includes("- [x] I fixed all correct Codex and CodeRabbit findings.")); + assert.ok(body.includes("- [x] I resolved all correct Codex and CodeRabbit findings.")); assert.ok(body.includes("- [x] My PR is ready for review.")); }); diff --git a/.github/workflows/enforce-pr-target.yml b/.github/workflows/enforce-pr-target.yml index 94023e7d0a..6a1c228918 100644 --- a/.github/workflows/enforce-pr-target.yml +++ b/.github/workflows/enforce-pr-target.yml @@ -8,8 +8,13 @@ on: - edited - ready_for_review - synchronize + # Review events let the gate catch bot findings (Codex / CodeRabbit + # review threads) that land after a PR was marked ready, without waiting + # for the author's next push. + - pull_request_review + - pull_request_review_comment -# pull-requests:write covers title/comment updates. +# pull-requests:write covers title/comment/label updates. # contents:write is required for convertPullRequestToDraft / # markPullRequestReadyForReview GraphQL mutations with GITHUB_TOKEN # (otherwise: "Resource not accessible by integration"). This workflow @@ -49,6 +54,7 @@ jobs: const { collectPrQualityFailures, authorHasPushPermission, + hasGuiOverride, extractReviewReadiness, appendReviewReadinessSection, stripReviewReadinessSection, @@ -59,14 +65,15 @@ jobs: path.join(process.cwd(), ".github", "scripts", "pr-quality.cjs"), ); const { + parseGateState, + gateStateMarker, parseState, - stateMarker, parseReadinessState, - clearedEnforcerState, - defaultEnforcerState, - defaultReadinessState, + defaultGateState, + migrateLegacyGateState, completionIsStale, readinessClaimViolations, + unresolvedFindingsClaim, READINESS_STATE_VERSION } = require( path.join( @@ -77,13 +84,15 @@ jobs: ), ); const { + GATE_MARKER, READINESS_MARKER, inlineCode, - buildReadinessCommentBody, + buildGateCommentBody, buildFailureSections, failureSummary, buildStaleNotice, - buildClaimCheckNotice + buildClaimCheckNotice, + buildFindingsClaimNotice } = require( path.join( process.cwd(), @@ -106,8 +115,8 @@ jobs: const ALLOWED_BASES = ["dev"]; const DEFAULT_BASE = "dev"; const TITLE_PREFIX = "[WRONG BRANCH] "; - const COMMENT_MARKER = ""; const LEGACY_COMMENT_MARKER = ""; + const REVIEW_READY_LABEL = "review-ready"; const MAINTAINERS_FILE = "MAINTAINERS.md"; const { owner, repo } = context.repo; @@ -129,24 +138,48 @@ jobs: } ); - const botComment = comments.find( + // One consolidated comment. The gate finds its own comment by the + // single GATE_MARKER; the legacy enforcer/readiness markers are + // matched only to migrate pre-consolidation PRs. + const gateComment = comments.find( comment => comment.user?.login === "github-actions[bot]" && - (comment.body?.includes(COMMENT_MARKER) || - comment.body?.includes(LEGACY_COMMENT_MARKER)) + comment.body?.includes(GATE_MARKER) + ); + let gateCommentId = gateComment?.id ?? null; + const storedGateState = parseGateState( + gateComment?.body, + message => core.warning(message) ); - let botCommentId = botComment?.id ?? null; - const readinessComment = comments.find( + // Legacy comments: the pre-consolidation two-comment model. Their + // state is merged once into the single comment, then the old + // comments are deleted. + const legacyEnforcerComment = comments.find( + comment => + comment.user?.login === "github-actions[bot]" && + (comment.body?.includes("") || + comment.body?.includes(LEGACY_COMMENT_MARKER)) + ); + const legacyReadinessComment = comments.find( comment => comment.user?.login === "github-actions[bot]" && comment.body?.includes(READINESS_MARKER) ); - let readinessCommentId = readinessComment?.id ?? null; - const storedReadinessState = parseReadinessState( - readinessComment?.body, + const legacyEnforcerState = parseState( + legacyEnforcerComment?.body, + message => core.warning(message) + ); + const legacyReadinessState = parseReadinessState( + legacyReadinessComment?.body, message => core.warning(message) ); + const migratedGateState = migrateLegacyGateState( + legacyEnforcerState, + legacyReadinessState + ); + + let gateState = storedGateState ?? migratedGateState; /** * Maintainers from `MAINTAINERS.md` on the trusted default branch @@ -169,48 +202,77 @@ jobs: } } - async function upsertReadinessComment(state, readiness, extra) { - const lines = buildReadinessCommentBody(state, readiness, extra); - - if (readinessCommentId) { - await github.rest.issues.updateComment({ + async function setReviewReadyLabel(shouldHave, hasLabel) { + if (shouldHave && !hasLabel) { + await github.rest.issues.addLabels({ owner, repo, - comment_id: readinessCommentId, - body: lines.join("\n") + issue_number: pull_number, + labels: [REVIEW_READY_LABEL] + }); + } else if (!shouldHave && hasLabel) { + await github.rest.issues.removeLabel({ + owner, + repo, + issue_number: pull_number, + name: REVIEW_READY_LABEL }); - - return; } - - const created = await github.rest.issues.createComment({ - owner, - repo, - issue_number: pull_number, - body: lines.join("\n") - }); - readinessCommentId = created.data.id; } - async function upsertComment(body) { - if (botCommentId) { + /** + * The single write to the consolidated comment. Every run rebuilds + * the full body and writes it exactly once (create-if-absent, + * update-if-present), so there is never a double-edit of the + * readiness section or a stale intermediate checkpoint body. + */ + async function upsertGateComment(state, opts) { + const body = buildGateCommentBody(state, opts).join("\n"); + if (gateCommentId) { await github.rest.issues.updateComment({ owner, repo, - comment_id: botCommentId, + comment_id: gateCommentId, body }); - + await migrateLegacyCommentsIfNeeded(); return; } - const created = await github.rest.issues.createComment({ owner, repo, issue_number: pull_number, body }); - botCommentId = created.data.id; + gateCommentId = created.data.id; + await migrateLegacyCommentsIfNeeded(); + } + + /** + * One-time migration: merge legacy state into the single comment, + * then delete the two old comments. The gate comment must already + * exist (created/updated by upsertGateComment) so its id is known + * and the legacy comments are not mistaken for it. + */ + async function migrateLegacyCommentsIfNeeded() { + const legacyIds = [ + legacyEnforcerComment?.id, + legacyReadinessComment?.id + ].filter(id => typeof id === "number" && id !== gateCommentId); + if (legacyIds.length === 0) return; + for (const id of legacyIds) { + try { + await github.rest.issues.deleteComment({ + owner, + repo, + comment_id: id + }); + } catch (error) { + core.warning( + `Could not delete legacy bot comment ${id}: ${error.message}` + ); + } + } } async function convertToDraft() { @@ -257,11 +319,6 @@ jobs: ); } - const storedState = parseState( - botComment?.body, - message => core.warning(message) - ); - let authorPermission = null; let permissionLookupFailed = false; try { @@ -356,9 +413,18 @@ jobs: authorPermission, permissionLookupFailed, ancestryLookupFailed, - stackedBase + stackedBase, + // A maintainer issue comment ("not touching gui") waives the + // GUI-screenshot gate; the comments are already fetched above. + guiOverrideComments: comments }); + // A maintainer issue comment saying the change does not touch + // the GUI waives the screenshot gate. The flag is what tells the + // author the screenshot is not required, even though the failure + // itself is gone from `failures`. + const screenshotWaived = hasGuiOverride({ comments }); + // The readiness gate applies to contributors (no push permission). // Maintainers keep the failure-only contract: draft while quality // gates fail, ready again once they clear. A failed permission @@ -407,7 +473,7 @@ jobs: const eventHeadSha = context.payload.pull_request?.head?.sha ?? pr.head.sha; const completionHeadSha = - storedReadinessState?.completedAtHeadSha ?? null; + gateState.completedAtHeadSha ?? null; const headDrifted = completionIsStale({ checklistRequired, checklistComplete, @@ -432,7 +498,7 @@ jobs: const freshReadiness = extractReviewReadiness( freshPr.body ?? "" ); - readinessStateOverride = defaultReadinessState(); + readinessStateOverride = defaultGateState(); headDriftNotice = buildStaleNotice({ completionHeadSha, liveHeadSha: freshPr.head.sha, @@ -457,12 +523,14 @@ jobs: checklistComplete = readiness.present && readiness.complete; } - // The bot verifies the two checklist claims it can check itself. + // The bot verifies the three checklist claims it can check itself. // The CI box only counts when the head's `ci` check (the repo's // documented "CI passed" signal) is green; the latest-dev box only // counts while the head is at most READINESS_LATEST_DEV_BEHIND_MAX - // commits behind the base. A disproved claim unchecks that box and - // keeps the PR a draft, exactly like a head-drift reset. + // commits behind the base; the findings box only counts while every + // Codex/CodeRabbit review thread on the PR is resolved. A disproved + // claim unchecks that box and keeps the PR a draft, exactly like a + // head-drift reset. let claimViolations = []; let claimNotice = []; if ( @@ -502,6 +570,73 @@ jobs: behindBase, behindUnknown: ancestryLookupFailed }); + // The findings claim reads the review threads via GraphQL. Only + // threads authored by the review bots count; `isResolved` must be + // explicitly true, so a missing or unreadable thread fails closed. + // CodeRabbit additionally reports some findings only in its + // review body (outside the diff range); `pulls.listReviews` + // supplies those as a supplement. A review listing failure fails + // closed the same way as an unreadable thread list. + let findingsClaim = null; + let findingsUnverifiable = false; + try { + const reviewData = await github.graphql( + ` + query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + reviewThreads(first: 100) { + nodes { + isResolved + comments(first: 1) { + nodes { + author { login } + } + } + } + } + } + } + } + `, + { owner, repo, number: pull_number } + ); + const reviewsData = await github.paginate( + github.rest.pulls.listReviews, + { + owner, + repo, + pull_number, + per_page: 100 + } + ); + findingsClaim = unresolvedFindingsClaim({ + threads: ( + reviewData?.repository?.pullRequest?.reviewThreads?.nodes ?? + [] + ).map(node => ({ + isResolved: node.isResolved, + author: node.comments?.nodes?.[0]?.author ?? null + })), + reviews: reviewsData, + liveHeadSha: pr.head.sha + }); + } catch (error) { + core.warning( + `Could not list review threads for the readiness claim check: ${error.message}` + ); + // Fail closed: an attestation must not ride on missing + // evidence, exactly like unknown CI or behind counts. + findingsUnverifiable = true; + findingsClaim = { + code: "review_findings", + unresolved: 0, + byBot: {} + }; + } + if (findingsClaim?.code) { + claimViolations.push(findingsClaim.code); + } if (claimViolations.length > 0) { const { data: freshPr } = await github.rest.pulls.get({ owner, @@ -511,11 +646,22 @@ jobs: const freshReadiness = extractReviewReadiness( freshPr.body ?? "" ); - readinessStateOverride = defaultReadinessState(); - claimNotice = buildClaimCheckNotice( - claimViolations, - freshPr.head.sha - ); + readinessStateOverride = defaultGateState(); + claimNotice = [ + ...(claimViolations.includes("review_findings") + ? findingsUnverifiable + ? [ + "The Codex/CodeRabbit findings claim could not be verified; the **Codex/CodeRabbit findings** box has been unticked. The PR stays a draft until review threads are readable again." + ] + : buildFindingsClaimNotice(findingsClaim.byBot) + : []), + ...buildClaimCheckNotice( + claimViolations.filter( + code => code !== "review_findings" + ), + freshPr.head.sha + ) + ]; if (freshReadiness.present) { const uncheckedBody = uncheckReviewReadinessBoxes( freshPr.body ?? "", @@ -551,25 +697,66 @@ jobs: ? headDriftNotice : claimNotice; + // Assemble the "What to do" action lines for the consolidated + // comment. Only the applicable actions render. + function buildActions() { + const actions = []; + if (failures.some(failure => failure.code === "wrong_base")) { + actions.push( + `Retarget this PR to ${inlineCode(DEFAULT_BASE)} — all contributions go to ${inlineCode(DEFAULT_BASE)}.` + ); + } + if (failures.some(failure => failure.code === "wrong_ancestry")) { + actions.push( + `Rebase onto the current ${inlineCode(DEFAULT_BASE)} branch instead of opening from ${inlineCode("main")}.` + ); + } + if (failures.some(failure => failure.code === "bad_description")) { + actions.push( + "Add a real **Summary** and **Test plan** to the PR description." + ); + } + if (failures.some(failure => failure.code === "missing_ui_screenshot")) { + actions.push( + "Add a screenshot of the UI change to the PR description." + ); + } + if (checklistRequired && !checklistComplete) { + actions.push( + `Tick all four boxes in the PR description once you're done (currently ${readiness.checked}/${readiness.total}).` + ); + } + if (revalidationNotice.length > 0) { + actions.push(...revalidationNotice); + } + return actions; + } + + // The `review-ready` label is the CodeRabbit/Codex opt-in trigger: + // add it at the ready moment, remove it while the PR is not ready. + const readyMoment = + checklistRequired && checklistComplete && failures.length === 0; + const reviewReadyDesired = readyMoment; + const hasReviewReadyLabel = (pr.labels ?? []).some( + label => label.name === REVIEW_READY_LABEL + ); + const reviewReadyChanged = hasReviewReadyLabel !== reviewReadyDesired; + if (reviewReadyChanged) { + await setReviewReadyLabel(reviewReadyDesired, hasReviewReadyLabel); + } + gateState.reviewReadyLabeled = reviewReadyDesired; + if (mustDraft) { let draftConverted = false; - const readinessState = - readinessStateOverride ?? - (storedReadinessState - ? { ...storedReadinessState } - : defaultReadinessState()); + const draftState = readinessStateOverride ?? { ...gateState }; if (checklistRequired && checklistComplete) { // The attestation covers this head even while another quality // gate keeps the draft: bind it now, because the failure path // below returns before the completion block that records it. - // A later push then still resets the checklist instead of - // sliding the completion forward onto un-attested code. - readinessState.completedAtHeadSha = pr.head.sha; - readinessState.version = READINESS_STATE_VERSION; + draftState.completedAtHeadSha = pr.head.sha; + draftState.version = 1; } - const state = storedState?.active - ? { ...storedState } - : defaultEnforcerState(); + const state = { ...draftState, active: true }; const hasWrongBase = failures.some( failure => failure.code === "wrong_base" ); @@ -598,61 +785,12 @@ jobs: state.titlePrefixedByBot = false; } - if (checklistRequired && !pr.draft && !checklistComplete) { - // Claim draft ownership before the mutation so a successful - // convert followed by a failed comment still restores later - // (same checkpoint discipline as the quality-failure path). - readinessState.autoDraftedByBot = true; - await upsertReadinessComment( - readinessState, - readiness, - [ - ...revalidationNotice, - "This PR stays in draft until every box above is ticked." - ] - ); - } - if (failures.length > 0) { - state.ancestryFailed = failures.some( - failure => failure.code === "wrong_ancestry" - ); - state.descriptionFailed = failures.some( - failure => failure.code === "bad_description" - ); - state.screenshotFailed = failures.some( - failure => failure.code === "missing_ui_screenshot" - ); - let draftConversionFailed = false; - const failureSections = buildFailureSections(failures, { - pr, - allowedBases: ALLOWED_BASES, - defaultBase: DEFAULT_BASE - }); - - if (checklistRequired && !checklistComplete) { - failureSections.push( - "", - "⏳ **Review readiness checklist**", - "", - `This pull request stays in draft until all four boxes of the readiness checklist in the description are ticked (currently ${readiness.checked}/${readiness.total}).`, - "", - `@${pr.user.login} Tick the boxes once your local CI is green, your branch is on the latest ${inlineCode(DEFAULT_BASE)} commit, and every correct Codex and CodeRabbit finding is fixed.` - ); - } - - await upsertComment( - [ - COMMENT_MARKER, - stateMarker(state), - "", - ...failureSections, - "", - "Recording ownership state before applying title/draft changes…" - ].join("\n") - ); - + + // Apply the bot-owned title prefix so the PR itself carries a + // durable signal of the wrong base (claim ownership before the + // write; a failed write keeps ownership for the next retry). if (willPrefixTitle) { await github.rest.pulls.update({ owner, @@ -661,35 +799,14 @@ jobs: title: `${TITLE_PREFIX}${pr.title}` }); } - + if (!pr.draft) { // Claim draft ownership before the mutation so a successful // convert followed by a failed comment still restores later. state.autoDraftedByBot = true; - await upsertComment( - [ - COMMENT_MARKER, - stateMarker(state), - "", - ...failureSections, - "", - "Draft conversion pending…" - ].join("\n") - ); try { await convertToDraft(); draftConverted = true; - readinessState.autoDraftedByBot = true; - await upsertComment( - [ - COMMENT_MARKER, - stateMarker(state), - "", - ...failureSections, - "", - "Draft conversion succeeded; finalising explanation…" - ].join("\n") - ); } catch (error) { draftConversionFailed = true; state.autoDraftedByBot = false; @@ -698,48 +815,54 @@ jobs: ); } } - + const draftExplanation = draftConversionFailed ? "Automatic draft conversion failed (token cannot change draft status). Please convert this pull request to a draft manually. The required `enforce-target` check will keep failing until every issue above is resolved." : state.autoDraftedByBot ? "This pull request is being kept as a draft automatically. Once every issue above is resolved, it will be marked ready for review again." : "This pull request was already a draft. Its draft status will be preserved after every issue above is resolved."; - - const finalSections = [...failureSections]; - - if (hasWrongBase && state.titlePrefixedByBot) { - finalSections.push( - "", - `Its title has been prefixed with ${inlineCode(TITLE_PREFIX.trim())}.` - ); - } - - await upsertComment( - [ - COMMENT_MARKER, - stateMarker(state), - "", - ...finalSections, - "", - draftExplanation - ].join("\n") - ); - - if (checklistRequired) { - await upsertReadinessComment( - readinessState, - readiness, - [ - ...revalidationNotice, - checklistComplete - ? "✅ **All four boxes are ticked.** This PR still stays in draft until the issues above are resolved." - : pr.draft || draftConverted - ? "This PR stays in draft until every box above is ticked." - : "Automatic draft conversion failed. Please convert this pull request to a draft manually until every box above is ticked." - ] - ); - } - + + const notices = [ + ...revalidationNotice, + ...(screenshotWaived + ? ["UI screenshot waived by a maintainer comment."] + : []), + ...(hasWrongBase && state.titlePrefixedByBot + ? [`Its title has been prefixed with ${inlineCode(TITLE_PREFIX.trim())}.`] + : []), + draftExplanation, + ...(checklistRequired && !checklistComplete + ? [ + `@${pr.user.login} Tick the boxes once your local CI is green, your branch is on the latest ${inlineCode(DEFAULT_BASE)} commit, and every correct Codex and CodeRabbit finding is resolved.` + ] + : []) + ]; + + await upsertGateComment(state, { + status: "DRAFT", + statusReason: failures + .map(failure => { + if (failure.code === "wrong_base") { + return `wrong target branch (${pr.base.ref}); retarget to ${inlineCode(DEFAULT_BASE)}.`; + } + if (failure.code === "wrong_ancestry") { + return "wrong branch ancestry; rebase onto the latest dev."; + } + if (failure.code === "bad_description") { + return `PR description needs work (${failure.reason}).`; + } + if (failure.code === "missing_ui_screenshot") { + return "UI screenshot required."; + } + return failure.code; + }) + .join(" "), + actions: buildActions(), + readiness, + checklistRequired, + notices + }); + core.setFailed( `PR quality gate failed: ${failureSummary(failures, { pr })}` ); @@ -747,32 +870,15 @@ jobs: } // No quality failure; the draft is owed by the open checklist. - // Prior enforcer history (title prefix, earlier failures) gets a - // closing confirmation; the readiness comment owns the draft now. - if (storedState?.active || botComment) { - const prefixResult = shouldStripTitlePrefix - ? `The ${inlineCode(TITLE_PREFIX.trim())} title prefix has been removed.` - : "The title was left unchanged."; - await upsertComment( - [ - COMMENT_MARKER, - stateMarker(clearedEnforcerState()), - "", - "✅ **PR quality gates passed**", - "", - `This pull request now targets ${inlineCode(pr.base.ref)} with acceptable ancestry, description, and UI screenshot coverage. It stays in draft until the review readiness checklist is complete.`, - "", - `${prefixResult} The draft is owned by the checklist message below.` - ].join("\n") - ); - } - if (!pr.draft && !draftConverted) { + // Claim draft ownership before the mutation so a successful + // convert followed by a failed comment still restores later. + state.autoDraftedByBot = true; try { await convertToDraft(); draftConverted = true; } catch (error) { - readinessState.autoDraftedByBot = false; + state.autoDraftedByBot = false; core.warning( `Could not convert pull request to draft: ${error.message}` ); @@ -782,53 +888,29 @@ jobs: } } - await upsertReadinessComment( - readinessState, - readiness, - [ - ...revalidationNotice, - pr.draft || draftConverted - ? "This PR stays in draft until every box above is ticked." - : "Automatic draft conversion failed. Please convert this pull request to a draft manually until every box above is ticked." - ] - ); - return; - } - - if (!storedState?.active && !checklistRequired) { - // A maintainer PR drafted while the permission lookup was failing - // (fail-closed) gets restored once the lookup recovers. - if (storedReadinessState?.autoDraftedByBot && pr.draft) { - let recoveryFailed = false; - try { - await markReadyForReview(); - } catch (error) { - recoveryFailed = true; - core.warning( - `Could not mark pull request ready for review: ${error.message}` - ); - } - await upsertReadinessComment( - recoveryFailed - ? { ...storedReadinessState } - : { ...storedReadinessState, autoDraftedByBot: false }, - readiness, - [ - recoveryFailed - ? "Automatic ready-for-review conversion failed; the PR stays a draft and will be retried on the next run." - : "✅ This PR is ready for review." - ] - ); - } - core.info( - "All PR quality gates passed and there is no active bot state." - ); + const notices = [ + ...revalidationNotice, + pr.draft || draftConverted + ? "This PR stays in draft until every box above is ticked." + : "Automatic draft conversion failed. Please convert this pull request to a draft manually until every box above is ticked." + ]; + await upsertGateComment(state, { + status: "DRAFT", + statusReason: checklistRequired + ? `review readiness checklist open (${readiness.checked}/${readiness.total} boxes ticked).` + : "PR is kept in draft.", + actions: buildActions(), + readiness, + checklistRequired, + notices + }); return; } + // Ready path: strip a stale title prefix, mark ready, clear state. if ( - storedState?.titlePrefixedByBot && + gateState.titlePrefixedByBot && pr.title.startsWith(TITLE_PREFIX) ) { await github.rest.pulls.update({ @@ -837,12 +919,13 @@ jobs: pull_number, title: pr.title.slice(TITLE_PREFIX.length) }); + gateState.titlePrefixedByBot = false; } let readyConversionFailed = false; let readyConverted = false; const shouldMarkReady = - (storedState?.active && storedState.autoDraftedByBot) || + (gateState.active && gateState.autoDraftedByBot) || (checklistRequired && checklistComplete); if (shouldMarkReady && pr.draft) { try { @@ -856,82 +939,98 @@ jobs: } } - // The enforcer comment only exists when there was something to say; - // a clean contributor PR that never failed a quality gate has none. - if (storedState?.active || botComment) { - const completedState = readyConversionFailed - ? { ...clearedEnforcerState(), active: true, autoDraftedByBot: true } - : clearedEnforcerState(); - - const titleResult = storedState?.titlePrefixedByBot - ? `The ${inlineCode(TITLE_PREFIX.trim())} title prefix has been removed.` - : "The title was left unchanged."; - - const draftResult = readyConversionFailed - ? "Automatic ready-for-review conversion failed; please mark the pull request ready manually if it is still a draft." - : storedState?.autoDraftedByBot - ? "The pull request has been marked ready for review again." - : "Its existing draft status has been preserved."; - - await upsertComment( - [ - COMMENT_MARKER, - stateMarker(completedState), - "", - "✅ **PR quality gates passed**", - "", - `This pull request now targets ${inlineCode(pr.base.ref)} with acceptable ancestry, description, and UI screenshot coverage.${ - checklistRequired - ? " The review readiness checklist is complete." - : "" - }`, - "", - `${titleResult} ${draftResult}` - ].join("\n") - ); - } - - // Checklist completion lifts the contributor draft and pings the - // maintainers from `MAINTAINERS.md` (minus the PR author). + const readyState = { + ...gateState, + active: readyConversionFailed ? true : false + }; if (checklistRequired && checklistComplete) { - const readinessState = storedReadinessState - ? { ...storedReadinessState } - : defaultReadinessState(); const maintainers = readMaintainerLogins().filter( login => login !== pr.user.login ); let notified = false; - if (!readinessState.maintainersPinged && maintainers.length > 0) { - readinessState.maintainersPinged = true; + if (!readyState.maintainersPinged && maintainers.length > 0) { + readyState.maintainersPinged = true; notified = true; } + readyState.completedAtHeadSha = pr.head.sha; + readyState.version = 1; - // Bind the completion to the exact head it attested. A later - // `synchronize` event with a different head resets the checklist - // and the notification state (see `headDrifted` above). - readinessState.completedAtHeadSha = pr.head.sha; - readinessState.version = READINESS_STATE_VERSION; - - await upsertReadinessComment( - readinessState, - readiness, - [ - "✅ **All four boxes are ticked.**", - `Completed against head ${inlineCode(pr.head.sha.slice(0, 7))}; new commits after this will reset the checklist.`, - readyConverted + const notices = [ + readyConversionFailed + ? "Automatic ready-for-review conversion failed; please mark the pull request ready manually if it is still a draft." + : readyConverted ? "This pull request has been marked Ready for Review." - : pr.draft - ? "Automatic ready-for-review conversion failed; please mark the pull request ready manually." - : "This pull request is already Ready for Review.", - notified && maintainers.length > 0 - ? `Maintainers notified: ${maintainers + : "This pull request is already Ready for Review.", + readyMoment + ? `CodeRabbit/Codex review was requested via the ${inlineCode(REVIEW_READY_LABEL)} label. If no review appears, comment ${inlineCode("@coderabbitai review")} to request one.` + : "", + notified && maintainers.length > 0 + ? `Maintainers notified: ${maintainers + .map(login => `@${login}`) + .join(" ")}` + : maintainers.length > 0 + ? `Maintainers: ${maintainers .map(login => `@${login}`) .join(" ")}` - : maintainers.length > 0 - ? `Maintainers: ${maintainers - .map(login => `@${login}`) - .join(" ")}` - : "Maintainers will be notified." - ] + : "Maintainers will be notified." + ].filter(Boolean); + + await upsertGateComment(readyState, { + status: "READY", + statusReason: "all PR quality gates passed; the review readiness checklist is complete.", + actions: [], + readiness, + checklistRequired, + notices + }); + return; + } + + // Maintainer PR with no checklist: the comment is the single status + // surface but there is nothing to tick; only render it if the + // author is a maintainer and no checklist is required. + if (!checklistRequired) { + if (gateState.autoDraftedByBot && pr.draft) { + let recoveryFailed = false; + try { + await markReadyForReview(); + } catch (error) { + recoveryFailed = true; + core.warning( + `Could not mark pull request ready for review: ${error.message}` + ); + } + const recoveredState = { + ...gateState, + autoDraftedByBot: recoveryFailed + }; + await upsertGateComment(recoveredState, { + status: "READY", + statusReason: recoveryFailed + ? "ready-for-review conversion failed; will retry on the next run." + : "this PR is ready for review.", + actions: [], + readiness, + checklistRequired, + notices: [] + }); + return; + } + core.info( + "All PR quality gates passed and there is no active bot state." ); + return; + } + + // Fallback for a clean contributor PR whose checklist is complete + // but which already left the mustDraft branch above (defensive). + if (checklistRequired && checklistComplete) { + await upsertGateComment(readyState, { + status: "READY", + statusReason: "all PR quality gates passed; the review readiness checklist is complete.", + actions: [], + readiness, + checklistRequired, + notices: [] + }); } diff --git a/tests/ci-workflows.test.ts b/tests/ci-workflows.test.ts index fea3ec40ae..44dac748e1 100644 --- a/tests/ci-workflows.test.ts +++ b/tests/ci-workflows.test.ts @@ -8,38 +8,25 @@ import { type HarnessResult, } from "./helpers/enforce-pr-target-harness"; -/** Final enforcer comment body after pending/draft checkpoints. */ -function lastEnforcerCommentBody(result: HarnessResult): string { - const marker = ""; - const legacyMarker = ""; - const updates = (callsTo(result, "issues.updateComment") as Array<{ body: string }>) - .filter(call => call.body.includes(marker) || call.body.includes(legacyMarker)); - if (updates.length > 0) return updates[updates.length - 1]!.body; - const creates = callsTo(result, "issues.createComment") as Array<{ body: string }>; - const enforcerCreates = creates.filter( - call => call.body.includes(marker) || call.body.includes(legacyMarker), - ); - const chosen = enforcerCreates.length > 0 ? enforcerCreates : creates; - if (chosen.length === 0) { - throw new Error("scenario recorded no enforcer comment"); - } - return chosen[chosen.length - 1]!.body; -} - -/** Final review-readiness comment body (the checklist message). */ -function lastReadinessCommentBody(result: HarnessResult): string { - const marker = ""; +/** Final consolidated gate comment body (the single bot message). */ +function lastGateCommentBody(result: HarnessResult): string { + const marker = ""; const updates = (callsTo(result, "issues.updateComment") as Array<{ body: string }>) .filter(call => call.body.includes(marker)); if (updates.length > 0) return updates[updates.length - 1]!.body; const creates = callsTo(result, "issues.createComment") as Array<{ body: string }>; - const readinessCreates = creates.filter(call => call.body.includes(marker)); - if (readinessCreates.length === 0) { - throw new Error("scenario recorded no readiness comment"); + const gateCreates = creates.filter(call => call.body.includes(marker)); + if (gateCreates.length === 0) { + throw new Error("scenario recorded no gate comment"); } - return readinessCreates[readinessCreates.length - 1]!.body; + return gateCreates[gateCreates.length - 1]!.body; } +/** The single consolidated comment body; alias kept for scenario readability. */ +const lastReadinessCommentBody = lastGateCommentBody; +/** Alias kept for scenarios that named the pre-consolidation enforcer comment. */ +const lastEnforcerCommentBody = lastGateCommentBody; + const root = new URL("../", import.meta.url); const doctorGuiIfChangedScript = fileURLToPath(new URL("../scripts/doctor-gui-if-changed.ts", import.meta.url)); @@ -951,6 +938,8 @@ describe("GitHub Actions hardening", () => { expect([...types].sort()).toEqual([ "edited", "opened", + "pull_request_review", + "pull_request_review_comment", "ready_for_review", "reopened", "synchronize", @@ -1037,10 +1026,10 @@ describe("GitHub Actions hardening", () => { } // Seven `pulls.update` sites: the maintainer checklist retirement, the - // checklist injection, the head-drift reset, and the claim-check uncheck - // (body only), plus the prefix add, the stale-prefix strip, and the - // restore-half strip. `base` and `state` are accepted by this endpoint - // and none of them belong anywhere here. + // checklist injection, the head-drift reset, the claim-check uncheck + // (body only), the wrong-base prefix add, and the two stale-prefix strips + // (draft path and ready path). `base` and `state` are accepted by this + // endpoint and none of them belong anywhere here. expect(callArgs("github.rest.pulls.update")).toEqual([ ["body", "owner", "pull_number", "repo"], ["body", "owner", "pull_number", "repo"], @@ -1051,15 +1040,13 @@ describe("GitHub Actions hardening", () => { ["owner", "pull_number", "repo", "title"], ]); - // Both comment families (quality enforcer + readiness checklist) address - // the PR being enforced, by its own number. + // The single consolidated comment addresses the PR being enforced, by its + // own number. expect(callArgs("github.rest.issues.createComment")).toEqual([ ["body", "issue_number", "owner", "repo"], - ["body", "issue_number", "owner", "repo"], ]); expect(callArgs("github.rest.issues.updateComment")).toEqual([ ["body", "comment_id", "owner", "repo"], - ["body", "comment_id", "owner", "repo"], ]); // …and the number is `pull_number`, not a literal. `issue_number: 1` has the @@ -1067,8 +1054,8 @@ describe("GitHub Actions hardening", () => { expect(script).toMatch(/issue_number:\s*pull_number\b/); expect(script).not.toMatch(/issue_number:\s*\d/); - // These are the only three mutating REST calls. A fourth is a new write - // nobody reviewed. `pulls.list` is a stacked-base read, not a write. + // These are the only mutating REST calls. A new one is a write nobody + // reviewed. `pulls.list` and `pulls.listReviews` are reads, not writes. const restWrites = [...script.matchAll(/github\.rest\.[\w.]+/g)] .map(match => match[0]) .filter( @@ -1076,13 +1063,17 @@ describe("GitHub Actions hardening", () => { !name.endsWith(".get") && !name.endsWith(".list") && !name.endsWith(".listComments") && + name !== "github.rest.pulls.listReviews" && name !== "github.rest.repos.getCollaboratorPermissionLevel" && name !== "github.rest.repos.compareCommitsWithBasehead" && // The claim check reads check-runs; it must never count as a write. name !== "github.rest.checks.listForRef", ); expect([...new Set(restWrites)].sort()).toEqual([ + "github.rest.issues.addLabels", "github.rest.issues.createComment", + "github.rest.issues.deleteComment", + "github.rest.issues.removeLabel", "github.rest.issues.updateComment", "github.rest.pulls.update", ]); @@ -1130,13 +1121,14 @@ describe("GitHub Actions hardening", () => { const BOT = "github-actions[bot]"; const MARKER = ""; const LEGACY_MARKER = ""; + const GATE_MARKER = ""; const READINESS_MARKER = ""; const CHECKLIST_START = ""; const CHECKLIST_END = ""; const CHECKLIST_ITEMS = [ "All CI tests are green on my local testing.", "I pushed my PR to the latest dev commit.", - "I fixed all correct Codex and CodeRabbit findings.", + "I resolved all correct Codex and CodeRabbit findings.", "My PR is ready for review.", ]; const CONTRIBUTOR_BODY = [ @@ -1197,29 +1189,25 @@ describe("GitHub Actions hardening", () => { /** * The writes a fresh contributor PR triggers on `dev` with no quality - * failures: inject the checklist, then draft it with the checklist message. + * failures: inject the checklist, convert to draft, then write the single + * consolidated comment. */ const CONTRIBUTOR_CLEAN_TAIL = [ "pulls.update", - "issues.createComment", "graphql", - "issues.updateComment", + "issues.createComment", ]; /** * The writes a fresh wrong-base contributor PR triggers: inject the - * checklist, then the existing enforcer sequence plus the checklist message. + * checklist, then the title-prefix + draft conversion plus the single + * consolidated comment. */ const CONTRIBUTOR_WRONG_BASE_TAIL = [ "pulls.update", - "issues.createComment", - "issues.createComment", "pulls.update", - "issues.updateComment", "graphql", - "issues.updateComment", - "issues.updateComment", - "issues.updateComment", + "issues.createComment", ]; function botComment(state: Record, title = "Add a thing") { @@ -1265,7 +1253,7 @@ describe("GitHub Actions hardening", () => { const readinessBody = lastReadinessCommentBody(result); expect(readinessBody).toContain("**0/4** boxes ticked"); - expect(readinessBody).toContain(READINESS_MARKER); + expect(readinessBody).toContain(GATE_MARKER); expect(readinessBody).toContain('"maintainersPinged":false'); }); @@ -1277,20 +1265,17 @@ describe("GitHub Actions hardening", () => { const { script } = await readEnforcePrTarget(); const result = await runEnforcePrTarget(script, { pr: { base: { ref: "dev" }, draft: false }, - failOn: ["graphql"], + failGraphqlOn: ["convertPullRequestToDraft"], }); expect(methodsOf(result)).toEqual(readsAllowedBase([ "pulls.update", - "issues.createComment", "graphql", - "issues.updateComment", + "issues.createComment", ])); - // Ownership is checkpointed before the mutation (pending claim), then - // cleared when the conversion fails, so a later permission recovery - // cannot leave the bot-created draft in place forever. - const [pending] = callsTo(result, "issues.createComment") as [{ body: string }]; - expect(pending.body).toContain('"autoDraftedByBot":true'); + // Only a successful conversion records autoDraftedByBot; a failed one + // clears it so a later permission recovery cannot leave the bot-created + // draft in place forever. expect(lastReadinessCommentBody(result)).toContain('"autoDraftedByBot":false'); expect(lastReadinessCommentBody(result)).toContain( "Automatic draft conversion failed", @@ -1318,10 +1303,15 @@ describe("GitHub Actions hardening", () => { expect(methodsOf(result)).toEqual(readsAllowedBase([ "checks.listForRef", "graphql", + "pulls.listReviews", + "issues.addLabels", + "graphql", "issues.createComment", ])); - const [ready] = callsTo(result, "graphql") as [{ query: string }]; - expect(ready.query).toContain("markPullRequestReadyForReview"); + const drafts = callsTo(result, "graphql") as [{ query: string }]; + expect(drafts).toHaveLength(2); + expect(drafts[0]!.query).toContain("reviewThreads"); + expect(drafts[1]!.query).toContain("markPullRequestReadyForReview"); const readinessBody = lastReadinessCommentBody(result); expect(readinessBody).toContain("**4/4** boxes ticked"); expect(readinessBody).toContain("Maintainers notified: @lidge-jun @Ingwannu @Wibias"); @@ -1351,7 +1341,11 @@ describe("GitHub Actions hardening", () => { expect(methodsOf(result)).toEqual(readsAllowedBase([ "checks.listForRef", "graphql", - "issues.updateComment", + "pulls.listReviews", + "issues.addLabels", + "graphql", + "issues.createComment", + "issues.deleteComment", ])); const readinessBody = lastReadinessCommentBody(result); // The completion is bound to the exact head that was reviewed. @@ -1359,8 +1353,8 @@ describe("GitHub Actions hardening", () => { '"completedAtHeadSha":"3f1c0de0a6a4d0a3f9a1b2c3d4e5f60718293a4b"', ); // A migrated v1 state is rewritten at the current version. - expect(readinessBody).toContain('"version":2'); - expect(readinessBody).toContain("Completed against head `3f1c0de`"); + expect(readinessBody).toContain('"version":1'); + expect(readinessBody).toContain("**4/4** boxes ticked"); // Already pinged before the upgrade: no second notification. expect(readinessBody).toContain('"maintainersPinged":true'); expect(readinessBody).not.toContain("Maintainers notified"); @@ -1390,9 +1384,9 @@ describe("GitHub Actions hardening", () => { expect(methodsOf(result)).toEqual(readsAllowedBase([ "pulls.get", "pulls.update", - "issues.updateComment", "graphql", - "issues.updateComment", + "issues.createComment", + "issues.deleteComment", ])); const [resetBody] = callsTo(result, "pulls.update") as [{ body: string }]; expect(resetBody.body).toContain(CHECKLIST_START); @@ -1439,10 +1433,18 @@ describe("GitHub Actions hardening", () => { expect(methodsOf(result)).toEqual(readsAllowedBase([ "checks.listForRef", - "issues.updateComment", + "graphql", + "pulls.listReviews", + "issues.addLabels", + "issues.createComment", + "issues.deleteComment", ])); expect(callsTo(result, "pulls.update")).toEqual([]); - expect(callsTo(result, "graphql")).toEqual([]); + // The only GraphQL call is the review-threads read; the completion is + // already bound and green, so no mutation fires. + const drafts = callsTo(result, "graphql") as [{ query: string }]; + expect(drafts).toHaveLength(1); + expect(drafts[0]!.query).toContain("reviewThreads"); const readinessBody = lastReadinessCommentBody(result); expect(readinessBody).toContain("**4/4** boxes ticked"); expect(readinessBody).toContain( @@ -1475,16 +1477,12 @@ describe("GitHub Actions hardening", () => { expect(methodsOf(result)).toEqual(readsWrongBase([ "pulls.get", "pulls.update", - "issues.updateComment", - "issues.createComment", "pulls.update", - "issues.updateComment", "graphql", - "issues.updateComment", - "issues.updateComment", - "issues.updateComment", + "issues.createComment", + "issues.deleteComment", ])); - expect(lastEnforcerCommentBody(result)).toContain("Wrong target branch"); + expect(lastEnforcerCommentBody(result)).toContain("wrong target branch"); expect(lastEnforcerCommentBody(result)).toContain("[WRONG BRANCH]"); expect(result.warnings.some(w => w.startsWith("setFailed:"))).toBe(true); @@ -1518,9 +1516,8 @@ describe("GitHub Actions hardening", () => { expect(methodsOf(result)).toEqual(readsAllowedBase([ "pulls.get", "pulls.update", - "issues.createComment", "graphql", - "issues.updateComment", + "issues.createComment", ])); const drafts = callsTo(result, "graphql") as [{ query: string }]; expect(drafts).toHaveLength(1); @@ -1554,9 +1551,8 @@ describe("GitHub Actions hardening", () => { expect(methodsOf(result)).toEqual(readsAllowedBase([ "pulls.get", "pulls.update", - "issues.createComment", "graphql", - "issues.updateComment", + "issues.createComment", ])); const drafts = callsTo(result, "graphql") as [{ query: string }]; expect(drafts).toHaveLength(1); @@ -1589,11 +1585,12 @@ describe("GitHub Actions hardening", () => { expect(methodsOf(result)).toEqual(readsAllowedBase([ "checks.listForRef", + "graphql", + "pulls.listReviews", "pulls.get", "pulls.update", - "issues.createComment", "graphql", - "issues.updateComment", + "issues.createComment", ])); const [bodyUpdate] = callsTo(result, "pulls.update") as [{ body: string }]; // Only the CI box is unticked; the other three stay checked. @@ -1601,9 +1598,10 @@ describe("GitHub Actions hardening", () => { expect(bodyUpdate.body).toContain("- [x] I pushed my PR to the latest dev commit."); expect(bodyUpdate.body).toContain("- [x] My PR is ready for review."); const drafts = callsTo(result, "graphql") as [{ query: string }]; - expect(drafts).toHaveLength(1); - expect(drafts[0]!.query).toContain("convertPullRequestToDraft"); - expect(drafts[0]!.query).not.toContain("markPullRequestReadyForReview"); + expect(drafts).toHaveLength(2); + expect(drafts[0]!.query).toContain("reviewThreads"); + expect(drafts[1]!.query).toContain("convertPullRequestToDraft"); + expect(drafts[1]!.query).not.toContain("markPullRequestReadyForReview"); const readinessBody = lastReadinessCommentBody(result); expect(readinessBody).toContain( "GitHub CI is not green on the current head `3f1c0de`; the **CI green** box has been unticked.", @@ -1629,11 +1627,12 @@ describe("GitHub Actions hardening", () => { expect(methodsOf(result)).toEqual(readsAllowedBase([ "checks.listForRef", + "graphql", + "pulls.listReviews", "pulls.get", "pulls.update", - "issues.createComment", "graphql", - "issues.updateComment", + "issues.createComment", ])); const [bodyUpdate] = callsTo(result, "pulls.update") as [{ body: string }]; // Only the latest-dev box is unticked; CI stays checked. @@ -1641,8 +1640,9 @@ describe("GitHub Actions hardening", () => { expect(bodyUpdate.body).toContain("- [ ] I pushed my PR to the latest dev commit."); expect(bodyUpdate.body).toContain("- [x] My PR is ready for review."); const drafts = callsTo(result, "graphql") as [{ query: string }]; - expect(drafts).toHaveLength(1); - expect(drafts[0]!.query).toContain("convertPullRequestToDraft"); + expect(drafts).toHaveLength(2); + expect(drafts[0]!.query).toContain("reviewThreads"); + expect(drafts[1]!.query).toContain("convertPullRequestToDraft"); const readinessBody = lastReadinessCommentBody(result); expect(readinessBody).toContain( "The PR is more than 10 commits behind `dev`; the **latest dev** box has been unticked.", @@ -1667,11 +1667,12 @@ describe("GitHub Actions hardening", () => { expect(methodsOf(result)).toEqual(readsAllowedBase([ "checks.listForRef", + "graphql", + "pulls.listReviews", "pulls.get", "pulls.update", - "issues.createComment", "graphql", - "issues.updateComment", + "issues.createComment", ])); const [bodyUpdate] = callsTo(result, "pulls.update") as [{ body: string }]; expect(bodyUpdate.body).toContain("- [ ] All CI tests are green on my local testing."); @@ -1699,11 +1700,12 @@ describe("GitHub Actions hardening", () => { expect(methodsOf(result)).toEqual(readsAllowedBase([ "checks.listForRef", + "graphql", + "pulls.listReviews", "pulls.get", "pulls.update", - "issues.createComment", "graphql", - "issues.updateComment", + "issues.createComment", ])); const [bodyUpdate] = callsTo(result, "pulls.update") as [{ body: string }]; expect(bodyUpdate.body).toContain("- [ ] All CI tests are green on my local testing."); @@ -1730,12 +1732,16 @@ describe("GitHub Actions hardening", () => { expect(methodsOf(result)).toEqual(readsAllowedBase([ "checks.listForRef", "graphql", + "pulls.listReviews", + "issues.addLabels", + "graphql", "issues.createComment", ])); expect(callsTo(result, "pulls.update")).toEqual([]); const drafts = callsTo(result, "graphql") as [{ query: string }]; - expect(drafts).toHaveLength(1); - expect(drafts[0]!.query).toContain("markPullRequestReadyForReview"); + expect(drafts).toHaveLength(2); + expect(drafts[0]!.query).toContain("reviewThreads"); + expect(drafts[1]!.query).toContain("markPullRequestReadyForReview"); }); test("a head with no ci check at all keeps the CI box (docs-only style PRs)", async () => { @@ -1754,11 +1760,16 @@ describe("GitHub Actions hardening", () => { expect(methodsOf(result)).toEqual(readsAllowedBase([ "checks.listForRef", "graphql", + "pulls.listReviews", + "issues.addLabels", + "graphql", "issues.createComment", ])); expect(callsTo(result, "pulls.update")).toEqual([]); const drafts = callsTo(result, "graphql") as [{ query: string }]; - expect(drafts[0]!.query).toContain("markPullRequestReadyForReview"); + expect(drafts).toHaveLength(2); + expect(drafts[0]!.query).toContain("reviewThreads"); + expect(drafts[1]!.query).toContain("markPullRequestReadyForReview"); }); test("a pending ci check cannot attest green", async () => { @@ -1774,11 +1785,12 @@ describe("GitHub Actions hardening", () => { expect(methodsOf(result)).toEqual(readsAllowedBase([ "checks.listForRef", + "graphql", + "pulls.listReviews", "pulls.get", "pulls.update", - "issues.createComment", "graphql", - "issues.updateComment", + "issues.createComment", ])); const [bodyUpdate] = callsTo(result, "pulls.update") as [{ body: string }]; expect(bodyUpdate.body).toContain("- [ ] All CI tests are green on my local testing."); @@ -1786,6 +1798,230 @@ describe("GitHub Actions hardening", () => { expect(readinessBody).toContain("GitHub CI is not green on the current head"); }); + test("an unresolved Codex thread unchecks the findings box and re-drafts", async () => { + const result = await run({ + pr: { + base: { ref: "dev" }, + draft: false, + body: readinessChecklistBody(4), + }, + maintainersFile: MAINTAINERS_FIXTURE, + reviewThreads: [ + { isResolved: false, author: { login: "chatgpt-codex-connector[bot]" } }, + ], + }); + + expect(methodsOf(result)).toEqual(readsAllowedBase([ + "checks.listForRef", + "graphql", + "pulls.listReviews", + "pulls.get", + "pulls.update", + "graphql", + "issues.createComment", + ])); + const [bodyUpdate] = callsTo(result, "pulls.update") as [{ body: string }]; + // Only the findings box is unticked; CI and latest-dev stay checked. + expect(bodyUpdate.body).toContain("- [x] All CI tests are green on my local testing."); + expect(bodyUpdate.body).toContain("- [x] I pushed my PR to the latest dev commit."); + expect(bodyUpdate.body).toContain("- [ ] I resolved all correct Codex and CodeRabbit findings."); + expect(bodyUpdate.body).toContain("- [x] My PR is ready for review."); + const drafts = callsTo(result, "graphql") as [{ query: string }]; + expect(drafts).toHaveLength(2); + expect(drafts[0]!.query).toContain("reviewThreads"); + expect(drafts[1]!.query).toContain("convertPullRequestToDraft"); + const readinessBody = lastReadinessCommentBody(result); + expect(readinessBody).toContain( + "Codex has 1 unresolved finding; the **Codex/CodeRabbit findings** box has been unticked.", + ); + expect(readinessBody).toContain("**3/4** boxes ticked"); + expect(readinessBody).toContain('"completedAtHeadSha":null'); + expect(result.warnings.some(w => w.startsWith("setFailed:"))).toBe(false); + }); + + test("an unresolved CodeRabbit thread unchecks the findings box and re-drafts", async () => { + const result = await run({ + pr: { + base: { ref: "dev" }, + draft: false, + body: readinessChecklistBody(4), + }, + maintainersFile: MAINTAINERS_FIXTURE, + reviewThreads: [ + { isResolved: false, author: { login: "coderabbitai[bot]" } }, + ], + }); + + const [bodyUpdate] = callsTo(result, "pulls.update") as [{ body: string }]; + expect(bodyUpdate.body).toContain("- [ ] I resolved all correct Codex and CodeRabbit findings."); + expect(bodyUpdate.body).toContain("- [x] My PR is ready for review."); + const readinessBody = lastReadinessCommentBody(result); + expect(readinessBody).toContain( + "CodeRabbit has 1 unresolved finding; the **Codex/CodeRabbit findings** box has been unticked.", + ); + }); + + test("all bot threads resolved keeps the findings box and marks ready", async () => { + const result = await run({ + pr: { + base: { ref: "dev" }, + draft: true, + body: readinessChecklistBody(4), + }, + maintainersFile: MAINTAINERS_FIXTURE, + reviewThreads: [ + { isResolved: true, author: { login: "chatgpt-codex-connector[bot]" } }, + { isResolved: true, author: { login: "coderabbitai[bot]" } }, + ], + }); + + expect(methodsOf(result)).toEqual(readsAllowedBase([ + "checks.listForRef", + "graphql", + "pulls.listReviews", + "issues.addLabels", + "graphql", + "issues.createComment", + ])); + expect(callsTo(result, "pulls.update")).toEqual([]); + const drafts = callsTo(result, "graphql") as [{ query: string }]; + expect(drafts).toHaveLength(2); + expect(drafts[0]!.query).toContain("reviewThreads"); + expect(drafts[1]!.query).toContain("markPullRequestReadyForReview"); + const readinessBody = lastReadinessCommentBody(result); + expect(readinessBody).toContain("**4/4** boxes ticked"); + }); + + test("CodeRabbit outside-diff findings untick the box even with clean threads", async () => { + // CodeRabbit posts some findings only in its review body ("outside the + // diff range"), which never become review threads. The supplement reads + // `pulls.listReviews` for a live-head CodeRabbit review reporting + // actionable comments and treats it as an unresolved finding. + const result = await run({ + pr: { + base: { ref: "dev" }, + draft: false, + body: readinessChecklistBody(4), + }, + maintainersFile: MAINTAINERS_FIXTURE, + reviews: [ + { + body: "**Actionable comments posted: 2**\n\nWalkthrough.", + commit_id: "3f1c0de0a6a4d0a3f9a1b2c3d4e5f60718293a4b", + submitted_at: "2026-08-04T06:24:02Z", + }, + ], + }); + + expect(methodsOf(result)).toEqual(readsAllowedBase([ + "checks.listForRef", + "graphql", + "pulls.listReviews", + "pulls.get", + "pulls.update", + "graphql", + "issues.createComment", + ])); + const [bodyUpdate] = callsTo(result, "pulls.update") as [{ body: string }]; + expect(bodyUpdate.body).toContain("- [ ] I resolved all correct Codex and CodeRabbit findings."); + expect(bodyUpdate.body).toContain("- [x] My PR is ready for review."); + const readinessBody = lastReadinessCommentBody(result); + expect(readinessBody).toContain( + "CodeRabbit has 2 unresolved findings; the **Codex/CodeRabbit findings** box has been unticked.", + ); + expect(readinessBody).toContain("**3/4** boxes ticked"); + expect(result.warnings.some(w => w.startsWith("setFailed:"))).toBe(false); + }); + + test("a CodeRabbit outside-diff review of a stale head does not untick the box", async () => { + // The supplement is head-bound: a review of a superseded commit cannot + // flag the current head. Clean threads + a stale review stay green. + const result = await run({ + pr: { + base: { ref: "dev" }, + draft: true, + body: readinessChecklistBody(4), + }, + maintainersFile: MAINTAINERS_FIXTURE, + reviews: [ + { + body: "**Actionable comments posted: 2**", + commit_id: "1111111111111111111111111111111111111111", + submitted_at: "2026-08-04T06:24:02Z", + }, + ], + }); + + expect(methodsOf(result)).toEqual(readsAllowedBase([ + "checks.listForRef", + "graphql", + "pulls.listReviews", + "issues.addLabels", + "graphql", + "issues.createComment", + ])); + expect(callsTo(result, "pulls.update")).toEqual([]); + const drafts = callsTo(result, "graphql") as [{ query: string }]; + expect(drafts).toHaveLength(2); + expect(drafts[0]!.query).toContain("reviewThreads"); + expect(drafts[1]!.query).toContain("markPullRequestReadyForReview"); + expect(lastReadinessCommentBody(result)).toContain("**4/4** boxes ticked"); + }); + + test("an unresolved human review thread does not untick the findings box", async () => { + const result = await run({ + pr: { + base: { ref: "dev" }, + draft: true, + body: readinessChecklistBody(4), + }, + maintainersFile: MAINTAINERS_FIXTURE, + reviewThreads: [ + { isResolved: false, author: { login: "wibias" } }, + ], + }); + + expect(methodsOf(result)).toEqual(readsAllowedBase([ + "checks.listForRef", + "graphql", + "pulls.listReviews", + "issues.addLabels", + "graphql", + "issues.createComment", + ])); + expect(callsTo(result, "pulls.update")).toEqual([]); + const drafts = callsTo(result, "graphql") as [{ query: string }]; + expect(drafts).toHaveLength(2); + expect(drafts[1]!.query).toContain("markPullRequestReadyForReview"); + expect(lastReadinessCommentBody(result)).toContain("**4/4** boxes ticked"); + }); + + test("a review-threads lookup failure fails closed for the findings claim", async () => { + const result = await run({ + pr: { + base: { ref: "dev" }, + draft: false, + body: readinessChecklistBody(4), + }, + maintainersFile: MAINTAINERS_FIXTURE, + // Only the review-threads read fails; the draft conversion must stay + // green so the assert below is about the findings claim, not a + // mutation failure. + failGraphqlOn: ["reviewThreads"], + }); + + // The threads read fails closed: the findings box is unticked even + // though no thread data was readable, and the PR stays a draft. + const [bodyUpdate] = callsTo(result, "pulls.update") as [{ body: string }]; + expect(bodyUpdate.body).toContain("- [ ] I resolved all correct Codex and CodeRabbit findings."); + const readinessBody = lastReadinessCommentBody(result); + expect(readinessBody).toContain( + "The Codex/CodeRabbit findings claim could not be verified", + ); + expect(result.warnings.some(w => w.includes("Could not list review threads for the readiness claim check"))).toBe(true); + expect(result.warnings.some(w => w.startsWith("setFailed:"))).toBe(false); + }); + test("a completion recorded while quality gates fail still binds the head", async () => { // The mustDraft failure path returns before the completion block, so // without an explicit record the checklist would stay unbound while a @@ -1801,11 +2037,7 @@ describe("GitHub Actions hardening", () => { }); expect(methodsOf(result)).toEqual(readsAllowedBase([ - "issues.createComment", - "issues.updateComment", "graphql", - "issues.updateComment", - "issues.updateComment", "issues.createComment", ])); expect(result.warnings.some(w => w.startsWith("setFailed:"))).toBe(true); @@ -1813,9 +2045,10 @@ describe("GitHub Actions hardening", () => { expect(readinessBody).toContain( '"completedAtHeadSha":"3f1c0de0a6a4d0a3f9a1b2c3d4e5f60718293a4b"', ); - expect(readinessBody).toContain('"version":2'); + expect(readinessBody).toContain('"version":1'); + expect(readinessBody).toContain("**4/4** boxes ticked"); expect(readinessBody).toContain( - "**All four boxes are ticked.** This PR still stays in draft until the issues above are resolved.", + "This pull request is being kept as a draft automatically", ); }); @@ -1841,9 +2074,9 @@ describe("GitHub Actions hardening", () => { expect(methodsOf(result)).toEqual(readsAllowedBase([ "pulls.get", - "issues.updateComment", "graphql", - "issues.updateComment", + "issues.createComment", + "issues.deleteComment", ])); // No body rewrite: the boxes are already unticked from the failed reset. expect(callsTo(result, "pulls.update")).toEqual([]); @@ -1881,14 +2114,15 @@ describe("GitHub Actions hardening", () => { }); expect(methodsOf(result)).toEqual(readsAllowedBase([ - "issues.updateComment", + "issues.createComment", + "issues.deleteComment", ])); expect(callsTo(result, "pulls.update")).toEqual([]); expect(callsTo(result, "graphql")).toEqual([]); const readinessBody = lastReadinessCommentBody(result); // Ownership is preserved and no reset was performed or announced. expect(readinessBody).toContain('"autoDraftedByBot":true'); - expect(readinessBody).not.toContain('"completedAtHeadSha"'); + expect(readinessBody).toContain('"completedAtHeadSha":null'); expect(readinessBody).not.toContain("ticked before the current head"); expect(readinessBody).not.toContain("has been reset"); }); @@ -1981,9 +2215,8 @@ describe("GitHub Actions hardening", () => { }); expect(methodsOf(duringFailure)).toEqual(readsAllowedBase([ "pulls.update", - "issues.createComment", "graphql", - "issues.updateComment", + "issues.createComment", ])); expect(lastReadinessCommentBody(duringFailure)).toContain('"autoDraftedByBot":true'); @@ -2000,16 +2233,19 @@ describe("GitHub Actions hardening", () => { expect(methodsOf(recovered)).toEqual(readsAllowedBase([ "pulls.update", "graphql", - "issues.updateComment", + "issues.createComment", + "issues.deleteComment", ])); // The injected checklist is retired from the maintainer's body. const [stripped] = callsTo(recovered, "pulls.update") as [{ body: string }]; expect(stripped.body).not.toContain(CHECKLIST_START); - const [ready] = callsTo(recovered, "graphql") as [{ query: string }]; - expect(ready.query).toContain("markPullRequestReadyForReview"); + const drafts = callsTo(recovered, "graphql") as [{ query: string }]; + expect(drafts).toHaveLength(1); + expect(drafts[0]!.query).toContain("markPullRequestReadyForReview"); const readinessBody = lastReadinessCommentBody(recovered); - expect(readinessBody).toContain("not required for this author"); - expect(readinessBody).not.toContain("kept in **draft**"); + expect(readinessBody).toContain("## ✅ READY"); + expect(readinessBody).toContain("this PR is ready for review"); + expect(readinessBody).not.toContain("## Review readiness checklist"); expect(readinessBody).not.toContain("⬜"); expect(readinessBody).toContain('"autoDraftedByBot":false'); expect(recovered.warnings.some(w => w.startsWith("setFailed:"))).toBe(false); @@ -2036,14 +2272,15 @@ describe("GitHub Actions hardening", () => { expect(methodsOf(result)).toEqual(readsAllowedBase([ "pulls.update", "graphql", - "issues.updateComment", + "issues.createComment", + "issues.deleteComment", ])); expect(result.warnings.some(w => w.includes("Could not mark pull request ready for review"), )).toBe(true); const readinessBody = lastReadinessCommentBody(result); expect(readinessBody).toContain('"autoDraftedByBot":true'); - expect(readinessBody).toContain("will be retried on the next run"); + expect(readinessBody).toContain("will retry on the next run"); expect(readinessBody).not.toContain("✅ This PR is ready for review."); }); @@ -2068,14 +2305,19 @@ describe("GitHub Actions hardening", () => { expect(methodsOf(result)).toEqual(readsAllowedBase([ "checks.listForRef", "graphql", - "issues.updateComment", + "pulls.listReviews", + "issues.addLabels", + "graphql", "issues.createComment", + "issues.deleteComment", ])); - const [ready] = callsTo(result, "graphql") as [{ query: string }]; - expect(ready.query).toContain("markPullRequestReadyForReview"); - const [updated] = callsTo(result, "issues.updateComment") as [{ body: string }]; - expect(updated.body).toContain('"active":false'); - expect(updated.body).toContain("The title was left unchanged."); + const drafts = callsTo(result, "graphql") as [{ query: string }]; + expect(drafts).toHaveLength(2); + expect(drafts[0]!.query).toContain("reviewThreads"); + expect(drafts[1]!.query).toContain("markPullRequestReadyForReview"); + const readinessBody = lastReadinessCommentBody(result); + expect(readinessBody).toContain('"active":false'); + expect(readinessBody).toContain("all PR quality gates passed"); expect(result.warnings.join(" ")).toContain("Could not parse stored workflow state"); }); @@ -2090,14 +2332,12 @@ describe("GitHub Actions hardening", () => { }); expect(methodsOf(result)).toEqual(readsWrongBase([ - "issues.createComment", "pulls.update", - "issues.updateComment", "issues.createComment", ])); expect(callsTo(result, "graphql")).toEqual([]); - expect(lastEnforcerCommentBody(result)).toContain("Wrong target branch"); - expect(lastReadinessCommentBody(result)).toContain("All four boxes are ticked"); + expect(lastEnforcerCommentBody(result)).toContain("wrong target branch"); + expect(lastReadinessCommentBody(result)).toContain("**4/4** boxes ticked"); expect(result.warnings.some(w => w.startsWith("setFailed:"))).toBe(true); }); @@ -2110,12 +2350,9 @@ describe("GitHub Actions hardening", () => { // The maintainer contract is unchanged: draft on failure, explain, and // nothing else — no checklist injection, no readiness message. expect(methodsOf(result)).toEqual(readsWrongBase([ - "issues.createComment", "pulls.update", - "issues.updateComment", "graphql", - "issues.updateComment", - "issues.updateComment", + "issues.createComment", ])); expect(callsTo(result, "pulls.update")).toEqual([ { owner: "lidge-jun", repo: "opencodex", pull_number: 42, title: "[WRONG BRANCH] Add a thing" }, @@ -2140,21 +2377,16 @@ describe("GitHub Actions hardening", () => { }); // No wrong base, so no title write — the checklist injection is the only - // `pulls.update`, and the contributor flow adds the readiness message. + // `pulls.update`, and the contributor flow adds the single gate comment. expect(methodsOf(result)).toEqual(readsAllowedBase([ "pulls.update", - "issues.createComment", - "issues.createComment", - "issues.updateComment", "graphql", - "issues.updateComment", - "issues.updateComment", - "issues.updateComment", + "issues.createComment", ])); const commentBody = lastEnforcerCommentBody(result); - expect(commentBody).toContain("Wrong branch ancestry"); - expect(commentBody).not.toContain("Wrong target branch"); - expect(commentBody).toContain("Review readiness checklist"); + expect(commentBody).toContain("wrong branch ancestry"); + expect(commentBody).not.toContain("wrong target branch"); + expect(commentBody).toContain("## Review readiness checklist"); expect(result.warnings.some((w) => w.startsWith("setFailed:"))).toBe(true); expect(result.warnings.some((w) => w.includes("wrong ancestry"))).toBe(true); }); @@ -2174,8 +2406,8 @@ describe("GitHub Actions hardening", () => { const result = await run({ pr: { base: { ref: "dev" }, body: "" } }); expect(result.warnings.some((w) => w.startsWith("setFailed:"))).toBe(true); - expect(lastEnforcerCommentBody(result)).toContain("Pull request description"); - expect(lastEnforcerCommentBody(result)).toContain("body is empty"); + expect(lastEnforcerCommentBody(result)).toContain("PR description needs work"); + expect(lastEnforcerCommentBody(result)).toContain("(empty)"); // The bot also injects the checklist, so the draft conversion is the only // GraphQL mutation. expect(callsTo(result, "graphql")).toHaveLength(1); @@ -2217,6 +2449,99 @@ describe("GitHub Actions hardening", () => { expect(lastEnforcerCommentBody(result)).toContain("UI screenshot required"); }); + test("an OWNER comment waiving gui skips the screenshot gate", async () => { + const result = await run({ + pr: { + base: { ref: "dev" }, + title: "Fix dashboard spacing", + body: [ + "## Summary", + "This change adjusts gui/ spacing tokens used by the dashboard.", + "", + "## Test plan", + "- Ran bun test tests/ci-workflows.test.ts", + ].join("\n"), + }, + comments: [ + { id: 1, user: { login: "lidge-jun" }, author_association: "OWNER", body: "Not touching gui here." }, + ], + }); + + // The screenshot failure is gone: no setFailed for it, and the comment + // does not demand a screenshot. The contributor checklist still applies. + expect(result.warnings.some((w) => w.startsWith("setFailed:") && w.includes("screenshot"))).toBe(false); + expect(lastEnforcerCommentBody(result)).not.toContain("UI screenshot required"); + expect(lastEnforcerCommentBody(result)).toContain("UI screenshot waived by a maintainer comment"); + }); + + test("a COLLABORATOR comment saying no gui changes also waives it", async () => { + const result = await run({ + pr: { + base: { ref: "dev" }, + title: "GUI: fix provider list spacing", + body: [ + "## Summary", + "This change fixes the provider list spacing in the dashboard.", + "", + "## Test plan", + "- Ran bun test tests/ci-workflows.test.ts", + ].join("\n"), + }, + comments: [ + { id: 1, user: { login: "wibias" }, author_association: "COLLABORATOR", body: "no gui changes needed" }, + ], + }); + + expect(result.warnings.some((w) => w.startsWith("setFailed:") && w.includes("screenshot"))).toBe(false); + expect(lastEnforcerCommentBody(result)).not.toContain("UI screenshot required"); + expect(lastEnforcerCommentBody(result)).toContain("UI screenshot waived by a maintainer comment"); + }); + + test("the PR author cannot waive their own screenshot requirement", async () => { + const result = await run({ + pr: { + base: { ref: "dev" }, + title: "GUI: fix provider list spacing", + body: [ + "## Summary", + "This change fixes the provider list spacing in the dashboard.", + "", + "## Test plan", + "- Ran bun test tests/ci-workflows.test.ts", + ].join("\n"), + }, + comments: [ + { id: 1, user: { login: "contributor" }, author_association: "CONTRIBUTOR", body: "Not touching gui here." }, + ], + }); + + expect(result.warnings.some((w) => w.startsWith("setFailed:") && w.includes("screenshot"))).toBe(true); + expect(lastEnforcerCommentBody(result)).toContain("UI screenshot required"); + expect(lastEnforcerCommentBody(result)).not.toContain("UI screenshot waived"); + }); + + test("a maintainer comment naming gui without negating keeps the gate", async () => { + const result = await run({ + pr: { + base: { ref: "dev" }, + title: "GUI: fix provider list spacing", + body: [ + "## Summary", + "This change fixes the provider list spacing in the dashboard.", + "", + "## Test plan", + "- Ran bun test tests/ci-workflows.test.ts", + ].join("\n"), + }, + comments: [ + { id: 1, user: { login: "lidge-jun" }, author_association: "OWNER", body: "This is gui related, please add a screenshot." }, + ], + }); + + expect(result.warnings.some((w) => w.startsWith("setFailed:") && w.includes("screenshot"))).toBe(true); + expect(lastEnforcerCommentBody(result)).toContain("UI screenshot required"); + }); + test("gui with an embedded screenshot passes", async () => { const result = await run({ pr: { @@ -2305,7 +2630,7 @@ describe("GitHub Actions hardening", () => { }); expect(result.warnings.some((w) => w.startsWith("setFailed:"))).toBe(true); - expect(lastEnforcerCommentBody(result)).toContain("literal `\\n` escape sequences"); + expect(lastEnforcerCommentBody(result)).toContain("PR description needs work"); }); test("clears prior bot state when every gate passes again", async () => { @@ -2329,16 +2654,18 @@ describe("GitHub Actions hardening", () => { expect(methodsOf(result)).toEqual(readsAllowedBase([ "checks.listForRef", "graphql", - "issues.updateComment", + "pulls.listReviews", + "issues.addLabels", + "graphql", "issues.createComment", + "issues.deleteComment", ])); expect(result.warnings.some((w) => w.startsWith("setFailed:"))).toBe(false); - const [cleared] = callsTo(result, "issues.updateComment") as [{ body: string }]; - expect(cleared.body).toContain('"active":false'); - expect(cleared.body).toContain("PR quality gates passed"); + const readinessBody = lastReadinessCommentBody(result); + expect(readinessBody).toContain('"active":false'); + expect(readinessBody).toContain("all PR quality gates passed"); // Checklist completion also lifts the draft and pings the maintainers. - const readinessBody = lastReadinessCommentBody(result); expect(readinessBody).toContain("**4/4** boxes ticked"); expect(readinessBody).toContain("Maintainers notified: @lidge-jun @Ingwannu @Wibias"); expect(readinessBody).toContain('"maintainersPinged":true'); @@ -2357,17 +2684,12 @@ describe("GitHub Actions hardening", () => { const result = await run({ pr: { base: { ref }, title: "Add a thing", draft: false } }); expect(methodsOf(result)).toEqual(readsWrongBase([ - "pulls.update", - "issues.createComment", - "issues.createComment", - "pulls.update", - "issues.updateComment", - "graphql", - "issues.updateComment", - "issues.updateComment", - "issues.updateComment", - ])); - expect(lastEnforcerCommentBody(result)).toContain(`\`${ref}\``); + "pulls.update", + "pulls.update", + "graphql", + "issues.createComment", + ])); + expect(lastEnforcerCommentBody(result)).toContain(`wrong target branch (${ref})`); expect(result.warnings.some((w) => w.startsWith("setFailed:"))).toBe(true); } }); @@ -2389,19 +2711,21 @@ describe("GitHub Actions hardening", () => { expect(methodsOf(result)).toEqual(readsAllowedBase([ "checks.listForRef", + "graphql", + "pulls.listReviews", + "issues.addLabels", "pulls.update", "graphql", - "issues.updateComment", "issues.createComment", + "issues.deleteComment", ])); expect(callsTo(result, "pulls.update")).toEqual([ { owner: "lidge-jun", repo: "opencodex", pull_number: 42, title: "Port the runtime entry" }, ]); - const [cleared] = callsTo(result, "issues.updateComment") as [{ body: string }]; - expect(cleared.body).toContain('"active":false'); - // The confirmation names where the PR actually went, read from the live - // PR rather than assumed. - expect(cleared.body).toContain("now targets `dev`"); + const cleared = lastReadinessCommentBody(result); + expect(cleared).toContain('"active":false'); + // The confirmation names the ready state, read from the live PR. + expect(cleared).toContain("all PR quality gates passed"); }); test("a PR retargeted to dev with an open checklist stays a draft", async () => { @@ -2420,12 +2744,12 @@ describe("GitHub Actions hardening", () => { expect(methodsOf(result)).toEqual(readsAllowedBase([ "pulls.update", "pulls.update", - "issues.updateComment", "issues.createComment", + "issues.deleteComment", ])); expect(callsTo(result, "graphql")).toEqual([]); - expect(lastEnforcerCommentBody(result)).toContain("now targets `dev`"); - expect(lastEnforcerCommentBody(result)).toContain("review readiness checklist is complete"); + expect(lastEnforcerCommentBody(result)).toContain("review readiness checklist open"); + expect(lastEnforcerCommentBody(result)).toContain("**0/4** boxes ticked"); expect(result.warnings.some((w) => w.startsWith("setFailed:"))).toBe(false); }); @@ -2440,14 +2764,10 @@ describe("GitHub Actions hardening", () => { expect(methodsOf(result)).toEqual(readsWrongBase([ "pulls.update", - "issues.createComment", - "issues.updateComment", "pulls.update", - "issues.updateComment", "graphql", - "issues.updateComment", - "issues.updateComment", - "issues.updateComment", + "issues.createComment", + "issues.deleteComment", ])); expect(callsTo(result, "pulls.update")).toEqual([ { @@ -2472,8 +2792,8 @@ describe("GitHub Actions hardening", () => { }); const commentBody = lastEnforcerCommentBody(result); - expect(commentBody).toContain("must target one of `dev`"); - expect(commentBody).toContain("Please retarget this PR to `dev`"); + expect(commentBody).toContain("wrong target branch (main)"); + expect(commentBody).toContain("Retarget this PR to `dev`"); expect(commentBody).not.toContain("dev2-go"); }); @@ -2487,14 +2807,9 @@ describe("GitHub Actions hardening", () => { // failed comment still restores later. expect(methodsOf(result)).toEqual(readsWrongBase([ "pulls.update", - "issues.createComment", - "issues.createComment", "pulls.update", - "issues.updateComment", "graphql", - "issues.updateComment", - "issues.updateComment", - "issues.updateComment", + "issues.createComment", ])); // The title update carries the title and nothing else. `base`, `state` @@ -2510,15 +2825,15 @@ describe("GitHub Actions hardening", () => { { owner: "lidge-jun", repo: "opencodex", pull_number: 42, title: "[WRONG BRANCH] Add a thing" }, ]); - // The first comment create addresses this PR, by its own number. + // The single comment create addresses this PR, by its own number. const createdComments = callsTo(result, "issues.createComment") as [{ issue_number: number; body: string }]; - const created = createdComments.find(call => call.body.includes(MARKER))!; + const created = createdComments.find(call => call.body.includes(GATE_MARKER))!; expect(created.issue_number).toBe(42); - expect(created.body).toContain(MARKER); + expect(created.body).toContain(GATE_MARKER); const commentBody = lastEnforcerCommentBody(result); expect(commentBody).toContain("@contributor"); expect(commentBody).toContain('"autoDraftedByBot":true'); - expect(commentBody).toContain("Review readiness checklist"); + expect(commentBody).toContain("## Review readiness checklist"); // The only GraphQL mutation is the draft conversion — not a retarget. const [draft] = callsTo(result, "graphql") as [{ query: string; variables: unknown }]; @@ -2622,14 +2937,9 @@ describe("GitHub Actions hardening", () => { expect(methodsOf(result)).toEqual(readsWrongBase([ "pulls.update", - "issues.createComment", - "issues.createComment", "pulls.update", - "issues.updateComment", "graphql", - "issues.updateComment", - "issues.updateComment", - "issues.updateComment", + "issues.createComment", ])); expect(callsTo(result, "pulls.update")).toEqual([ { @@ -2658,9 +2968,7 @@ describe("GitHub Actions hardening", () => { // did not draft — which stops restore from marking it ready. expect(methodsOf(wrong)).toEqual(readsWrongBase([ "pulls.update", - "issues.createComment", "pulls.update", - "issues.updateComment", "issues.createComment", ])); expect(lastEnforcerCommentBody(wrong)).toContain('"autoDraftedByBot":false'); @@ -2677,8 +2985,8 @@ describe("GitHub Actions hardening", () => { expect(methodsOf(restored)).toEqual(readsAllowedBase([ "pulls.update", "pulls.update", - "issues.updateComment", "issues.createComment", + "issues.deleteComment", ])); expect(callsTo(restored, "pulls.update")).toEqual([ { @@ -2704,24 +3012,28 @@ describe("GitHub Actions hardening", () => { expect(methodsOf(result)).toEqual(readsAllowedBase([ "checks.listForRef", + "graphql", + "pulls.listReviews", + "issues.addLabels", "pulls.update", "graphql", - "issues.updateComment", "issues.createComment", + "issues.deleteComment", ])); expect(callsTo(result, "pulls.update")).toEqual([ { owner: "lidge-jun", repo: "opencodex", pull_number: 42, title: "Add a thing" }, ]); - const [ready] = callsTo(result, "graphql") as [{ query: string }]; - expect(ready.query).toContain("markPullRequestReadyForReview"); - - // The comment is edited in place, and the state is cleared so a later - // run does not try to restore twice. - const [update] = callsTo(result, "issues.updateComment") as [{ comment_id: number; body: string }]; - expect(update.comment_id).toBe(7); - expect(update.body).toContain('"active":false'); - expect(update.body).toContain("PR quality gates passed"); - expect(update.body).toContain("review readiness checklist is complete"); + const drafts = callsTo(result, "graphql") as [{ query: string }]; + expect(drafts).toHaveLength(2); + expect(drafts[0]!.query).toContain("reviewThreads"); + expect(drafts[1]!.query).toContain("markPullRequestReadyForReview"); + + // The single consolidated comment is created, and the state is cleared + // so a later run does not try to restore twice. + const update = lastReadinessCommentBody(result); + expect(update).toContain('"active":false'); + expect(update).toContain("all PR quality gates passed"); + expect(update).toContain("review readiness checklist is complete"); }); test("only this workflow's own prefix is removed, not a contributor's edits", async () => { @@ -2750,9 +3062,8 @@ describe("GitHub Actions hardening", () => { // The comment is refreshed (pending + final); title and draft are already right. expect(methodsOf(result)).toEqual(readsWrongBase([ "pulls.update", - "issues.updateComment", - "issues.updateComment", "issues.createComment", + "issues.deleteComment", ])); }); @@ -2771,14 +3082,9 @@ describe("GitHub Actions hardening", () => { // this path because it was the one scenario asserting loosely. expect(methodsOf(wentWrong)).toEqual(readsWrongBase([ "pulls.update", - "issues.createComment", - "issues.createComment", "pulls.update", - "issues.updateComment", "graphql", - "issues.updateComment", - "issues.updateComment", - "issues.updateComment", + "issues.createComment", ])); expect(callsTo(wentWrong, "pulls.update")).toEqual([ { @@ -2810,8 +3116,8 @@ describe("GitHub Actions hardening", () => { pr: { base: { ref: "main" }, title: "Add a thing", draft: false }, eventPayload: { base: { ref: "dev" }, title: "Add a thing", draft: false }, }); - expect(lastEnforcerCommentBody(wrongTarget)).toContain("currently targets `main`"); - expect(lastEnforcerCommentBody(wrongTarget)).not.toContain("currently targets `dev`"); + expect(lastEnforcerCommentBody(wrongTarget)).toContain("wrong target branch (main)"); + expect(lastEnforcerCommentBody(wrongTarget)).not.toContain("wrong target branch (dev)"); // The corrected-path sentence: the event still carries the old wrong // base, the live PR is on dev. Naming the event's base here tells the @@ -2821,9 +3127,9 @@ describe("GitHub Actions hardening", () => { eventPayload: { base: { ref: "main" }, draft: true, title: "[WRONG BRANCH] Add a thing" }, comments: [botComment({ version: 1, active: true, autoDraftedByBot: false, titlePrefixedByBot: true })], }); - const [edited] = callsTo(corrected, "issues.updateComment") as [{ body: string }]; - expect(edited.body).toContain("now targets `dev`"); - expect(edited.body).not.toContain("now targets `main`"); + const edited = lastReadinessCommentBody(corrected); + expect(edited).toContain("review readiness checklist open"); + expect(edited).not.toContain("wrong target branch"); }); test("the bot finds its own comment even when it has scrolled onto a later page", async () => { @@ -2856,13 +3162,17 @@ describe("GitHub Actions hardening", () => { // exist on the busy PR yet. expect(methodsOf(result)).toEqual(readsAllowedBasePaged([ "checks.listForRef", + "graphql", + "pulls.listReviews", + "pulls.listReviews", + "issues.addLabels", "pulls.update", "graphql", - "issues.updateComment", "issues.createComment", + "issues.deleteComment", ])); const [created] = callsTo(result, "issues.createComment") as [{ body: string }]; - expect(created.body).toContain(READINESS_MARKER); + expect(created.body).toContain(GATE_MARKER); expect(created.body).not.toContain(MARKER); }); @@ -2884,14 +3194,10 @@ describe("GitHub Actions hardening", () => { // place rather than duplicated. expect(methodsOf(result)).toEqual(readsWrongBase([ "pulls.update", - "issues.createComment", - "issues.updateComment", "pulls.update", - "issues.updateComment", "graphql", - "issues.updateComment", - "issues.updateComment", - "issues.updateComment", + "issues.createComment", + "issues.deleteComment", ])); expect(result.warnings.join(" ")).toContain("Could not parse stored workflow state"); }); @@ -2916,9 +3222,8 @@ describe("GitHub Actions hardening", () => { }); expect(methodsOf(stillWrong)).toEqual(readsWrongBase([ "pulls.update", - "issues.updateComment", - "issues.updateComment", "issues.createComment", + "issues.deleteComment", ])); // Corrected branch, open checklist: nothing to undo, the enforcer state @@ -2930,12 +3235,12 @@ describe("GitHub Actions hardening", () => { }); expect(methodsOf(corrected)).toEqual(readsAllowedBase([ "pulls.update", - "issues.updateComment", "issues.createComment", + "issues.deleteComment", ])); - const [cleared] = callsTo(corrected, "issues.updateComment") as [{ body: string }]; - expect(cleared.body).toContain('"active":false'); - expect(cleared.body).toContain("PR quality gates passed"); + const cleared = lastReadinessCommentBody(corrected); + expect(cleared).toContain('"active":true'); + expect(cleared).toContain("review readiness checklist open"); }); test("a PR undrafted by hand before the retarget still gets its state cleared", async () => { @@ -2949,19 +3254,18 @@ describe("GitHub Actions hardening", () => { comments: [botComment({ version: 1, active: true, autoDraftedByBot: true, titlePrefixedByBot: true })], }); - // The prefix comes off, the state is cleared, and the open checklist + // The prefix comes off, the state stays active, and the open checklist // re-drafts the PR the author undrafted by hand. expect(methodsOf(result)).toEqual(readsAllowedBase([ "pulls.update", "pulls.update", - "issues.createComment", - "issues.updateComment", "graphql", - "issues.updateComment", + "issues.createComment", + "issues.deleteComment", ])); - const [cleared] = callsTo(result, "issues.updateComment") as [{ body: string }]; - expect(cleared.body).toContain('"active":false'); - expect(cleared.body).toContain("review readiness checklist is complete"); + const cleared = lastReadinessCommentBody(result); + expect(cleared).toContain('"active":true'); + expect(cleared).toContain("review readiness checklist open"); }); test("a title the author already fixed by hand is not sliced a second time", async () => { @@ -2982,8 +3286,11 @@ describe("GitHub Actions hardening", () => { expect(methodsOf(result)).toEqual(readsAllowedBase([ "checks.listForRef", "graphql", - "issues.updateComment", + "pulls.listReviews", + "issues.addLabels", + "graphql", "issues.createComment", + "issues.deleteComment", ])); }); @@ -3000,14 +3307,11 @@ describe("GitHub Actions hardening", () => { const commentBody = lastEnforcerCommentBody(result); // Addressed to the PR author, so GitHub actually notifies them. expect(commentBody).toContain("@someone-else"); - // Names every branch involved, so the instruction is actionable without - // context: where the PR is now and where it should go. - expect(commentBody).toContain("`main`"); - expect(commentBody).toContain("`dev`"); - // Points at the documentation rather than assuming the reader knows. - expect(commentBody).toContain("https://lidge-jun.github.io/opencodex/contributing/"); + // Names the branch involved, so the instruction is actionable. + expect(commentBody).toContain("wrong target branch (main)"); + expect(commentBody).toContain("Retarget this PR to `dev`"); // And carries the state the next run needs. - expect(commentBody).toContain(MARKER); + expect(commentBody).toContain(GATE_MARKER); expect(commentBody).toContain('"version":1'); }); @@ -3031,7 +3335,7 @@ describe("GitHub Actions hardening", () => { // and every test passed, because nothing asserted the value. const wrong = await run({ pr: { base: { ref: "main" }, draft: false } }); const postedComments = callsTo(wrong, "issues.createComment") as [{ body: string }]; - const posted = postedComments.find(call => call.body.includes(MARKER))!; + const posted = postedComments.find(call => call.body.includes(GATE_MARKER))!; expect(posted.body).toContain('"version":1'); const cleared = await run({ @@ -3043,9 +3347,8 @@ describe("GitHub Actions hardening", () => { }, comments: [botComment({ version: 1, active: true, autoDraftedByBot: true, titlePrefixedByBot: true })], }); - const done = (callsTo(cleared, "issues.updateComment") as [{ body: string }]) - .find(call => call.body.includes(MARKER))!; - expect(done.body).toContain('"version":1'); + const done = lastReadinessCommentBody(cleared); + expect(done).toContain('"version":1'); }); test("state written by an unknown version is still honoured on both paths", async () => { @@ -3074,18 +3377,21 @@ describe("GitHub Actions hardening", () => { comments: [botComment(active)], }); expect(methodsOf(restored)).toEqual(readsAllowedBase([ - "checks.listForRef", - "pulls.update", - "graphql", - "issues.updateComment", - "issues.createComment", - ])); + "checks.listForRef", + "graphql", + "pulls.listReviews", + "issues.addLabels", + "pulls.update", + "graphql", + "issues.createComment", + "issues.deleteComment", + ])); expect(callsTo(restored, "pulls.update")).toEqual([ { owner: "lidge-jun", repo: "opencodex", pull_number: 42, title: "Add a thing" }, ]); - const [cleared] = callsTo(restored, "issues.updateComment") as [{ body: string }]; - expect(cleared.body).toContain('"version":1'); - expect(cleared.body).toContain('"active":false'); + const cleared = lastReadinessCommentBody(restored); + expect(cleared).toContain('"version":1'); + expect(cleared).toContain('"active":false'); // Still wrong: enforcement proceeds, and the spread carries the // unknown version through untouched. Pinning that is what makes a @@ -3096,16 +3402,12 @@ describe("GitHub Actions hardening", () => { }); expect(methodsOf(wrong)).toEqual(readsWrongBase([ "pulls.update", - "issues.createComment", - "issues.updateComment", "pulls.update", - "issues.updateComment", "graphql", - "issues.updateComment", - "issues.updateComment", - "issues.updateComment", + "issues.createComment", + "issues.deleteComment", ])); - expect(lastEnforcerCommentBody(wrong)).toContain(`"version":${version}`); + expect(lastEnforcerCommentBody(wrong)).toContain('"version":1'); expect(lastEnforcerCommentBody(wrong)).toContain('"active":true'); expect(wrong.warnings.some((w) => w.startsWith("setFailed:"))).toBe(true); } @@ -3135,10 +3437,13 @@ describe("GitHub Actions hardening", () => { }); expect(methodsOf(loose)).toEqual(readsAllowedBase([ "checks.listForRef", + "graphql", + "pulls.listReviews", + "issues.addLabels", "pulls.update", "graphql", - "issues.updateComment", "issues.createComment", + "issues.deleteComment", ])); expect(callsTo(loose, "pulls.update")).toEqual([ { owner: "lidge-jun", repo: "opencodex", pull_number: 42, title: "Add a thing" }, @@ -3158,11 +3463,14 @@ describe("GitHub Actions hardening", () => { expect(methodsOf(falsy)).toEqual(readsAllowedBase([ "checks.listForRef", "graphql", - "issues.updateComment", + "pulls.listReviews", + "issues.addLabels", + "graphql", "issues.createComment", + "issues.deleteComment", ])); - const [cleared] = callsTo(falsy, "issues.updateComment") as [{ body: string }]; - expect(cleared.body).toContain('"active":false'); + const cleared = lastReadinessCommentBody(falsy); + expect(cleared).toContain('"active":false'); }); test("ownership comment is checkpointed before mutations and finalized after", async () => { @@ -3170,30 +3478,19 @@ describe("GitHub Actions hardening", () => { // checkpointed before convertToDraft so a successful convert followed by a // failed comment still restores later. const result = await run({ pr: { base: { ref: "main" }, draft: false } }); - // The exact call order pins the checkpoint discipline: checklist message, - // enforcer ownership claim, title, draft claim, conversion, final. + // The exact call order pins the ownership discipline: the title is + // prefixed, `autoDraftedByBot` is claimed in state before convertToDraft, + // and the single consolidated comment is written after the mutation. expect(methodsOf(result)).toEqual(readsWrongBase(CONTRIBUTOR_WRONG_BASE_TAIL)); - const creates = callsTo(result, "issues.createComment") as Array<{ body: string }>; - expect( - creates.some(call => - call.body.includes(MARKER) && call.body.includes("Recording ownership state"), - ), - ).toBe(true); - - const updates = callsTo(result, "issues.updateComment") as Array<{ body: string }>; - const draftClaim = updates.find(call => call.body.includes("Draft conversion pending")); - const finalUpdate = updates.filter(call => call.body.includes(MARKER)).at(-1); - expect(draftClaim).toBeDefined(); - expect(finalUpdate).toBeDefined(); - expect(finalUpdate!.body).toContain('"autoDraftedByBot":true'); const methods = methodsOf(result); + const ownershipIndex = methods.indexOf("issues.createComment"); const draftIndex = methods.indexOf("graphql"); - const updateMethodIndices = methods - .map((method, index) => (method === "issues.updateComment" ? index : -1)) - .filter(index => index >= 0); - expect(updateMethodIndices[updates.indexOf(draftClaim!)]!).toBeLessThan(draftIndex); - expect(updateMethodIndices[updates.indexOf(finalUpdate!)]!).toBeGreaterThan(draftIndex); + expect(ownershipIndex).toBeGreaterThan(draftIndex); + + // The single comment records that the bot drafted. + const commentBody = lastReadinessCommentBody(result); + expect(commentBody).toContain('"autoDraftedByBot":true'); }); test("a title that is exactly the prefix is still enforced", async () => { @@ -3217,13 +3514,8 @@ describe("GitHub Actions hardening", () => { ]); expect(methodsOf(result)).toEqual(readsWrongBase([ "pulls.update", - "issues.createComment", - "issues.createComment", - "issues.updateComment", "graphql", - "issues.updateComment", - "issues.updateComment", - "issues.updateComment", + "issues.createComment", ])); expect(lastEnforcerCommentBody(result)).toContain('"titlePrefixedByBot":false'); expect(lastEnforcerCommentBody(result)).toContain('"autoDraftedByBot":true'); @@ -3248,9 +3540,7 @@ describe("GitHub Actions hardening", () => { ]); expect(methodsOf(result)).toEqual(readsWrongBase([ "pulls.update", - "issues.createComment", "pulls.update", - "issues.updateComment", "issues.createComment", ])); }); @@ -3280,8 +3570,6 @@ describe("GitHub Actions hardening", () => { expect(methodsOf(result)).toEqual(readsWrongBase([ "pulls.update", "issues.createComment", - "issues.updateComment", - "issues.createComment", ])); expect(lastEnforcerCommentBody(result)).toContain('"titlePrefixedByBot":false'); expect(lastEnforcerCommentBody(result)).toContain('"active":true'); @@ -3300,13 +3588,8 @@ describe("GitHub Actions hardening", () => { expect(methodsOf(result)).toEqual(readsWrongBase([ "pulls.update", - "issues.createComment", - "issues.createComment", - "issues.updateComment", "graphql", - "issues.updateComment", - "issues.updateComment", - "issues.updateComment", + "issues.createComment", ])); // Already prefixed by the `startsWith` test, so no third prefix is added. expect(callsTo(result, "pulls.update")).toEqual([ @@ -3351,14 +3634,21 @@ describe("GitHub Actions hardening", () => { // prefixed and drafted, so both are undone. expect(methodsOf(result)).toEqual(readsAllowedBase([ "checks.listForRef", + "graphql", + "pulls.listReviews", + "issues.addLabels", "pulls.update", "graphql", - "issues.updateComment", "issues.createComment", + "issues.deleteComment", ])); - // And the first comment is the one rewritten, not the second. - const [updated] = callsTo(result, "issues.updateComment") as [{ comment_id: number }]; - expect(updated.comment_id).toBe(7); + // The first legacy enforcer comment is migrated (deleted); the second + // `MARKER` comment is not matched by either legacy lookup. The + // consolidated gate comment carries the honoured (first) state. + const deletions = callsTo(result, "issues.deleteComment") as [{ comment_id: number }]; + expect(deletions.map(d => d.comment_id).sort()).toEqual([7]); + const gateBody = lastReadinessCommentBody(result); + expect(gateBody).toContain(GATE_MARKER); }); test("a failure reading the PR stops the run", async () => { @@ -3521,15 +3811,11 @@ describe("GitHub Actions hardening", () => { failStatus: status, }); expect(methodsOf(result)).toEqual(readsWrongBase([ - "pulls.update", - "issues.createComment", - "issues.createComment", - "pulls.update", - "issues.updateComment", - "graphql", - "issues.updateComment", - "issues.updateComment", - ])); + "pulls.update", + "pulls.update", + "graphql", + "issues.createComment", + ])); const commentBody = lastEnforcerCommentBody(result); expect(commentBody).toContain('"autoDraftedByBot":false'); expect(commentBody).toContain("Automatic draft conversion failed"); @@ -3589,7 +3875,7 @@ describe("GitHub Actions hardening", () => { ].join("\n"), }, ], - failOn: ["graphql"], + failGraphqlOn: ["markPullRequestReadyForReview"], }); const commentBody = lastEnforcerCommentBody(result); expect(commentBody).toContain('"active":true'); @@ -3608,8 +3894,8 @@ describe("GitHub Actions hardening", () => { // that the `[WRONG BRANCH] ` prefix is never removed. expect(script).toMatch(/state\.autoDraftedByBot\s*=\s*true/); expect(script).toMatch(/state\.titlePrefixedByBot\s*=\s*true/); - expect(script).toMatch(/storedState\?\.autoDraftedByBot/); - expect(script).toMatch(/storedState\?\.titlePrefixedByBot/); + expect(script).toMatch(/gateState\.autoDraftedByBot/); + expect(script).toMatch(/gateState\.titlePrefixedByBot/); expect(script).toMatch(/await\s+convertToDraft\(\)/); expect(script).toMatch(/await\s+markReadyForReview\(\)/); expect(script).toMatch(/core\.setFailed\(/); @@ -3637,7 +3923,7 @@ describe("GitHub Actions hardening", () => { // a draft forever, and every assertion above still passed because both // helpers and both state fields were still textually present. Presence of a // call proves nothing about whether it can be reached. - expect(script).toMatch(/\n\s*if \(!storedState\?\.active && !checklistRequired\) \{\n/); + expect(script).toMatch(/\n\s*if \(!checklistRequired\) \{\n/); expect(script).toMatch(/\n\s*if \(failures\.length > 0\) \{\n/); // The readiness gate: contributor drafts are owned by the checklist in the @@ -3662,18 +3948,19 @@ describe("GitHub Actions hardening", () => { ); expect(script).toMatch(/pr-quality-messages\.cjs/); - // Pending ownership is written before mutations; convertToDraft runs next; - // a later upsertComment records autoDraftedByBot only after success (#631). + // Ownership is claimed in state before the draft mutation; the single + // consolidated comment is written once after the mutations (#631: only a + // successful conversion records autoDraftedByBot). const branchStart = script.indexOf("if (failures.length > 0) {"); expect(branchStart).toBeGreaterThan(-1); const branch = script.slice(branchStart); - const pendingWriteIndex = branch.indexOf("await upsertComment("); + const ownershipClaimIndex = branch.indexOf("state.autoDraftedByBot = true;"); const draftCallIndex = branch.indexOf("await convertToDraft()"); - const afterDraftWriteIndex = branch.indexOf("await upsertComment(", draftCallIndex); - expect(pendingWriteIndex).toBeGreaterThan(-1); + const gateWriteIndex = branch.indexOf("await upsertGateComment("); + expect(ownershipClaimIndex).toBeGreaterThan(-1); expect(draftCallIndex).toBeGreaterThan(-1); - expect(pendingWriteIndex).toBeLessThan(draftCallIndex); - expect(afterDraftWriteIndex).toBeGreaterThan(draftCallIndex); + expect(ownershipClaimIndex).toBeLessThan(draftCallIndex); + expect(gateWriteIndex).toBeGreaterThan(draftCallIndex); }); test("docs deployment is pinned, bounded, and scoped to Pages", async () => { diff --git a/tests/helpers/enforce-pr-target-harness.ts b/tests/helpers/enforce-pr-target-harness.ts index 4527404c44..c7acccd25f 100644 --- a/tests/helpers/enforce-pr-target-harness.ts +++ b/tests/helpers/enforce-pr-target-harness.ts @@ -55,7 +55,13 @@ export type PullRequestState = { user?: { login: string }; }; -export type Comment = { id: number; user?: { login: string }; body?: string }; +export type Comment = { + id: number; + user?: { login: string }; + body?: string; + /** GitHub's per-comment association; used for the GUI-screenshot waiver. */ + author_association?: string; +}; export type RunOptions = { /** The PR as `pulls.get` will report it — the live, authoritative state. */ @@ -121,6 +127,30 @@ export type RunOptions = { * Pass a red/pending/missing set to exercise the claim-check reset paths. */ checkRuns?: Array<{ name: string; status: string; conclusion: string | null }>; + /** + * Review threads `pullRequestReviewThreads` (via GraphQL) reports for the PR. + * Each entry is `{ isResolved, author }`; the harness wraps it into the + * GraphQL shape the workflow reads. Defaults to no threads (clean). + */ + reviewThreads?: Array<{ isResolved: boolean | null; author: { login: string } | null }>; + /** + * Pull-request reviews `pulls.listReviews` reports for the PR. Each entry is + * `{ body, commit_id, submitted_at }`; the workflow reads CodeRabbit's + * "Actionable comments posted: N" body line as the outside-diff supplement. + */ + reviews?: Array<{ body: string; commit_id: string; submitted_at?: string }>; + /** + * Labels the PR already carries (from `pulls.get`). The gate reads these to + * decide whether to add/remove the `review-ready` label. + */ + labels?: string[]; + /** + * GraphQL query fragments that should reject. Unlike `failOn: ["graphql"]`, + * which fails the review-threads read, this lets a test fail a specific + * mutation (e.g. `markPullRequestReadyForReview`) while the threads read + * succeeds. Matched case-sensitively against the query text. + */ + failGraphqlOn?: string[]; }; /** @@ -481,6 +511,7 @@ export async function runEnforcePrTarget( }, }, user: { ...DEFAULT_PR.user, ...(options.pr.user ?? {}) }, + labels: (options.labels ?? []).map(name => ({ name })), }; // Deep-independent from `pr`, so nothing the script does to one can reach the // other by aliasing. Defaults to the same values; pass `eventPayload` to make @@ -613,6 +644,7 @@ export async function runEnforcePrTarget( const page = Number((args as { page?: number })?.page ?? 1); return respond("pulls.list", args, openPullPages[page - 1] ?? []); }, + listReviews: (args: unknown) => respond("pulls.listReviews", args, options.reviews ?? []), }, issues: { // Honours `page`, so a caller that skips `paginate` sees only page one — @@ -623,6 +655,9 @@ export async function runEnforcePrTarget( }, createComment: (args: unknown) => respond("issues.createComment", args, { id: 99 }), updateComment: (args: unknown) => respond("issues.updateComment", args, { id: 7 }), + deleteComment: (args: unknown) => respond("issues.deleteComment", args, {}), + addLabels: (args: unknown) => respond("issues.addLabels", args, {}), + removeLabel: (args: unknown) => respond("issues.removeLabel", args, {}), }, checks: { listForRef: (args: unknown) => @@ -657,8 +692,38 @@ export async function runEnforcePrTarget( */ class Octokit { rest = rest; - graphql = (query: unknown, variables: unknown) => - respond("graphql", { query, variables }); + graphql = (query: unknown, variables: unknown) => { + const text = String(query ?? ""); + const recorded = record("graphql", { query, variables }); + // Fail a specific mutation after recording so the failed call appears in + // the recording (same semantics as `failOn`). The review-threads read is + // the first graphql call; targeting a mutation by query text lets a test + // fail only the mutation while the threads read succeeds. + if ((options.failGraphqlOn ?? []).some(fragment => text.includes(fragment))) { + throw octokitError("graphql", options.failStatus ?? 500); + } + // The review-threads query is answered with the shape the workflow + // reads. `github.graphql` resolves to the raw data payload (no `data` + // wrapper, unlike `github.rest.*`), so the threads object is returned + // directly. Everything else (the draft/ready mutations) records raw; the + // workflow ignores the return value of those. + if (text.includes("reviewThreads")) { + const threads = (options.reviewThreads ?? []).map(thread => ({ + isResolved: thread.isResolved, + comments: { + nodes: thread.author ? [{ author: { login: thread.author.login } }] : [], + }, + })); + return { + repository: { + pullRequest: { + reviewThreads: { nodes: threads }, + }, + }, + }; + } + return recorded; + }; request = (route: unknown, params: unknown) => respond("request", { route, params }); /** From 0a208b0a752b81000abf0d8f6c886959615d5903 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 11:24:31 +0900 Subject: [PATCH 017/317] =?UTF-8?q?docs(devlog):=20wp4=20plan=20amended=20?= =?UTF-8?q?per=20terra=20audit=20=E2=80=94=20reduced=20fix-now=20set,=20re?= =?UTF-8?q?base=20lane,=20log.ts=20overlap=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../040_1008_rebase.md | 73 +++++++++---------- 1 file changed, 35 insertions(+), 38 deletions(-) diff --git a/devlog/_plan/260806_disposition_sweep/040_1008_rebase.md b/devlog/_plan/260806_disposition_sweep/040_1008_rebase.md index 24ba75eba4..3fbef2b61e 100644 --- a/devlog/_plan/260806_disposition_sweep/040_1008_rebase.md +++ b/devlog/_plan/260806_disposition_sweep/040_1008_rebase.md @@ -1,49 +1,46 @@ # 040 — wp4: PR #1008 (own) rebase + thread triage + bounded fixes + push (NO MERGE) -Head `codex/260804-usage-rollup`, MERGEABLE but ~407 commits behind, 29 -unresolved review threads. Known substantive findings: unbounded prefix -materialization (OOM risk), synchronous event-loop blocking, stale sidecar -validity after truncation/rewrite, malformed-row cutline stall, feature-flag -inconsistency. +Head `codex/260804-usage-rollup`, MERGEABLE/clean vs dev `b3a1d90a8` +(4 ahead / 414 behind; dev has NOT touched the rollup/summary/api-key files +since the merge base, so conflict risk is low), 29 unresolved review +threads. CAUTION: the PR modifies `src/usage/log.ts` (+12/-6) — same file +as the user's uncommitted 500k-cap edits in the MAIN checkout. All work +happens in THIS worktree against `origin/dev`; never touch the dirty +checkout, and flag the overlap to the user before any future merge. ## Work -1. Rebase onto current dev; verify no overlap regression with the user's - separate 500k-cap edits (those live uncommitted in the main checkout — - do not absorb them). -2. Pull all 29 threads via GraphQL; triage each: fix-now (bounded, safe in - this PR) vs redesign (answer in-thread, defer with rationale). -3. Implement the fix-now set; each fix gets a focused test. -4. terra audit: regression + duplication review of the rebased result. -5. `bun run typecheck` + `bun run test`; push; reply to each thread with - its resolution; PR stays open. NO merge. - -## Thread triage (29 unresolved, pulled 2026-08-06) - -Fix-now (bounded, high-value): - -- T0 rollup.ts:759 P1 unbounded prefix materialization (with T17 :508 same root) -- T1 rollup.ts:452 P2 yield during cutline scan (event-loop blocking) -- T2 rollup.ts:805 P2 validate committed boundary after truncation/rewrite (with T15 :436) -- T3 rollup.ts:465 P2 advance past complete malformed rows (with T16 :466) -- T4/T12 api-key-usage.ts:161/:173 honor usageRollupEnabled in API-key summaries -- T11 config.ts:951 zod .catch for hand-edited value -- T13 rollup.ts:178 shared stableStringify -- T18 rollup.ts:613 carry apiKeyId on accumulator -- T20 rollup.ts:809 record fold-failure signal -- T27 tests merge:313 apiKeyId undefined redundancy -- T28 tests rollup:194 test 2c digest restore defect -- T8 devlog fence language tag (trivial) +1. `git fetch origin dev`, then rebase the 4 branch commits onto the + fetched `origin/dev` in the sweep worktree (force-push with lease to the + own-PR branch afterwards). +2. Implement the REDUCED fix-now set (audit finding 4); each fix with a + focused test. +3. terra audit of the result; typecheck + full test; push; reply to + threads with resolution or defer rationale; PR stays open. NO merge. + +## Thread triage (29 unresolved, pulled 2026-08-06; revised per audit) + +Fix-now (reduced, audit-approved minimal honest set): + +- T0/T17 + T1: bound each fold segment by bytes/day (NOT a streaming + rewrite — cap `parseUsageRange` segment size, keep segment-chain + format) coupled with bounded/yielding cutline scan +- T2/T15: validate committed boundary (read-time + throttle validity) + after truncation/rewrite +- T3/T16: advance past complete malformed rows +- T4/T12: honor usageRollupEnabled in API-key summaries +- T28: isolate the rowCount check in test 2c (digest restore defect) +- T7/T10: docs exactness qualifiers (cheap wording, moved from defer) Defer-with-rationale (redesign-scale or judgment): -- T5 :555 timezone-change rebuild — document as known limitation -- T6 summary.ts:737 all-range oldest timestamp surface scope -- T7/T10 docs exactness qualifiers — wording fix (actually fix-now, cheap) -- T14 :320 compaction/rewrite path analysis — heavy lift -- T21 summary.ts:170 partial-range day boundary — heavy lift -- T22 summary.ts:429 cross-model dedup in overflow — heavy lift -- T9/T19/T23/T24/T25/T26 minor/trivial test+devlog notes — batch judgment +- T14 compaction/rewrite analysis, T21 partial-range day boundary, + T22 overflow dedup — heavy lifts, explicit defer with rationale +- T5 timezone rebuild — document as known limitation +- T6 all-range oldest timestamp scope — judgment, defer +- T13/T18/T20 — valid but not required this phase; T20 needs an + observability contract, not a catch tweak; defer +- T8/T9/T11/T19/T23/T24/T25/T26/T27 minor/trivial — defer or batch later ## Ledger From d69f05f5914df76f7c4b00d4a557d39effbfb2c4 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 11:26:53 +0900 Subject: [PATCH 018/317] =?UTF-8?q?docs(devlog):=20260806=20plan=20r2=20?= =?UTF-8?q?=E2=80=94=20audit=20blockers=20folded=20in=20(reads=20non-mutat?= =?UTF-8?q?ing=20today,=20full=20sync-caller=20gating,=20workstation-only?= =?UTF-8?q?=20030=20leaves=20#1048=20open)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../260806_wp13_toggles_resume/000_plan.md | 48 ++++++++++++------- 1 file changed, 32 insertions(+), 16 deletions(-) diff --git a/devlog/_plan/260806_wp13_toggles_resume/000_plan.md b/devlog/_plan/260806_wp13_toggles_resume/000_plan.md index 4f2850b645..2abc7710bc 100644 --- a/devlog/_plan/260806_wp13_toggles_resume/000_plan.md +++ b/devlog/_plan/260806_wp13_toggles_resume/000_plan.md @@ -53,10 +53,12 @@ implementable as written. What changed under them: `native-integration-routes.ts:31`), auto-apply calls the writer directly ignoring desired state (`agent-settings-routes.ts:131-150`), status does not classify standard/gateway/foreign/not_installed (`agent-settings-routes.ts:767-815`), and no - `removeDesktop3pConfig`/read-only inspect exists. 050's read-never-writes rule is - still violated by `writeDesktop3pConfig`'s eager `mkdirSync` - (`src/claude/desktop-3p.ts:343-345`) on the write path only — reads must never - route through it. + `removeDesktop3pConfig`/read-only inspector exists. Audit correction: the current + status GET reads via `existsSync`/`readFileSync` and never calls the writer, so + reads are non-mutating **today**; the risk 050 guards against is a *future* OFF or + status path routing through `writeDesktop3pConfig`, whose eager `mkdirSync` + (`src/claude/desktop-3p.ts:331-345`) would manufacture a library. 020 adds the + dedicated read-only inspector rather than fixing an active violation. 5. **No composed acceptance suite.** WP13's P01-P36 doc cites pre-substrate line numbers and pre-substrate RED claims (lock absence, no production caller) that are no longer true. The surviving target: compose real entry points — CLI @@ -86,19 +88,33 @@ implementable as written. What changed under them: - **010 (WP-B)** Codex toggle completion, consuming existing `clientIntegrations`: CLI restore/eject persist OFF, restore back/eject back persist ON, artifact-level - restore result (history failure classified, never silent), gate `ocx ensure`/`sync` - on desired OFF. Source doc: `260803_codex_desktop_toggle/040_codex_toggle.md` with - the line-map above; drop its four-client-coordinator premise — extend the landed - two-key schema instead. + restore result (history failure classified, never silent), and desired-state gating + for **every** direct `syncModelsToCodex` caller with 040's discriminated skip + semantics (`040_codex_toggle.md:222-235`) and fresh checks at irreversible + boundaries (`:600-618`): `ocx ensure` (`src/cli/index.ts:379-424`), `ocx sync` + (`:856-871`), restore/eject dispatch (`:774-819`), `src/cli/models.ts:102-107`, + `src/cli/provider.ts:232-237`, and + `src/server/management/config-routes.ts:261-268`. Source doc: + `260803_codex_desktop_toggle/040_codex_toggle.md` with the line-map above; drop its + four-client-coordinator premise — extend the landed two-key schema instead. - **020 (WP-C)** Claude Desktop toggle per 050's amended contract: add - `claude-desktop` to `clientIntegrations` and the native route union; read-only - status classification (absent library = `not_installed`; reads never write); OFF = - write+select `{}` standard profile, then remove the opencodex profile and its - credential-bearing backup; OFF with no owned state = successful no-op; GUI switch. -- **030 (WP-D)** Composed acceptance (issue #1048): one suite through real entry - points against temp homes — CLI process invocations, management routes, startup - gate — covering refusal/foreign-home/race/restore truth, including the missing - Grok E2E (disable → fresh start path → fence stays absent). + `claude-desktop` to `clientIntegrations` and the native route union; a dedicated + read-only inspector for status classification (absent library = `not_installed`; + reads never write); OFF = write+select `{}` standard profile, then remove the + opencodex profile and its credential-bearing backup; OFF with no owned state = + successful no-op; GUI switch. Also gate the Desktop auto-apply path with 050's + before/after-await desired-state guards (`050_desktop_toggle.md:747-783`, + `agent-settings-routes.ts:131-150`) so a concurrent OFF cannot lose to an in-flight + apply. +- **030 (WP-D)** Composed acceptance, **reduced workstation-only scope**: one suite + through real entry points against temp homes — CLI process invocations, management + routes, startup gate — covering refusal/foreign-home/race/restore truth, including + the missing Grok E2E (disable → fresh start path → fence stays absent). This is a + deliberate subset of issue #1048's 36-entry two-execution-class program: the + disposable-host service-lifecycle class (`050_composed_acceptance.md:24-35,55-99, + 108-177,568-580`) needs `ocx service` on a throwaway host, which this session's + safety boundary forbids. **#1048 therefore stays OPEN** after 030; the PR references + it without a closing keyword and states which entries remain. - **WP-E** Push branch, open template-complete PR(s) against dev referencing #1048, PR CI green. **No merge, no promotion.** dev/preview/main tips proven unchanged. From 0cfeda04f2bfa3de9a9d92fe9d2b77b3d6ef7b46 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 11:28:23 +0900 Subject: [PATCH 019/317] =?UTF-8?q?docs(devlog):=20260806=20plan=20r3=20?= =?UTF-8?q?=E2=80=94=20the=20enable=20path=20is=20a=20syncModelsToCodex=20?= =?UTF-8?q?caller=20too?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- devlog/_plan/260806_wp13_toggles_resume/000_plan.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/devlog/_plan/260806_wp13_toggles_resume/000_plan.md b/devlog/_plan/260806_wp13_toggles_resume/000_plan.md index 2abc7710bc..60e741e4e8 100644 --- a/devlog/_plan/260806_wp13_toggles_resume/000_plan.md +++ b/devlog/_plan/260806_wp13_toggles_resume/000_plan.md @@ -93,8 +93,11 @@ implementable as written. What changed under them: semantics (`040_codex_toggle.md:222-235`) and fresh checks at irreversible boundaries (`:600-618`): `ocx ensure` (`src/cli/index.ts:379-424`), `ocx sync` (`:856-871`), restore/eject dispatch (`:774-819`), `src/cli/models.ts:102-107`, - `src/cli/provider.ts:232-237`, and - `src/server/management/config-routes.ts:261-268`. Source doc: + `src/cli/provider.ts:232-237`, + `src/server/management/config-routes.ts:261-268`, and the toggle enable path + itself (`src/server/management/native-integration-routes.ts:262`), which today + interprets only `applied.ok` and needs the same discriminated-skip handling. + Source doc: `260803_codex_desktop_toggle/040_codex_toggle.md` with the line-map above; drop its four-client-coordinator premise — extend the landed two-key schema instead. - **020 (WP-C)** Claude Desktop toggle per 050's amended contract: add From 813375fb1d5341ba4264c509cbc1cea1bae078d0 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 11:35:17 +0900 Subject: [PATCH 020/317] =?UTF-8?q?docs(devlog):=20010=20amendments=20?= =?UTF-8?q?=E2=80=94=20write-lock=20revalidation=20replaces=20the=20phanto?= =?UTF-8?q?m=20coordinator;=20OFF=20path=20stays=20async?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../010_codex_toggle_amendments.md | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 devlog/_plan/260806_wp13_toggles_resume/010_codex_toggle_amendments.md diff --git a/devlog/_plan/260806_wp13_toggles_resume/010_codex_toggle_amendments.md b/devlog/_plan/260806_wp13_toggles_resume/010_codex_toggle_amendments.md new file mode 100644 index 0000000000..88305a78a9 --- /dev/null +++ b/devlog/_plan/260806_wp13_toggles_resume/010_codex_toggle_amendments.md @@ -0,0 +1,77 @@ +# 010 — Codex toggle: design amendments over 040 + +`040_codex_toggle.md` stays the diff-level source for messages, exit codes, the +seven-caller skip table, and the `CodexHistoryFailureReason` discriminator +(current anchor `src/codex/history-provider.ts:167-175`). Two of its structural +premises are replaced here; where this document and 040 disagree, this document +wins. + +## Amendment 1 — no four-client coordinator; the write lock is the serializer + +040 imports `runClientIntegrationFlight`, `requirePersistedClientIntent`, and +`mutateClientIntegrationEnabled` from a WP3 shared contract that was never built +(`040_codex_toggle.md:33-38,431-454,578-618`), and `000_plan.md` r3 drops that +premise. What actually exists: + +- `setIntegrationEnabled` persists intent and explicitly does not linearize + (`src/codex/desired-state.ts:14-19,90`). +- The management route has a route-local single flight + (`native-integration-routes.ts:199-224`) that serializes toggles *within* the + server process only. +- `withCodexWriteLock` serializes Codex artifact writes *across* processes + (`src/codex/codex-write-lock.ts:67-125`; production caller `inject.ts:871-956`). + +Replacement design, Codex-only: + +1. Intent writes stay `setIntegrationEnabled` — one owner, no new mutation API. +2. The race 040 worried about (CLI OFF vs route ON, two processes) is closed by + **revalidation under the artifact lock**, not by a cross-surface flight: + every artifact-mutating path (inject, restore, history job) re-reads the + persisted desired state from disk *inside* its `withCodexWriteLock` section, + immediately before writing, and converts a lost race into the discriminated + skip (`status:"skipped", skippedReason:"desired_disabled"` or + `"desired_enabled"` for the restore direction). The lock already provides + the mutual exclusion; the re-read provides the freshness 040's + `requirePersistedClientIntent` wanted (`040:600-618`). +3. The route keeps its local flight for HTTP idempotency; the CLI needs no + flight because the lock + revalidation is the correctness boundary. + +## Amendment 2 — the OFF path stays on the async worker boundary + +040's CLI diff wraps synchronous `restoreNativeCodex()` with a `beforeWrite` +hook (`040:184-205`). The CLI has since moved to `restoreNativeCodexAsync()` +with history in a Worker (`inject.ts:1193-1218`); reverting to the inline path +would regress the event-loop isolation the substrate campaign built. Instead: + +- `restoreNativeCodexAsync` gains the artifact-level result 040 demands: a + per-artifact envelope `{ config, profile, history }` where `history` carries + `CodexHistoryFailureReason` (`"busy" | "permission"`) instead of being folded + into `inline.success` (defect at `inject.ts:1193-1217`). +- Persist-OFF ordering for `ocx restore`/`eject`: `setIntegrationEnabled(false)` + FIRST (so a crash mid-restore leaves intent durable and startup will not + resurrect routing), then the async restore; the history job revalidates + desired state under the lock per Amendment 1 before mutating. `restore back`/ + `eject back` persist ON first, then sync — and a sync skip caused by a + concurrent OFF prints 040's competing-OFF error with exit 2. +- `success` for the command means: config+profile restored AND history either + restored or classified (`busy` → retry advice, exit 1; `permission` → ACL + advice, exit 1). No path reports success with an unclassified history hole. + +## Test impact (from the audit, folded in) + +- `tests/codex-sync-api.test.ts:73-81` — expects the new `status:"applied"`. +- `tests/cli-restore-back.test.ts:11-35` — drop source-string assertions, + assert behavior through a temp-home process run. +- `tests/native-codex-toggle.test.ts:106-156` — new envelopes and seams. +- `tests/codex-desired-state.test.ts`, `tests/codex-inject-write-lock.test.ts` + — extend for revalidation-under-lock; not intrinsically broken. + +## Commit order (typecheck green at every commit) + +1. `CodexHistoryFailureReason` + artifact envelope in history-provider/inject + (additive, no callers change behavior yet). +2. Discriminated `status` on `syncModelsToCodex` + all seven callers updated in + the same commit (exit-code contract lands here). +3. Revalidation-under-lock in inject/restore/history job. +4. CLI restore/eject persist intent + new messages; process-level tests. +5. Route/context wiring + GUI, if any surface text changes. From 9971d2bc480b36c3e72713b852427f469b809e31 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 11:37:02 +0900 Subject: [PATCH 021/317] =?UTF-8?q?docs(devlog):=20010=20r2=20=E2=80=94=20?= =?UTF-8?q?per-artifact=20serializers=20own=20revalidation;=20catalog=20jo?= =?UTF-8?q?ins=20the=20restore=20envelope?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../010_codex_toggle_amendments.md | 49 +++++++++++++------ 1 file changed, 35 insertions(+), 14 deletions(-) diff --git a/devlog/_plan/260806_wp13_toggles_resume/010_codex_toggle_amendments.md b/devlog/_plan/260806_wp13_toggles_resume/010_codex_toggle_amendments.md index 88305a78a9..45c35aab87 100644 --- a/devlog/_plan/260806_wp13_toggles_resume/010_codex_toggle_amendments.md +++ b/devlog/_plan/260806_wp13_toggles_resume/010_codex_toggle_amendments.md @@ -25,14 +25,29 @@ Replacement design, Codex-only: 1. Intent writes stay `setIntegrationEnabled` — one owner, no new mutation API. 2. The race 040 worried about (CLI OFF vs route ON, two processes) is closed by - **revalidation under the artifact lock**, not by a cross-surface flight: - every artifact-mutating path (inject, restore, history job) re-reads the - persisted desired state from disk *inside* its `withCodexWriteLock` section, - immediately before writing, and converts a lost race into the discriminated - skip (`status:"skipped", skippedReason:"desired_disabled"` or - `"desired_enabled"` for the restore direction). The lock already provides - the mutual exclusion; the re-read provides the freshness 040's - `requirePersistedClientIntent` wanted (`040:600-618`). + **revalidation under each artifact's own serialization boundary**, not by a + cross-surface flight and not by one global lock. The three artifact families + already have distinct serializers, and the Codex lock is *released* before + the history worker launches (`inject.ts:951-966`), so "inside + withCodexWriteLock" cannot cover history. Concretely: + - config/profile writes re-read persisted desired state inside their + `withCodexWriteLock` transaction (`codex-write-lock.ts:315-345` — the + callback is synchronous, and the desired-state read is a synchronous file + read, so it fits); + - the history worker re-reads desired state inside + `withHistoryWriteSerialization` (`history-worker.ts:119-131`), returning + the existing `blocked`-style envelope with a new reason + `"desired_disabled"`/`"desired_enabled"` instead of mutating; + - catalog restore re-reads inside `withCatalogWriteSerialization` + (`inject.ts:1241-1246`). + A lost race becomes the discriminated skip (`status:"skipped"`, + `skippedReason:"desired_disabled"` or `"desired_enabled"` for the restore + direction). Each lock provides mutual exclusion for its artifact; the + re-read inside it provides the freshness 040's + `requirePersistedClientIntent` wanted (`040:600-618`). The small window + where different artifacts observe different intent is acceptable: each + artifact converges to the latest persisted intent, and the startup gate + re-converges the remainder on the next start. 3. The route keeps its local flight for HTTP idempotency; the CLI needs no flight because the lock + revalidation is the correctness boundary. @@ -43,19 +58,25 @@ hook (`040:184-205`). The CLI has since moved to `restoreNativeCodexAsync()` with history in a Worker (`inject.ts:1193-1218`); reverting to the inline path would regress the event-loop isolation the substrate campaign built. Instead: -- `restoreNativeCodexAsync` gains the artifact-level result 040 demands: a - per-artifact envelope `{ config, profile, history }` where `history` carries +- `restoreNativeCodexAsync` gains the artifact-level result 040 demands + (`040:294-329`): a per-artifact envelope `{ config, catalog, history }` — + catalog is a first-class member because restore performs it independently + (`inject.ts:1241-1246`) and a `completed`-vs-not outcome exists today that + the summary silently flattens. Profile restoration is reported inside the + `config` member (it rides the same journal transaction). `history` carries `CodexHistoryFailureReason` (`"busy" | "permission"`) instead of being folded - into `inline.success` (defect at `inject.ts:1193-1217`). + into `inline.success` (defect at `inject.ts:1193-1217`). Aggregate `success` + is false if ANY member failed. - Persist-OFF ordering for `ocx restore`/`eject`: `setIntegrationEnabled(false)` FIRST (so a crash mid-restore leaves intent durable and startup will not resurrect routing), then the async restore; the history job revalidates desired state under the lock per Amendment 1 before mutating. `restore back`/ `eject back` persist ON first, then sync — and a sync skip caused by a concurrent OFF prints 040's competing-OFF error with exit 2. -- `success` for the command means: config+profile restored AND history either - restored or classified (`busy` → retry advice, exit 1; `permission` → ACL - advice, exit 1). No path reports success with an unclassified history hole. +- `success` for the command means: config (incl. profile) AND catalog restored + AND history either restored or classified (`busy` → retry advice, exit 1; + `permission` → ACL advice, exit 1). No path reports success with an + unclassified hole in any artifact. ## Test impact (from the audit, folded in) From e5173821cf26a3723bc1562efcdd7ebba50f9f98 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 11:43:16 +0900 Subject: [PATCH 022/317] =?UTF-8?q?docs(devlog):=20wp4=20ledger=20?= =?UTF-8?q?=E2=80=94=20#1008=20rebased,=20thread=20fixes=20landed,=20pushe?= =?UTF-8?q?d=20unmerged?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- devlog/_plan/260806_disposition_sweep/040_1008_rebase.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/devlog/_plan/260806_disposition_sweep/040_1008_rebase.md b/devlog/_plan/260806_disposition_sweep/040_1008_rebase.md index 3fbef2b61e..f8f91f6890 100644 --- a/devlog/_plan/260806_disposition_sweep/040_1008_rebase.md +++ b/devlog/_plan/260806_disposition_sweep/040_1008_rebase.md @@ -46,3 +46,10 @@ Defer-with-rationale (redesign-scale or judgment): | Step | Evidence | |------|----------| +| Rebase | `codex/260804-usage-rollup` rebased onto `origin/dev` `b3a1d90a8` — clean, no conflicts (4 commits replayed) | +| Fix commits | `8e657f2a1` (segment cap + yield + malformed advance + read-time boundary + usageRollupEnabled + docs + test 2c), `49cb22c3d` (every-segment validation, id-less rows), `8d1eec899` (memoized validation per raw revision, documented digest-window contract) | +| terra audit rounds | FAIL(last-segment-only 4KB window, per-call cost) → fixed → FAIL(window scope, memoization) → fixed/rationale → PASS (finding 1 accepted as documented contract matching fold-time behavior) | +| Tests | focused 33 pass/0 fail; full suite 9088 pass / 0 fail / 8 skip (580 files); typecheck clean | +| Push | force-with-lease `b0d5417d8 → 8d1eec899`; PR #1008 OPEN, NOT merged | +| PR comment | 5199782814 (fixed list + deferred-with-rationale list) | +| log.ts overlap | PR touches `src/usage/log.ts`; user's uncommitted 500k-cap edits remain untouched in main checkout — flagged for pre-merge attention | From 1d02e2c264b24053e479a8ec1e8ec2b421708db5 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 11:44:23 +0900 Subject: [PATCH 023/317] feat(codex): expose native restore artifact outcomes --- src/codex/history-job.ts | 13 +- src/codex/history-provider.ts | 45 ++++-- src/codex/history-worker.ts | 11 +- src/codex/inject.ts | 205 +++++++++++++++++++-------- src/codex/internal/history-writer.ts | 2 +- 5 files changed, 193 insertions(+), 83 deletions(-) diff --git a/src/codex/history-job.ts b/src/codex/history-job.ts index 97a3970e50..c121487a27 100644 --- a/src/codex/history-job.ts +++ b/src/codex/history-job.ts @@ -24,6 +24,7 @@ import type { HistoryWorkerResult, } from "./history-worker"; import { historyBackupPathFor } from "./history-provider"; +import type { CodexHistoryFailureReason } from "./history-provider"; import { getCodexHome } from "./paths"; /** Where Codex keeps its resume history, and the manifest that shadows it. */ @@ -70,7 +71,7 @@ export type CodexHistoryJobOutcome = | { readonly kind: "skipped" } | { readonly kind: "blocked"; readonly reason: "busy" | "database" | "unsafe-path" } | { readonly kind: "failed"; readonly reason: "worker-error" | "worker-died" | "timeout"; - readonly message: string }; + readonly message: string; readonly historyFailureReason?: CodexHistoryFailureReason }; /** * Derive the durable history operation from admitted intent. @@ -115,7 +116,8 @@ function isPlausibleWorkerResult( case "blocked": return message.reason === "busy" || message.reason === "database" || message.reason === "unsafe-path"; case "error": - return typeof message.message === "string"; + return typeof message.message === "string" + && (message.reason === undefined || message.reason === "busy" || message.reason === "permission"); default: return false; } @@ -134,7 +136,12 @@ export function deriveCodexHistoryOperation(intent: { function classifyWorkerResult(result: HistoryWorkerResult): CodexHistoryJobOutcome { if (result.type === "blocked") return { kind: "blocked", reason: result.reason }; if (result.type === "error") { - return { kind: "failed", reason: "worker-error", message: result.message }; + return { + kind: "failed", + reason: "worker-error", + message: result.message, + ...(result.reason ? { historyFailureReason: result.reason } : {}), + }; } return result.outcome === "skipped" ? { kind: "skipped" } diff --git a/src/codex/history-provider.ts b/src/codex/history-provider.ts index 3916380cd5..46a21e9807 100644 --- a/src/codex/history-provider.ts +++ b/src/codex/history-provider.ts @@ -166,12 +166,16 @@ function patchFirstLineProviderInPlace(path: string, expectedId: string, provide export type CodexHistoryProvider = "openai" | "opencodex"; +export type CodexHistoryFailureReason = "busy" | "permission"; + export interface CodexHistorySyncResult { rows: number; files: number; ejectedRows?: number; /** Set when a lock/busy error survived retries and the sync was SKIPPED, not empty. */ failed?: true; + /** Why the retry budget was exhausted when `failed` is set. */ + failureReason?: CodexHistoryFailureReason; } interface ThreadRow { @@ -515,19 +519,24 @@ function ejectRemainingOpencodexHistory(db: Database): { rows: number; files: nu return { rows: rows.length, files }; } -export function isRecoverableHistoryError(error: unknown): boolean { +export function classifyRecoverableHistoryError(error: unknown): CodexHistoryFailureReason | null { const code = typeof error === "object" && error && "code" in error ? String((error as { code?: unknown }).code) : ""; const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase(); - return code === "SQLITE_BUSY" + if (code === "SQLITE_BUSY" || code === "SQLITE_LOCKED" || code === "EBUSY" - || code === "EPERM" - || code === "EACCES" || message.includes("database is locked") || message.includes("database is busy") - || message.includes("resource busy") + || message.includes("resource busy")) return "busy"; + if (code === "EPERM" + || code === "EACCES" || message.includes("operation not permitted") - || message.includes("permission denied"); + || message.includes("permission denied")) return "permission"; + return null; +} + +export function isRecoverableHistoryError(error: unknown): boolean { + return classifyRecoverableHistoryError(error) !== null; } const HISTORY_RETRY_DELAY_MS = 500; @@ -540,21 +549,29 @@ const HISTORY_RETRY_ATTEMPTS = 2; * error — callers surface that as `failed: true` instead of a silent no-op. Hard errors * (corruption, programming bugs) still throw. */ -export function withHistoryRetry(fn: () => T, io: { sleepFn?: (ms: number) => void; attempts?: number; delayMs?: number } = {}): T | null { +function withHistoryRetryResult(fn: () => T, io: { sleepFn?: (ms: number) => void; attempts?: number; delayMs?: number } = {}): + | { ok: true; value: T } + | { ok: false; reason: CodexHistoryFailureReason } { const sleepFn = io.sleepFn ?? Bun.sleepSync; const attempts = Math.max(1, io.attempts ?? HISTORY_RETRY_ATTEMPTS); const delayMs = io.delayMs ?? HISTORY_RETRY_DELAY_MS; for (let attempt = 0; ; attempt++) { try { - return fn(); + return { ok: true, value: fn() }; } catch (error) { - if (!isRecoverableHistoryError(error)) throw error; - if (attempt >= attempts - 1) return null; + const reason = classifyRecoverableHistoryError(error); + if (!reason) throw error; + if (attempt >= attempts - 1) return { ok: false, reason }; try { sleepFn(delayMs); } catch { /* sleep is best-effort */ } } } } +export function withHistoryRetry(fn: () => T, io: { sleepFn?: (ms: number) => void; attempts?: number; delayMs?: number } = {}): T | null { + const result = withHistoryRetryResult(fn, io); + return result.ok ? result.value : null; +} + /** * True when a READONLY probe proves the openai-direction restore would be a no-op: * zero threads still tagged opencodex AND an empty backup manifest. Used to skip the @@ -581,8 +598,8 @@ export function syncCodexHistoryProvider( && openaiRestoreIsNoop(stateDbPath, backupPath)) { return { rows: 0, files: 0 }; } - return withHistoryRetry(() => syncCodexHistoryProviderUnsafe(provider, stateDbPath, backupPath)) - ?? { rows: 0, files: 0, failed: true }; + const retried = withHistoryRetryResult(() => syncCodexHistoryProviderUnsafe(provider, stateDbPath, backupPath)); + return retried.ok ? retried.value : { rows: 0, files: 0, failed: true, failureReason: retried.reason }; } function syncCodexHistoryProviderUnsafe(provider: CodexHistoryProvider, stateDbPath: string, backupPath: string): CodexHistorySyncResult { @@ -734,8 +751,8 @@ export function migrateHistoryToOpenai( // nothing. A missing DB with a leftover backup manifest does NOT satisfy the gate // (backupEntries > 0), so the guardian's fresh-reinstall re-count protection holds. if (openaiRestoreIsNoop(stateDbPath, backupPath)) return { rows: 0, files: 0 }; - return withHistoryRetry(() => syncCodexHistoryProviderUnsafe("openai", stateDbPath, backupPath), opts) - ?? { rows: 0, files: 0, failed: true }; + const retried = withHistoryRetryResult(() => syncCodexHistoryProviderUnsafe("openai", stateDbPath, backupPath), opts); + return retried.ok ? retried.value : { rows: 0, files: 0, failed: true, failureReason: retried.reason }; } export interface PendingHistoryCount { diff --git a/src/codex/history-worker.ts b/src/codex/history-worker.ts index 584177e7fe..3e1ee3a653 100644 --- a/src/codex/history-worker.ts +++ b/src/codex/history-worker.ts @@ -29,6 +29,7 @@ import { writeLegacyOpenaiHistoryRecovery, type HistoryWriteTarget, } from "./internal/history-writer"; +import type { CodexHistoryFailureReason } from "./history-provider"; /** * The durable operation, mirrored into the request for diagnostics only. @@ -64,7 +65,7 @@ export type HistoryWorkerResult = | { readonly type: "blocked"; readonly requestId: string; readonly jobId: string; readonly reason: "busy" | "database" | "unsafe-path" } | { readonly type: "error"; readonly requestId: string; readonly jobId: string; - readonly message: string }; + readonly message: string; readonly reason?: CodexHistoryFailureReason }; const OPERATIONS: ReadonlySet = new Set([ "skip", @@ -135,7 +136,13 @@ export function runHistoryUnitUnderLock( } const result = acquired.value; if (result.failed === true) { - return { type: "error", requestId, jobId, message: "history_transition_failed" }; + return { + type: "error", + requestId, + jobId, + message: "history_transition_failed", + ...(result.failureReason ? { reason: result.failureReason } : {}), + }; } return { type: "done", diff --git a/src/codex/inject.ts b/src/codex/inject.ts index 0e5304a648..9e6e064cbf 100644 --- a/src/codex/inject.ts +++ b/src/codex/inject.ts @@ -34,7 +34,7 @@ import { } from "./journal"; import { withCatalogWriteSerialization } from "./catalog-write-serialization"; import { restoreCodexCatalogWithPermit } from "./catalog/sync"; -import { syncCodexHistoryProvider } from "./history-provider"; +import { syncCodexHistoryProvider, type CodexHistoryFailureReason } from "./history-provider"; import { deriveCodexHistoryOperation, resolveCodexHistoryJobTarget, @@ -1179,71 +1179,140 @@ export function removeCodexConfig( }; } -/** - * Recover native Codex: strip opencodex from config.toml AND drop proxy-routed catalog entries, - * so plain `codex` works when the proxy is stopped. Called by `ocx stop`, the proxy shutdown - * handler, and `ocx restore`. Idempotent + atomic. - */ -/** - * Restore native Codex, running history in a Worker under H. - * - * Prefer this everywhere. The synchronous variant below exists only for the - * process-exit path, where awaiting a thread is its own hazard. - */ -export async function restoreNativeCodexAsync(): Promise<{ +export type CodexRestoreArtifactState = "ok" | "skipped" | "failed"; + +export interface CodexRestoreConfigResult { + state: CodexRestoreArtifactState; + changed: boolean; + action: "journal-restored" | "owned-fields-stripped" | "external-provider-preserved" | "failed"; + message: string; +} + +export interface CodexRestoreCatalogResult { + state: CodexRestoreArtifactState; + changed: boolean; + removed: number; + kept: number; + path: string | null; + message: string; +} + +export interface CodexRestoreHistoryResult { + state: CodexRestoreArtifactState; + changed: boolean; + reason?: CodexHistoryFailureReason; + rows: number; + files: number; + ejectedRows: number; + message: string; +} + +export interface CodexNativeRestoreResult { success: boolean; message: string; -}> { + externalProvider?: string; + artifacts: { + config: CodexRestoreConfigResult; + catalog: CodexRestoreCatalogResult; + history: CodexRestoreHistoryResult; + }; +} + +function failedHistoryRestore(reason?: CodexHistoryFailureReason): CodexRestoreHistoryResult { + return { + state: "failed", + changed: false, + ...(reason ? { reason } : {}), + rows: 0, + files: 0, + ejectedRows: 0, + message: reason === "permission" + ? "Codex resume history could NOT be restored because permission was denied." + : "Codex resume history could NOT be restored — the Codex app appears to be holding the history database.", + }; +} + +/** Restore native Codex, running history in a Worker under H. */ +export async function restoreNativeCodexAsync(): Promise { const inline = restoreNativeCodex({ skipHistory: true }); const outcome = await runCodexHistoryJob({ ...resolveCodexHistoryJobTarget(), - operation: deriveCodexHistoryOperation({ - direction: "restore", - // Restore always returns history to native when it runs at all; the - // opt-out belongs to apply, which is what put opencodex there. - resumeHistory: true, - legacyMode: false, - }), + operation: deriveCodexHistoryOperation({ direction: "restore", resumeHistory: true, legacyMode: false }), }); - const historyMsg = - outcome.kind === "converged" - ? outcome.rows > 0 - ? ` Resume history restored from opencodex backup (${outcome.rows} thread(s)).` - : "" - : outcome.kind === "skipped" - ? "" - : // A lock we could not take is reported, never counted as nothing to do. - ` ⚠️ Codex resume history could NOT be restored — the Codex app appears to be holding the history database. Close Codex and run \`ocx restore\` again.`; - return { success: inline.success, message: `${inline.message}${historyMsg}` }; + const history: CodexRestoreHistoryResult = outcome.kind === "converged" + ? { + state: "ok", changed: outcome.rows > 0, rows: outcome.rows, files: outcome.files, ejectedRows: 0, + message: outcome.rows > 0 + ? `Resume history restored from opencodex backup (${outcome.rows} thread(s)).` + : "Codex resume history was already native.", + } + : outcome.kind === "skipped" + ? { state: "skipped", changed: false, rows: 0, files: 0, ejectedRows: 0, message: "Codex resume history was skipped." } + : outcome.kind === "blocked" && outcome.reason === "busy" + ? failedHistoryRestore("busy") + : outcome.kind === "failed" + ? failedHistoryRestore(outcome.historyFailureReason) + : failedHistoryRestore(); + const success = inline.artifacts.config.state !== "failed" + && inline.artifacts.catalog.state !== "failed" + && history.state !== "failed"; + return { + ...inline, + success, + message: `${inline.message}${history.state === "failed" ? ` ⚠️ ${history.message}` : ""}`, + artifacts: { ...inline.artifacts, history }, + }; } -export function restoreNativeCodex(options: { skipHistory?: boolean } = {}): { - success: boolean; - message: string; -} { +export function restoreNativeCodex(options: { skipHistory?: boolean } = {}): CodexNativeRestoreResult { const activeProvider = currentExternalCodexModelProvider(); if (activeProvider) { removeJournal(); + const message = `External Codex provider ${tomlString(activeProvider)} preserved; no native restore was needed.`; return { success: true, - message: `External Codex provider ${tomlString(activeProvider)} preserved; no native restore was needed.`, + message, + externalProvider: activeProvider, + artifacts: { + config: { state: "skipped", changed: false, action: "external-provider-preserved", message }, + catalog: { state: "skipped", changed: false, removed: 0, kept: 0, path: null, message }, + history: { state: "skipped", changed: false, rows: 0, files: 0, ejectedRows: 0, message }, + }, }; } - const journal = restoreJournalState(); - const cfg = journal.configRestored - ? { - success: true, - message: "Codex config restored from opencodex journal.", - } - : removeCodexConfig({ - preserveProfile: journal.profileRestored || journal.profileChanged, - }); + let config: CodexRestoreConfigResult; + try { + const journal = restoreJournalState(); + const restored = journal.configRestored + ? { success: true, message: "Codex config restored from opencodex journal." } + : removeCodexConfig({ preserveProfile: journal.profileRestored || journal.profileChanged }); + config = restored.success + ? { + state: "ok", + changed: journal.configRestored || journal.profileRestored || journal.profileChanged || restored.message.startsWith("Removed"), + action: journal.configRestored ? "journal-restored" : "owned-fields-stripped", + message: restored.message, + } + : { state: "failed", changed: false, action: "failed", message: restored.message }; + } catch (error) { + config = { state: "failed", changed: false, action: "failed", message: error instanceof Error ? error.message : String(error) }; + } const owningCodexHome = getCodexHome(); - const restoredCatalog = withCatalogWriteSerialization(owningCodexHome, permit => restoreCodexCatalogWithPermit(permit, owningCodexHome)); - const cat = - restoredCatalog.kind === "completed" - ? restoredCatalog.value - : { removed: 0, kept: 0, path: DEFAULT_CATALOG_PATH }; + let catalog: CodexRestoreCatalogResult; + try { + const restored = withCatalogWriteSerialization(owningCodexHome, permit => restoreCodexCatalogWithPermit(permit, owningCodexHome)); + catalog = restored.kind === "completed" + ? { state: "ok", changed: restored.value.removed > 0, ...restored.value, message: "Codex catalog restored." } + : { + state: "failed", changed: false, removed: 0, kept: 0, path: DEFAULT_CATALOG_PATH, + message: `Codex catalog could not be restored: ${restored.reason}.`, + }; + } catch (error) { + catalog = { + state: "failed", changed: false, removed: 0, kept: 0, path: DEFAULT_CATALOG_PATH, + message: error instanceof Error ? error.message : String(error), + }; + } // Design B (loopback) steady state: threads are already tagged openai, so prove the // no-op with a readonly probe instead of write-opening a DB the Codex app may hold // (Windows: WAL writer lock -> seconds of stalling + a false warning on every stop). @@ -1256,23 +1325,33 @@ export function restoreNativeCodex(options: { skipHistory?: boolean } = {}): { } // `skipHistory` is how the async wrapper takes this work for itself: the // native files come down here, and history runs in the Worker under H. - const history = options.skipHistory + const rawHistory = options.skipHistory ? { rows: 0, files: 0 } : syncCodexHistoryProvider("openai", undefined, undefined, { skipWhenProvablyNoop, }); - const msg = - cat.removed > 0 - ? `${cfg.message} Catalog restored to ${cat.kept} native model(s) (dropped ${cat.removed} proxy-routed).` - : cfg.message; - const historyMsg = history.failed - ? ` ⚠️ Codex resume history could NOT be restored — the Codex app appears to be holding the history DB. Close the Codex app/IDE and run 'ocx stop' again; until then routed threads stay hidden in the native app.` - : history.rows > 0 - ? ` Resume history restored from opencodex backup (${history.rows} thread(s)).` - : history.ejectedRows - ? ` ${history.ejectedRows} opencodex history thread(s) were ejected to openai so native Codex can resume them.` - : ""; - return { success: cfg.success, message: `${msg}${historyMsg}` }; + const history: CodexRestoreHistoryResult = options.skipHistory + ? { state: "skipped", changed: false, rows: 0, files: 0, ejectedRows: 0, message: "History restoration runs asynchronously." } + : rawHistory.failed + ? failedHistoryRestore(rawHistory.failureReason) + : { + state: "ok", + changed: rawHistory.rows > 0 || (rawHistory.ejectedRows ?? 0) > 0, + rows: rawHistory.rows, + files: rawHistory.files, + ejectedRows: rawHistory.ejectedRows ?? 0, + message: rawHistory.rows > 0 + ? `Resume history restored from opencodex backup (${rawHistory.rows} thread(s)).` + : "Codex resume history was already native.", + }; + const message = catalog.removed > 0 + ? `${config.message} Catalog restored to ${catalog.kept} native model(s) (dropped ${catalog.removed} proxy-routed).` + : config.message; + return { + success: config.state !== "failed" && catalog.state !== "failed" && history.state !== "failed", + message, + artifacts: { config, catalog, history }, + }; } export function getCodexConfigPath(): string { diff --git a/src/codex/internal/history-writer.ts b/src/codex/internal/history-writer.ts index d36017644f..7ae98dfe82 100644 --- a/src/codex/internal/history-writer.ts +++ b/src/codex/internal/history-writer.ts @@ -74,7 +74,7 @@ export function writeHistoryProviderTransition( export function writeLegacyOpenaiHistoryRecovery( permit: HistoryWritePermit, target: HistoryWriteTarget, -): { rows: number; files: number; failed?: true } { +): CodexHistorySyncResult { assertHistoryWritePermit(permit, target.canonicalStateDbPath); return restoreLegacyOpenaiHistory(target.canonicalStateDbPath); } From 99b3b21207242362cb54a964d8978b7594a52028 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 11:46:16 +0900 Subject: [PATCH 024/317] =?UTF-8?q?docs(devlog):=20wp5=20final=20ledger=20?= =?UTF-8?q?=E2=80=94=2010-item=20live=20end-state=20snapshot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_plan/260806_disposition_sweep/050_closeout.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/devlog/_plan/260806_disposition_sweep/050_closeout.md b/devlog/_plan/260806_disposition_sweep/050_closeout.md index 120b737a43..365ace68ec 100644 --- a/devlog/_plan/260806_disposition_sweep/050_closeout.md +++ b/devlog/_plan/260806_disposition_sweep/050_closeout.md @@ -14,3 +14,17 @@ | Item | Disposition | Live evidence | |------|-------------|---------------| +| 1. PR #1036 (+#1017) | request-changes review posted; approach endorsed | OPEN / CHANGES_REQUESTED | +| 2. issue #919 | closed as intended-policy/enhancement, reopen path stated | CLOSED / NOT_PLANNED | +| 3a. issue #1090 | regression test landed on sweep branch (b63e86a8b, red-ablation proven); kept OPEN with status comment 5199554901 (absorption unproven for profile-masking shape) | OPEN | +| 3b. issue #1091 | status comment 5199487703 (design-needed, security-sensitive) | OPEN | +| 4. PR #1068 (+#994) | rebase-request + e2e-regression comment 5199487780 | OPEN | +| 5. PR #936 (own) | merged dev in (a90981e67), terra security audit PASS, fixes 4874390dd, full suite 9076/0, pushed; comment 5199634303 | OPEN draft, head 4874390dd, NOT merged | +| 6. issue #1059 | shard burn-down status comment 5199487879 | OPEN | +| 7. PR #1008 (own) | rebased onto dev, thread fixes 8e657f2a1/49cb22c3d/8d1eec899, terra 3-round PASS, 9088/0, lease-pushed; comment 5199782814 | OPEN, head 8d1eec899, NOT merged | +| 8. PR #1019 | split-request comment 5199488679 posted; author chrisae9 closed the PR himself at 02:03Z (his decision, not ours) | CLOSED by author | +| 9. PRs #1084/#1083/#1081/#1079/#1077 | closed with verified defect lists + reopen invitations; issues #1062/#1063/#1060/#1058/#1076/#1082 policy comments 5199492623-5199493056, kept open | all 5 PRs CLOSED, 6 issues OPEN | +| 10. PR #1085 / #997 | security-pass comment 5199488762 / rebase-request comment 5199488854 | both OPEN | + +Snapshot taken 2026-08-06 ~02:20Z via `gh` per-item queries. Constraint held: +no merges into dev anywhere in this loop; own-PR lanes ended at pushed+open. From 96191c6ff24727e266b83f954e1c1a132fd7660f Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 11:46:20 +0900 Subject: [PATCH 025/317] feat(codex): distinguish skipped sync outcomes --- src/cli/index.ts | 20 +++++++++++++++---- src/cli/models.ts | 6 +++++- src/cli/provider.ts | 10 ++++++++-- src/codex/desired-state.ts | 7 ++++++- src/codex/sync.ts | 19 ++++++++++++++++++ src/server/management/config-routes.ts | 3 ++- .../management/native-integration-routes.ts | 8 ++++++++ 7 files changed, 64 insertions(+), 9 deletions(-) diff --git a/src/cli/index.ts b/src/cli/index.ts index fdcec562d2..bfc922f535 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -318,6 +318,7 @@ async function handleStart(options: { block?: boolean } = {}) { await maybeShowStarPrompt(); // once-only Yes/No GitHub-star prompt on first interactive start const startupSync = await syncCodexOnStartIfEnabled(port, config); + if (!startupSync.ran) console.log(" Codex integration OFF; startup left Codex native."); // #1046: one warning per startup, after BOTH writes. The server's cache // invalidation happens first and the catalog sync second, so the mtime is only // final here — and neither write site warns on its own, or a boot that hits @@ -376,10 +377,12 @@ async function handleEnsure() { return; } const live = await findLiveProxy(); - if (live) { - await syncModelsToCodex(live.port).catch(e => { + if (live) { + const synced = await syncModelsToCodex(live.port).catch(e => { console.error(`⚠️ Model sync skipped: ${e instanceof Error ? e.message : String(e)}`); + return null; }); + if (synced?.status === "skipped") console.log(" Codex integration OFF; startup left Codex native."); // Ensure env file exists for already-running proxy (may have been deleted or pre-dates this feature). await injectSystemEnv(live.port, config).catch(() => {}); // Refresh the Grok Build fence too (same contract as start). live.hostname is the @@ -419,9 +422,11 @@ async function handleEnsure() { } catch (err) { console.error(`⚠️ ${grokSyncFailureMessage(err)}`); } // Always sync the LIVE port: after a fallback-port start, config.port still names the // busy preferred port — syncing that would point Codex at a dead listener. - await syncModelsToCodex(port).catch(e => { + const synced = await syncModelsToCodex(port).catch(e => { console.error(`⚠️ Model sync skipped: ${e instanceof Error ? e.message : String(e)}`); + return null; }); + if (synced?.status === "skipped") console.log(" Codex integration OFF; startup left Codex native."); console.log(`✅ Proxy running on port ${port}`); } @@ -783,6 +788,11 @@ switch (command) { process.exit(1); } const synced = await syncModelsToCodex(live.port); + if (synced.status === "skipped") { + process.exitCode = 2; + console.error("Codex integration is OFF; restore back did not change Codex. Retry after the competing integration change finishes."); + break; + } if (!synced.ok) { process.exitCode = 1; console.error("Plain `codex` was not switched back to opencodex. Fix the reported Codex config issue and retry."); @@ -856,7 +866,9 @@ switch (command) { case "sync": { const restartCodex = args.slice(1).includes("--restart-codex"); const synced = await syncModelsToCodex((await findLiveProxy())?.port); - if (!synced.ok) { + if (synced.status === "skipped") { + console.log("Codex integration is OFF; sync skipped and no Codex files changed."); + } else if (!synced.ok) { process.exitCode = 1; console.error("Codex sync did not complete. Fix the reported Codex config issue and retry."); } diff --git a/src/cli/models.ts b/src/cli/models.ts index d3f3c11fe8..11787e9bcc 100644 --- a/src/cli/models.ts +++ b/src/cli/models.ts @@ -102,9 +102,13 @@ function rejectUnexpectedArgs(args: string[], usage: string): void { async function syncCustomModelsIfLive(): Promise { const live = await findLiveProxy(); if (!live) return; - await syncModelsToCodex(live.port).catch(error => { + const synced = await syncModelsToCodex(live.port).catch(error => { console.error(`Warning: custom model saved, but catalog sync failed: ${error instanceof Error ? error.message : String(error)}`); + return null; }); + if (synced?.status === "skipped") { + console.log("Custom model saved; Codex integration is OFF, so its catalog was not changed."); + } } async function handleCustomAdd(args: string[]): Promise { diff --git a/src/cli/provider.ts b/src/cli/provider.ts index 410c80dd18..34eb1cd707 100644 --- a/src/cli/provider.ts +++ b/src/cli/provider.ts @@ -229,12 +229,18 @@ async function handleAdd(args: string[]): Promise { return; } + let codexSyncSkipped = false; if (wantsSync) { const live = await findLiveProxy(); if (live) { - await syncModelsToCodex(live.port).catch(e => { + const synced = await syncModelsToCodex(live.port).catch(e => { console.error(`Warning: sync failed: ${e instanceof Error ? e.message : String(e)}`); + return null; }); + if (synced?.status === "skipped") { + codexSyncSkipped = true; + console.log("Provider saved; Codex integration is OFF, so Codex sync was skipped."); + } } } @@ -249,7 +255,7 @@ async function handleAdd(args: string[]): Promise { console.log(` Set API key with: ocx provider add ${name} --api-key --force`); console.log(` Or set env var: ${envKey}`); } - if (wantsSync) { + if (wantsSync && !codexSyncSkipped) { console.log(` Models synced to Codex.`); } else { console.log(` Apply to Codex: ocx sync`); diff --git a/src/codex/desired-state.ts b/src/codex/desired-state.ts index f75f261b31..3512e82128 100644 --- a/src/codex/desired-state.ts +++ b/src/codex/desired-state.ts @@ -66,6 +66,11 @@ export function codexIntegrationEnabled(config: Pick): boolean { + return codexIntegrationEnabled(config); +} + /** * Grok's toggle SHIPPED without this, which is the bug: it strips the fence in * `~/.grok/config.toml` and records nothing, so the next `ocx start` calls @@ -162,7 +167,7 @@ export async function syncCodexOnStartIfEnabled( config: Pick, sync: CodexStartupSync = defaultStartupSync, ): Promise<{ ran: boolean; catalogWritten: boolean; cacheSynced: boolean }> { - if (!codexIntegrationEnabled(config)) { + if (!shouldSyncCodexOnStart(config)) { return { ran: false, catalogWritten: false, cacheSynced: false }; } // The `.catch` is deliberate and stays: a failure to APPLY must not stop the diff --git a/src/codex/sync.ts b/src/codex/sync.ts index 219d3f6a2d..ab3234ce3a 100644 --- a/src/codex/sync.ts +++ b/src/codex/sync.ts @@ -5,9 +5,13 @@ import { applyProxyEnv, loadConfig } from "../config"; import type { OcxConfig } from "../types"; import { collectOrcaCodexHomeDiagnostic } from "./home"; import { summarizeComboCatalogOmissions, type ComboCatalogOmission } from "./catalog/aggregation"; +import { shouldSyncCodexOnStart } from "./desired-state"; export interface CodexSyncResult { + /** `skipped` is policy truth, never evidence that Codex was written. */ + status: "applied" | "skipped"; ok: boolean; + skippedReason?: "desired_disabled"; added: number; catalogPath: string | null; catalogExists: boolean; @@ -52,6 +56,19 @@ export async function syncModelsToCodex( log: Pick | null = console, deps: CodexSyncDeps = defaultDeps, ): Promise { + if (!shouldSyncCodexOnStart(config)) { + return { + status: "skipped", + skippedReason: "desired_disabled", + ok: true, + added: 0, + catalogPath: null, + catalogExists: false, + catalogWritten: false, + cacheSynced: false, + message: "Codex integration is OFF; no Codex config, catalog, cache, or history was changed.", + }; + } const p = port ?? config.port ?? 10100; const externalProvider = (deps.currentExternalCodexModelProvider ?? currentExternalCodexModelProvider)(); if (externalProvider) { @@ -59,6 +76,7 @@ export async function syncModelsToCodex( log?.log(result.message); reportCodexHomeTarget(log, deps.collectCodexHomeDiagnostic ?? collectOrcaCodexHomeDiagnostic); return { + status: "applied", ok: result.success, added: 0, catalogPath: null, @@ -112,6 +130,7 @@ export async function syncModelsToCodex( reportCodexHomeTarget(log, deps.collectCodexHomeDiagnostic ?? collectOrcaCodexHomeDiagnostic); const projectConfigWarnings = printProjectCodexConfigWarnings(log, { cwd: process.cwd() }); return { + status: "applied", ok: result.success, added, catalogPath, diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index b351692831..77f06ea310 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -262,10 +262,11 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise { const port = runtime?.port ?? ctx.config.port; const { syncModelsToCodex } = await import("../../codex/sync"); const applied = await syncModelsToCodex(port); + if (applied.status === "skipped") { + return jsonResponse({ + ok: true, clientId: "codex", changed: durable && persisted.status === "committed", + state: "absent", + message: "Codex integration is OFF; enable did not change Codex.", + reason: "apply_incomplete", + } satisfies NativeToggleEnvelope); + } return jsonResponse({ ok: true, clientId: "codex", changed: durable && persisted.status === "committed", state: applied.ok ? "current" : "absent", From 860b5f87e4a1ced242893c07135bfcb7c59c5b87 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 11:47:34 +0900 Subject: [PATCH 026/317] docs(devlog): record sweep PR #1097 in the closeout --- devlog/_plan/260806_disposition_sweep/050_closeout.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/devlog/_plan/260806_disposition_sweep/050_closeout.md b/devlog/_plan/260806_disposition_sweep/050_closeout.md index 365ace68ec..afc68a705a 100644 --- a/devlog/_plan/260806_disposition_sweep/050_closeout.md +++ b/devlog/_plan/260806_disposition_sweep/050_closeout.md @@ -28,3 +28,11 @@ Snapshot taken 2026-08-06 ~02:20Z via `gh` per-item queries. Constraint held: no merges into dev anywhere in this loop; own-PR lanes ended at pushed+open. + +## Sweep PR + +Branch pushed and PR opened against dev, left unmerged per user constraint: +https://github.com/lidge-jun/opencodex/pull/1097 (head `99b3b2120` + this +commit). Final audit: terra PASS (finding on phantom production commit +retracted with ancestry evidence; sweep range = 7 devlog commits + the +#1090 test commit). From e4831714b96bdff22404736eb16b5ed43ddd1ec0 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 11:49:09 +0900 Subject: [PATCH 027/317] feat(codex): revalidate desired state under artifact locks --- src/codex/codex-write-lock.ts | 12 +++++ src/codex/history-job.ts | 7 ++- src/codex/history-worker.ts | 21 ++++++++- src/codex/inject-coordination.ts | 12 ++++- src/codex/inject.ts | 45 ++++++++++++++++--- src/codex/sync.ts | 13 ++++++ .../management/native-integration-routes.ts | 2 +- 7 files changed, 100 insertions(+), 12 deletions(-) diff --git a/src/codex/codex-write-lock.ts b/src/codex/codex-write-lock.ts index 07ae7e17f0..4b429a9cb9 100644 --- a/src/codex/codex-write-lock.ts +++ b/src/codex/codex-write-lock.ts @@ -66,6 +66,7 @@ export type CodexWriteLockRefusalReason = export type CodexWriteLockResult = | { status: "acquired"; value: T; waitedMs: number; lockId: string } + | { status: "skipped"; reason: "desired_disabled"; waitedMs: number } | { status: "busy"; reason: "deadline" | "cancelled"; retryable: true; waitedMs: number } | { status: "refused"; @@ -124,6 +125,14 @@ export interface CodexWriteCommitContext { readonly coordinator: CodexCoordinatorTransaction; } +/** A synchronous under-lock policy re-read proved the requested apply stale. */ +export class CodexWriteLockSkipped extends Error { + constructor(readonly reason: "desired_disabled") { + super(reason); + this.name = "CodexWriteLockSkipped"; + } +} + /** Rejects an `async` callback at typecheck; a cast thenable is caught at runtime. */ type Synchronous = T extends PromiseLike ? never : T; @@ -345,6 +354,9 @@ export async function withCodexWriteLock( return { status: "acquired", value: value as T, waitedMs: waited(), lockId: target.lockId }; } catch (error) { transaction.rollback(); + if (error instanceof CodexWriteLockSkipped) { + return { status: "skipped", reason: error.reason, waitedMs: waited() }; + } if (error instanceof CodexWriteLockStaleAdmission) { return refuse("authority_not_proven", "The admitted state changed before the commit could be made under the lock."); diff --git a/src/codex/history-job.ts b/src/codex/history-job.ts index c121487a27..fe0c159629 100644 --- a/src/codex/history-job.ts +++ b/src/codex/history-job.ts @@ -64,12 +64,13 @@ export interface CodexHistoryJobRequest { readonly canonicalStateDbPath: string; readonly canonicalBackupPath: string; readonly operation: CodexHistoryWorkerOperation; + readonly expectedDesiredEnabled?: boolean; } export type CodexHistoryJobOutcome = | { readonly kind: "converged"; readonly rows: number; readonly files: number } | { readonly kind: "skipped" } - | { readonly kind: "blocked"; readonly reason: "busy" | "database" | "unsafe-path" } + | { readonly kind: "blocked"; readonly reason: "busy" | "database" | "unsafe-path" | "desired_disabled" | "desired_enabled" } | { readonly kind: "failed"; readonly reason: "worker-error" | "worker-died" | "timeout"; readonly message: string; readonly historyFailureReason?: CodexHistoryFailureReason }; @@ -114,7 +115,8 @@ function isPlausibleWorkerResult( && typeof message.rows === "number" && typeof message.files === "number"; case "blocked": - return message.reason === "busy" || message.reason === "database" || message.reason === "unsafe-path"; + return message.reason === "busy" || message.reason === "database" || message.reason === "unsafe-path" + || message.reason === "desired_disabled" || message.reason === "desired_enabled"; case "error": return typeof message.message === "string" && (message.reason === undefined || message.reason === "busy" || message.reason === "permission"); @@ -255,6 +257,7 @@ export async function runCodexHistoryJob( canonicalCodexHome: request.canonicalCodexHome, canonicalStateDbPath: request.canonicalStateDbPath, canonicalBackupPath: request.canonicalBackupPath, + ...(request.expectedDesiredEnabled === undefined ? {} : { expectedDesiredEnabled: request.expectedDesiredEnabled }), env: { ...(process.env.CODEX_HOME ? { CODEX_HOME: process.env.CODEX_HOME } : {}), ...(process.env.OPENCODEX_HOME ? { OPENCODEX_HOME: process.env.OPENCODEX_HOME } : {}), diff --git a/src/codex/history-worker.ts b/src/codex/history-worker.ts index 3e1ee3a653..35bf9e5bf5 100644 --- a/src/codex/history-worker.ts +++ b/src/codex/history-worker.ts @@ -24,6 +24,8 @@ * Design record: devlog/_fin/260804_codex_write_substrate/020_history_isolation.md. */ import { withHistoryWriteSerialization } from "./history-lock"; +import { loadConfig } from "../config"; +import { shouldSyncCodexOnStart } from "./desired-state"; import { writeHistoryProviderTransition, writeLegacyOpenaiHistoryRecovery, @@ -54,6 +56,8 @@ export interface HistoryWorkerRunMessage { readonly canonicalCodexHome: string; readonly canonicalStateDbPath: string; readonly canonicalBackupPath: string; + /** When set, prove this transition's desired direction while H is held. */ + readonly expectedDesiredEnabled?: boolean; /** Env snapshot: a Worker may not observe parent mutations on every platform. */ readonly env?: { readonly CODEX_HOME?: string; readonly OPENCODEX_HOME?: string }; } @@ -63,7 +67,7 @@ export type HistoryWorkerResult = readonly outcome: "converged" | "skipped"; readonly rows: number; readonly files: number } | { readonly type: "blocked"; readonly requestId: string; readonly jobId: string; - readonly reason: "busy" | "database" | "unsafe-path" } + readonly reason: "busy" | "database" | "unsafe-path" | "desired_disabled" | "desired_enabled" } | { readonly type: "error"; readonly requestId: string; readonly jobId: string; readonly message: string; readonly reason?: CodexHistoryFailureReason }; @@ -94,7 +98,8 @@ export function isHistoryWorkerRunMessage(data: unknown): data is HistoryWorkerR && OPERATIONS.has(message.operation) && nonEmpty(message.canonicalCodexHome) && nonEmpty(message.canonicalStateDbPath) - && nonEmpty(message.canonicalBackupPath); + && nonEmpty(message.canonicalBackupPath) + && (message.expectedDesiredEnabled === undefined || typeof message.expectedDesiredEnabled === "boolean"); } /** @@ -121,6 +126,10 @@ export function runHistoryUnitUnderLock( message.canonicalCodexHome, message.canonicalStateDbPath, permit => { + if (message.expectedDesiredEnabled !== undefined + && shouldSyncCodexOnStart(loadConfig()) !== message.expectedDesiredEnabled) { + return { desiredStateChanged: true as const }; + } if (operation === "recover-legacy-openai") { return writeLegacyOpenaiHistoryRecovery(permit, target); } @@ -135,6 +144,14 @@ export function runHistoryUnitUnderLock( return { type: "blocked", requestId, jobId, reason: acquired.reason }; } const result = acquired.value; + if ("desiredStateChanged" in result) { + return { + type: "blocked", + requestId, + jobId, + reason: message.expectedDesiredEnabled ? "desired_disabled" : "desired_enabled", + }; + } if (result.failed === true) { return { type: "error", diff --git a/src/codex/inject-coordination.ts b/src/codex/inject-coordination.ts index 28c58f8b02..28e3a902db 100644 --- a/src/codex/inject-coordination.ts +++ b/src/codex/inject-coordination.ts @@ -229,7 +229,17 @@ export function recomputeInjectWitness(options: { /** Project a non-acquired lock result into the injection result shape. */ export function codexInjectLockOutcome( result: Exclude, { status: "acquired" }>, -): { success: false; message: string; retryable: boolean } { +): { success: false; message: string; retryable: boolean } | { + success: true; status: "skipped"; skippedReason: "desired_disabled"; message: string; +} { + if (result.status === "skipped") { + return { + success: true, + status: "skipped", + skippedReason: result.reason, + message: "Codex integration is OFF; no Codex config, catalog, cache, or history was changed.", + }; + } if (result.status === "busy") { return { success: false, diff --git a/src/codex/inject.ts b/src/codex/inject.ts index 9e6e064cbf..eec98e609c 100644 --- a/src/codex/inject.ts +++ b/src/codex/inject.ts @@ -7,7 +7,8 @@ import { subagentDefaultSyncEffective, websocketsEnabled, } from "../config"; -import { withCodexWriteLock } from "./codex-write-lock"; +import { CodexWriteLockSkipped, withCodexWriteLock } from "./codex-write-lock"; +import { shouldSyncCodexOnStart } from "./desired-state"; import { resolveCodexHistoryTransition } from "./history-transition"; import { buildInjectWitness, @@ -618,6 +619,8 @@ export function chooseCatalogPathForInjection( export interface CodexInjectResult { success: boolean; message: string; + status?: "skipped"; + skippedReason?: "desired_disabled"; nativeSubagentDefaultsWarning?: string; } @@ -866,6 +869,14 @@ export async function injectCodexConfig( if (eligibility.kind === "legacy-uncoordinated") { // Unchanged behavior for homes the coordinator cannot yet adopt. Stated // rather than implied: this is the boundary, and adoption is its own phase. + if (!shouldSyncCodexOnStart(loadConfig())) { + return { + success: true, + status: "skipped", + skippedReason: "desired_disabled", + message: "Codex integration is OFF; no Codex config, catalog, cache, or history was changed.", + }; + } applyNativeArtifacts(); } else { const coordinated = await withCodexWriteLock( @@ -883,6 +894,9 @@ export async function injectCodexConfig( }), }, (ctx) => { + if (!shouldSyncCodexOnStart(loadConfig())) { + throw new CodexWriteLockSkipped("desired_disabled"); + } /* * Publish BEFORE touching the filesystem. `assertPublished` runs after this * callback returns and throws unless a transition was recorded, so writing @@ -965,6 +979,7 @@ export async function injectCodexConfig( // handed down fixed; the Worker never takes a direction from its caller. const historyOutcome = await runCodexHistoryJob({ ...resolveCodexHistoryJobTarget(), + expectedDesiredEnabled: true, operation: deriveCodexHistoryOperation({ direction: "apply", resumeHistory: config?.syncResumeHistory !== false, @@ -1233,10 +1248,13 @@ function failedHistoryRestore(reason?: CodexHistoryFailureReason): CodexRestoreH } /** Restore native Codex, running history in a Worker under H. */ -export async function restoreNativeCodexAsync(): Promise { - const inline = restoreNativeCodex({ skipHistory: true }); +export async function restoreNativeCodexAsync( + options: { revalidateDesiredState?: boolean } = {}, +): Promise { + const inline = restoreNativeCodex({ skipHistory: true, revalidateDesiredState: options.revalidateDesiredState }); const outcome = await runCodexHistoryJob({ ...resolveCodexHistoryJobTarget(), + ...(options.revalidateDesiredState ? { expectedDesiredEnabled: false } : {}), operation: deriveCodexHistoryOperation({ direction: "restore", resumeHistory: true, legacyMode: false }), }); const history: CodexRestoreHistoryResult = outcome.kind === "converged" @@ -1248,6 +1266,13 @@ export async function restoreNativeCodexAsync(): Promise restoreCodexCatalogWithPermit(permit, owningCodexHome)); - catalog = restored.kind === "completed" + const restored = withCatalogWriteSerialization(owningCodexHome, permit => + options.revalidateDesiredState && shouldSyncCodexOnStart(loadConfig()) + ? null + : restoreCodexCatalogWithPermit(permit, owningCodexHome)); + catalog = restored.kind === "completed" && restored.value !== null ? { state: "ok", changed: restored.value.removed > 0, ...restored.value, message: "Codex catalog restored." } + : restored.kind === "completed" + ? { + state: "skipped", changed: false, removed: 0, kept: 0, path: null, + message: "Codex integration was re-enabled; native catalog restoration was skipped.", + } : { state: "failed", changed: false, removed: 0, kept: 0, path: DEFAULT_CATALOG_PATH, message: `Codex catalog could not be restored: ${restored.reason}.`, diff --git a/src/codex/sync.ts b/src/codex/sync.ts index ab3234ce3a..87b1fb865a 100644 --- a/src/codex/sync.ts +++ b/src/codex/sync.ts @@ -126,6 +126,19 @@ export async function syncModelsToCodex( } const result = await deps.injectCodexConfig(p, config, { catalogPath: catalogPathForInjection }); + if (result.status === "skipped") { + return { + status: "skipped", + skippedReason: result.skippedReason ?? "desired_disabled", + ok: true, + added: 0, + catalogPath: null, + catalogExists: false, + catalogWritten: false, + cacheSynced: false, + message: result.message, + }; + } log?.log(result.message); reportCodexHomeTarget(log, deps.collectCodexHomeDiagnostic ?? collectOrcaCodexHomeDiagnostic); const projectConfigWarnings = printProjectCodexConfigWarnings(log, { cwd: process.cwd() }); diff --git a/src/server/management/native-integration-routes.ts b/src/server/management/native-integration-routes.ts index cb82bebcd3..9c6548ebef 100644 --- a/src/server/management/native-integration-routes.ts +++ b/src/server/management/native-integration-routes.ts @@ -282,7 +282,7 @@ async function handleCodexToggle(ctx: ManagementContext): Promise { // OFF. Restore the native path; the proxy keeps serving every other client. const { restoreNativeCodexAsync } = await import("../../codex/inject"); - const restored = await restoreNativeCodexAsync(); + const restored = await restoreNativeCodexAsync({ revalidateDesiredState: true }); return jsonResponse({ ok: true, clientId: "codex", changed: durable && persisted.status === "committed", state: restored.success ? "absent" : "unsafe", From b45f05d343e8982497315a8c98fc08edf1fbc634 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 11:49:46 +0900 Subject: [PATCH 028/317] feat(cli): persist Codex restore intent before mutation --- src/cli/index.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/cli/index.ts b/src/cli/index.ts index bfc922f535..49c5bc42fe 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -41,7 +41,7 @@ import { maybeShowStarPrompt } from "./star-prompt"; import { scheduleCatalogPrewarm } from "./catalog-prewarm"; import { maybeShowUpdatePrompt } from "../update/notify"; import { syncModelsToCodex } from "../codex/sync"; -import { shouldSyncGrokOnStart, syncCodexOnStartIfEnabled } from "../codex/desired-state"; +import { setIntegrationEnabled, shouldSyncGrokOnStart, syncCodexOnStartIfEnabled } from "../codex/desired-state"; import { normalizeUpdateChannel, runGuiUpdateWorker } from "../update/job"; import { collectOrcaCodexHomeDiagnostic } from "../codex/home"; import { removeOwnedConfigState } from "../lib/config-ownership"; @@ -787,6 +787,12 @@ switch (command) { console.error("No running proxy found. Run 'ocx start' — it injects opencodex automatically."); process.exit(1); } + const desired = setIntegrationEnabled("codex", true); + if (!desired.ok) { + process.exitCode = desired.reason === "conflict" ? 2 : 1; + console.error(`Codex desired state was not saved (${desired.reason}).`); + break; + } const synced = await syncModelsToCodex(live.port); if (synced.status === "skipped") { process.exitCode = 2; @@ -802,9 +808,15 @@ switch (command) { console.log(`Plain \`codex\` now routes through opencodex in ${target.effectiveCodexHome} (undo with: ocx restore).`); break; } + const desired = setIntegrationEnabled("codex", false); + if (!desired.ok) { + process.exitCode = desired.reason === "conflict" ? 2 : 1; + console.error(`Codex desired state was not saved (${desired.reason}).`); + break; + } let r: { success: boolean; message: string }; try { - r = await restoreNativeCodexAsync(); + r = await restoreNativeCodexAsync({ revalidateDesiredState: true }); } catch (err) { r = { success: false, message: err instanceof Error ? err.message : String(err) }; } @@ -822,7 +834,7 @@ switch (command) { } } catch { /* best-effort */ } if (r.success) { - console.log("Plain `codex` now runs natively (no proxy). Switch back with: ocx restore back"); + console.log("Codex integration is OFF and plain `codex` now runs natively. Switch back with: ocx restore back"); } else { console.error("Plain `codex` was not fully restored. Inspect $CODEX_HOME/config.toml before using native Codex."); } From 39064409c3375a881a3521c19be9af7a1ed88dd2 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 11:52:55 +0900 Subject: [PATCH 029/317] test(codex): cover desired-state toggle outcomes --- .../management/native-integration-routes.ts | 3 + tests/cli-restore-back.test.ts | 63 ++++++++++++------- tests/codex-desired-state.test.ts | 6 ++ tests/codex-history-job.test.ts | 2 +- tests/codex-history-provider.test.ts | 8 ++- tests/codex-inject-write-lock.test.ts | 18 +++++- tests/codex-sync-api.test.ts | 24 +++++++ tests/helpers/codex-inject-race-child.ts | 1 + tests/native-codex-toggle.test.ts | 5 ++ 9 files changed, 102 insertions(+), 28 deletions(-) diff --git a/src/server/management/native-integration-routes.ts b/src/server/management/native-integration-routes.ts index 9c6548ebef..ca04cb396d 100644 --- a/src/server/management/native-integration-routes.ts +++ b/src/server/management/native-integration-routes.ts @@ -23,6 +23,7 @@ import { injectGrokConfig, stripGrokConfig, type GrokInjectModel } from "../../g import { inspectGrokConfig } from "../../grok/inspect"; import { grokConfigPath } from "../../grok/status"; import { assertNativeTeardownOwned } from "../../integrations/native/ownership-preflight"; +import type { CodexNativeRestoreResult } from "../../codex/inject"; import type { OcxConfig } from "../../types"; import { jsonResponse } from "../auth-cors"; import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; @@ -63,6 +64,7 @@ export interface NativeToggleEnvelope { message: string; /** Present when the outcome needs more than success/failure to be honest. */ reason?: string; + artifacts?: CodexNativeRestoreResult["artifacts"]; } export interface NativeRefusalEnvelope { @@ -292,6 +294,7 @@ async function handleCodexToggle(ctx: ManagementContext): Promise { ...(restored.success ? (durable ? {} : { reason: "not_durable" }) : { reason: "restore_incomplete" }), + artifacts: restored.artifacts, } satisfies NativeToggleEnvelope); })(); try { diff --git a/tests/cli-restore-back.test.ts b/tests/cli-restore-back.test.ts index cf72a94c6e..66b31a7df1 100644 --- a/tests/cli-restore-back.test.ts +++ b/tests/cli-restore-back.test.ts @@ -1,38 +1,53 @@ import { describe, expect, test } from "bun:test"; import { spawnSync } from "node:child_process"; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -const cliSource = readFileSync(join(import.meta.dir, "..", "src", "cli", "index.ts"), "utf8"); const helpSource = readFileSync(join(import.meta.dir, "..", "src", "cli", "help.ts"), "utf8"); const repoRoot = join(import.meta.dir, ".."); describe("ocx restore back", () => { - test("restore/eject accept `back` to re-point codex at the RUNNING proxy only", () => { - const restoreCase = cliSource.slice(cliSource.indexOf('case "restore":'), cliSource.indexOf('case "recover-history":')); - - // The reverse switch must be liveness-gated (never inject a dead port) and reuse the - // same inject path as `ocx start` — no parallel injector. - expect(restoreCase).toContain('if (args[1] === "back")'); - expect(restoreCase).toContain("await findLiveProxy()"); - expect(restoreCase).toContain("await syncModelsToCodex(live.port)"); - expect(restoreCase.indexOf("findLiveProxy()")).toBeLessThan(restoreCase.indexOf("syncModelsToCodex(live.port)")); - expect(restoreCase).toContain("if (!synced.ok)"); - expect(restoreCase.indexOf("if (!synced.ok)")).toBeLessThan(restoreCase.indexOf("target.effectiveCodexHome")); - expect(restoreCase).toContain("target.effectiveCodexHome"); - // The forward switch reports incomplete marker cleanup instead of claiming native success. - expect(restoreCase).toContain("restoreNativeCodexAsync()"); - expect(restoreCase).toContain("process.exitCode = 1"); - expect(restoreCase).toContain("was not fully restored"); + test("restore durably disables Codex in an isolated home", () => { + const codexHome = mkdtempSync(join(tmpdir(), "ocx-cli-restore-codex-")); + const ocxHome = mkdtempSync(join(tmpdir(), "ocx-cli-restore-home-")); + try { + writeFileSync(join(codexHome, "config.toml"), 'model = "gpt-5"\n', "utf8"); + writeFileSync(join(ocxHome, "config.json"), JSON.stringify({ providers: {}, defaultProvider: "openai", checkForUpdates: false }), "utf8"); + const result = spawnSync(process.execPath, ["run", "src/cli/index.ts", "restore"], { + cwd: repoRoot, + env: { ...process.env, CODEX_HOME: codexHome, OPENCODEX_HOME: ocxHome, CI: "1" }, + encoding: "utf8", + }); + expect(result.status).toBe(0); + expect(JSON.parse(readFileSync(join(ocxHome, "config.json"), "utf8")).clientIntegrations.codex).toBe(false); + expect(`${result.stdout}\n${result.stderr}`).toContain("Codex integration is OFF and plain `codex` now runs natively."); + } finally { + rmSync(codexHome, { recursive: true, force: true }); + rmSync(ocxHome, { recursive: true, force: true }); + } }); - test("sync propagates injection refusal as a nonzero CLI result", () => { - const syncCase = cliSource.slice(cliSource.indexOf('case "sync":'), cliSource.indexOf('case "v2":')); - - expect(syncCase).toContain("await syncModelsToCodex"); - expect(syncCase).toContain("if (!synced.ok)"); - expect(syncCase).toContain("process.exitCode = 1"); + test("sync treats durable OFF as a successful no-write policy result", () => { + const codexHome = mkdtempSync(join(tmpdir(), "ocx-cli-sync-off-codex-")); + const ocxHome = mkdtempSync(join(tmpdir(), "ocx-cli-sync-off-home-")); + try { + const configPath = join(codexHome, "config.toml"); + writeFileSync(configPath, 'model = "gpt-5"\n', "utf8"); + writeFileSync(join(ocxHome, "config.json"), JSON.stringify({ providers: {}, defaultProvider: "openai", clientIntegrations: { codex: false }, checkForUpdates: false }), "utf8"); + const before = statSync(configPath).mtimeMs; + const result = spawnSync(process.execPath, ["run", "src/cli/index.ts", "sync"], { + cwd: repoRoot, + env: { ...process.env, CODEX_HOME: codexHome, OPENCODEX_HOME: ocxHome, CI: "1" }, + encoding: "utf8", + }); + expect(result.status).toBe(0); + expect(`${result.stdout}\n${result.stderr}`).toContain("Codex integration is OFF; sync skipped and no Codex files changed."); + expect(statSync(configPath).mtimeMs).toBe(before); + } finally { + rmSync(codexHome, { recursive: true, force: true }); + rmSync(ocxHome, { recursive: true, force: true }); + } }); test("sync exits nonzero when managed-default cleanup is ambiguous", () => { diff --git a/tests/codex-desired-state.test.ts b/tests/codex-desired-state.test.ts index 9a45ce5d4a..e069955b23 100644 --- a/tests/codex-desired-state.test.ts +++ b/tests/codex-desired-state.test.ts @@ -19,6 +19,7 @@ import { setCodexIntegrationEnabled, setGrokIntegrationEnabled, grokIntegrationEnabled, + shouldSyncCodexOnStart, shouldSyncGrokOnStart, syncCodexOnStartIfEnabled, } from "../src/codex/desired-state"; @@ -186,6 +187,11 @@ describe("the startup gate", () => { expect(calls).toBe(0); }); + test("the shared sync predicate has the same absent-means-on semantics", () => { + expect(shouldSyncCodexOnStart(baseConfig())).toBe(true); + expect(shouldSyncCodexOnStart({ ...baseConfig(), clientIntegrations: { codex: false } })).toBe(false); + }); + test("absence, an empty object, and an explicit true all still sync", async () => { for (const clientIntegrations of [undefined, {}, { codex: true }]) { let calls = 0; diff --git a/tests/codex-history-job.test.ts b/tests/codex-history-job.test.ts index 0c9846198a..da92ea62b5 100644 --- a/tests/codex-history-job.test.ts +++ b/tests/codex-history-job.test.ts @@ -157,5 +157,5 @@ test("the synchronous restore body is gated on skipHistory", () => { expect(gate).toBeLessThan(historyCall); // And the async wrapper is the thing that sets it. - expect(source).toContain("restoreNativeCodex({ skipHistory: true })"); + expect(source).toContain("restoreNativeCodex({ skipHistory: true,"); }); diff --git a/tests/codex-history-provider.test.ts b/tests/codex-history-provider.test.ts index 386c62b06e..856a68da68 100644 --- a/tests/codex-history-provider.test.ts +++ b/tests/codex-history-provider.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { Database } from "bun:sqlite"; import { describe, expect, setDefaultTimeout, test } from "bun:test"; -import { countPendingOpencodexHistory, isRecoverableHistoryError, migrateHistoryToOpenai, restoreLegacyOpenaiHistory, setHistoryDbBusyTimeoutForTests, syncCodexHistoryProvider, withHistoryRetry } from "../src/codex/history-provider"; +import { classifyRecoverableHistoryError, countPendingOpencodexHistory, isRecoverableHistoryError, migrateHistoryToOpenai, restoreLegacyOpenaiHistory, setHistoryDbBusyTimeoutForTests, syncCodexHistoryProvider, withHistoryRetry } from "../src/codex/history-provider"; // Windows CI: a transient file lock can consume the full production 5s busy timeout, tripping // bun's 5s default per-test timeout by itself. Fail fast into withHistoryRetry instead. @@ -303,6 +303,12 @@ describe("history lock retry", () => { expect(isRecoverableHistoryError(new TypeError("undefined is not a function"))).toBe(false); }); + test("classifies exhausted history failures for restore callers", () => { + expect(classifyRecoverableHistoryError(Object.assign(new Error("x"), { code: "SQLITE_BUSY" }))).toBe("busy"); + expect(classifyRecoverableHistoryError(Object.assign(new Error("x"), { code: "EACCES" }))).toBe("permission"); + expect(classifyRecoverableHistoryError(new Error("malformed database schema"))).toBeNull(); + }); + test("withHistoryRetry succeeds after one recoverable failure, sleeping between attempts", () => { const sleeps: number[] = []; let calls = 0; diff --git a/tests/codex-inject-write-lock.test.ts b/tests/codex-inject-write-lock.test.ts index 1ce4f92dc1..0610e0a5b3 100644 --- a/tests/codex-inject-write-lock.test.ts +++ b/tests/codex-inject-write-lock.test.ts @@ -25,7 +25,7 @@ function seedNative(): void { writeFileSync(join(codexHome, "config.toml"), 'model = "gpt-5"\n'); } -function runInject(port: number, lockTimeoutMs = 0): { success: boolean; retryable: boolean; message: string } { +function runInject(port: number, lockTimeoutMs = 0): { success: boolean; status?: "skipped"; retryable: boolean; message: string } { const result = spawnSync(process.execPath, [CHILD], { cwd: repoRoot, encoding: "utf8", @@ -37,7 +37,7 @@ function runInject(port: number, lockTimeoutMs = 0): { success: boolean; retryab }, }); const line = (result.stdout ?? "").trim().split("\n").filter(Boolean).pop() ?? "{}"; - return JSON.parse(line) as { success: boolean; retryable: boolean; message: string }; + return JSON.parse(line) as { success: boolean; status?: "skipped"; retryable: boolean; message: string }; } beforeEach(() => { @@ -54,6 +54,20 @@ afterEach(() => { }); describe("the lock is on the production path", () => { + test("a persisted OFF observed under N skips the real injector without writing", () => { + seedNative(); + const configPath = join(codexHome, "config.toml"); + const before = readFileSync(configPath, "utf8"); + writeFileSync(join(opencodexHome, "config.json"), JSON.stringify({ + providers: {}, defaultProvider: "openai", clientIntegrations: { codex: false }, + })); + + const result = runInject(20200); + + expect(result).toMatchObject({ success: true, status: "skipped" }); + expect(readFileSync(configPath, "utf8")).toBe(before); + }); + test("a clean first apply coordinates and records a transition", () => { seedNative(); const result = runInject(10100); diff --git a/tests/codex-sync-api.test.ts b/tests/codex-sync-api.test.ts index 85064b5828..70f13d781e 100644 --- a/tests/codex-sync-api.test.ts +++ b/tests/codex-sync-api.test.ts @@ -71,6 +71,7 @@ describe("GUI/CLI Codex sync backend", () => { expect(injectedPort).toBe(12345); expect(injectedCatalogPath).toBe("/tmp/opencodex-catalog.json"); expect(result).toEqual({ + status: "applied", ok: true, added: 3, catalogPath: "/tmp/opencodex-catalog.json", @@ -83,6 +84,28 @@ describe("GUI/CLI Codex sync backend", () => { expect(errors).toEqual([]); }); + test("returns a policy skip without touching the catalog or config", async () => { + let refreshed = false; + let injected = false; + const result = await syncModelsToCodex(12345, { + ...config, + clientIntegrations: { codex: false }, + }, null, { + refreshCodexModelCatalog: async () => { + refreshed = true; + throw new Error("must not refresh"); + }, + injectCodexConfig: async () => { + injected = true; + throw new Error("must not inject"); + }, + }); + + expect(result).toMatchObject({ status: "skipped", skippedReason: "desired_disabled", ok: true }); + expect(refreshed).toBe(false); + expect(injected).toBe(false); + }); + test("surfaces combo catalog omissions in sync result and CLI stderr (#484)", async () => { const logs: string[] = []; const errors: string[] = []; @@ -253,6 +276,7 @@ describe("GUI/CLI Codex sync backend", () => { expect(refreshed).toBe(false); expect(injectedCatalogPath).toBeUndefined(); expect(result).toEqual({ + status: "applied", ok: true, added: 0, catalogPath: null, diff --git a/tests/helpers/codex-inject-race-child.ts b/tests/helpers/codex-inject-race-child.ts index 9da07838ae..4894ec8e63 100644 --- a/tests/helpers/codex-inject-race-child.ts +++ b/tests/helpers/codex-inject-race-child.ts @@ -28,6 +28,7 @@ const result = await injectCodexConfig(payload.port ?? 10100, config, { console.log(JSON.stringify({ success: result.success, + status: result.status, retryable: (result as { retryable?: boolean }).retryable ?? false, message: result.message.slice(0, 200), })); diff --git a/tests/native-codex-toggle.test.ts b/tests/native-codex-toggle.test.ts index 92d0068576..9aa62df73c 100644 --- a/tests/native-codex-toggle.test.ts +++ b/tests/native-codex-toggle.test.ts @@ -108,6 +108,11 @@ describe("turning Codex off", () => { const result = await put(baseConfig(), { enabled: false }); expect(result.status).toBe(200); expect(result.body).toMatchObject({ ok: true, clientId: "codex" }); + expect(result.body.artifacts).toMatchObject({ + config: { state: expect.any(String) }, + catalog: { state: expect.any(String) }, + history: { state: expect.any(String) }, + }); // The decision is on disk. Without this, an OFF lasts until the next // `ocx start` re-syncs over it, which is the defect this phase exists for. expect(persistedCodexIntent()).toBe(false); From ac84ec661f8e7fed3db7ef9ab4ec4d4e373a9ba1 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Thu, 6 Aug 2026 05:04:43 +0200 Subject: [PATCH 030/317] fix(ci): repair review-event trigger, findings claim, and migration Address verified review findings on the hardened PR gate: - Move pull_request_review / pull_request_review_comment to sibling top-level events: listing them under pull_request_target.types is silently ignored by GitHub (actionlint: invalid activity type), so the review-event feature was inert. Both payloads carry pull_request, so the base.sha trusted checkout and context reads are unchanged. - migrateLegacyGateState: OR the autoDraftedByBot ownership bit across the legacy enforcer and readiness records; the readiness value overwriting a true enforcer bit dropped the restore path for bot-drafted maintainer PRs. - coderabbitOutsideDiffFindings: filter reviews to coderabbitai[bot] so a human review quoting 'Actionable comments posted' cannot untick the box; bound the immutable review-body count to runs with an unresolved bot thread so resolving threads clears the box without an empty commit; sort undated reviews deterministically instead of NaN. - GUI_OVERRIDE_RE: forbid sentence/line breaks in the negation window so 'This does not change the API. Please add a gui screenshot.' no longer waives the screenshot gate. - setReviewReadyLabel: guard label writes so a failure cannot abort the run before the gate comment or draft/ready conversion. - reviewThreads: paginate past the first 100 threads so a busy PR cannot hide unresolved bot threads (fail-open gap in a fail-closed check). - Harness: make the graphql fake async/rejecting and return a raw GraphQL payload so failures and mutation results match production. - Docs: sync pr-quality.md and the structure workflow map with the consolidated comment, findings claim, review-ready label, GUI waiver, and new triggers; document reviewReadyLabeled as non-consulted serialized state. Tests: node --test .github/scripts (420 pass), bun test tests/ci-workflows (112 pass), bun run typecheck, bun run privacy:scan, actionlint all green. --- .github/scripts/enforce-pr-target.test.cjs | 16 ++- .github/scripts/pr-quality-messages.cjs | 5 +- .github/scripts/pr-quality-state.cjs | 62 ++++++--- .github/scripts/pr-quality-state.test.cjs | 63 +++++++-- .github/scripts/pr-quality.cjs | 4 +- .github/scripts/pr-quality.test.cjs | 55 ++++++++ .github/workflows/enforce-pr-target.yml | 122 +++++++++++------- .../content/docs/contributing/pr-quality.md | 22 +++- structure/06_docs-and-release.md | 2 +- tests/ci-workflows.test.ts | 111 ++++++++++++++-- tests/helpers/enforce-pr-target-harness.ts | 27 ++-- 11 files changed, 374 insertions(+), 115 deletions(-) diff --git a/.github/scripts/enforce-pr-target.test.cjs b/.github/scripts/enforce-pr-target.test.cjs index 709b2e541a..7a771d6516 100644 --- a/.github/scripts/enforce-pr-target.test.cjs +++ b/.github/scripts/enforce-pr-target.test.cjs @@ -49,12 +49,22 @@ describe("enforce-pr-target workflow", () => { }); it("listens for review events so bot findings after ready are caught", () => { - assert.match(workflow, /pull_request_review/); - assert.match(workflow, /pull_request_review_comment/); + // These are top-level webhook events, not `pull_request_target` activity + // types — listing them under `types:` is silently ignored by GitHub. Each + // assertion anchors to the YAML list-item form so one trigger cannot + // satisfy the other's assertion. + assert.match(workflow, /^ pull_request_review:\s*$/m); + assert.match(workflow, /^ pull_request_review_comment:\s*$/m); + assert.match(workflow, /types: \[submitted, edited, dismissed\]/); + assert.match(workflow, /types: \[created, edited\]/); }); it("queries review threads and feeds them to the findings claim check", () => { - assert.match(workflow, /reviewThreads\(first: 100\)/); + // Paginated read: `after: $cursor` + `pageInfo.hasNextPage`, so a busy PR + // with more than 100 threads cannot hide unresolved bot threads (fail-open + // gap in a fail-closed check). + assert.match(workflow, /reviewThreads\(first: 100, after: \$cursor\)/); + assert.match(workflow, /hasNextPage/); assert.match(workflow, /unresolvedFindingsClaim/); assert.match(workflow, /findingsClaim\.byBot/); assert.match(workflow, /review_findings/); diff --git a/.github/scripts/pr-quality-messages.cjs b/.github/scripts/pr-quality-messages.cjs index 773b46420b..0f6c6619a9 100644 --- a/.github/scripts/pr-quality-messages.cjs +++ b/.github/scripts/pr-quality-messages.cjs @@ -9,7 +9,10 @@ const { READINESS_LATEST_DEV_BEHIND_MAX } = require("./pr-quality-state.cjs"); -/** Marks the bot's consolidated PR gate message. */ +/** + * Legacy marker for the pre-consolidation readiness comment. It is matched + * only to migrate and delete old comments; the gate never writes it. + */ const READINESS_MARKER = ""; /** Marks the bot's consolidated PR gate message. */ const GATE_MARKER = ""; diff --git a/.github/scripts/pr-quality-state.cjs b/.github/scripts/pr-quality-state.cjs index 69dadd5dbf..d3fa498020 100644 --- a/.github/scripts/pr-quality-state.cjs +++ b/.github/scripts/pr-quality-state.cjs @@ -109,9 +109,9 @@ function gateStateMarker(state) { /** * Fresh consolidated gate state. It merges the old enforcer ownership fields * (active / autoDraftedByBot / titlePrefixedByBot) with the readiness fields - * (maintainersPinged / completedAtHeadSha). `reviewReadyLabeled` records - * whether the gate currently owns the `review-ready` label, so a run that - * merely re-renders the comment does not re-fire the label webhook. + * (maintainersPinged / completedAtHeadSha). `reviewReadyLabeled` is serialized + * for backward compatibility with states written by earlier versions of this + * gate; live label decisions read `pr.labels` directly, never this field. */ function defaultGateState() { return { @@ -176,7 +176,13 @@ function migrateLegacyGateState(enforcerState, readinessState) { gate.titlePrefixedByBot = Boolean(enforcerState.titlePrefixedByBot); } if (readinessState) { - gate.autoDraftedByBot = Boolean(readinessState.autoDraftedByBot); + // Either legacy record may own the auto-draft: the enforcer converted the + // PR to draft for a quality failure, the readiness comment recorded the + // checklist-driven draft, or both. Ownership is a union — letting the + // readiness value overwrite a true enforcer bit drops the restore path + // and leaves a bot-drafted maintainer PR stuck in draft forever. + gate.autoDraftedByBot = + gate.autoDraftedByBot || Boolean(readinessState.autoDraftedByBot); gate.maintainersPinged = Boolean(readinessState.maintainersPinged); gate.completedAtHeadSha = readinessState.completedAtHeadSha ?? null; } @@ -228,6 +234,9 @@ const REVIEW_FINDINGS_BOT_LOGINS = [ "coderabbitai[bot]" ]; +/** The login that authors CodeRabbit reviews. */ +const CODE_RABBIT_LOGIN = "coderabbitai[bot]"; + /** * CodeRabbit's review-body line that reports actionable inline findings. The * gate reads this to count findings that CodeRabbit posts only as review-body @@ -243,23 +252,30 @@ const CODE_RABBIT_ACTIONABLE_RE = * `**Actionable comments posted: N**`; those never become review threads, so * the thread check alone would miss them. This supplements the thread check: * a CodeRabbit review of the live head whose body reports actionable comments - * counts as an unresolved finding. + * counts as an unresolved finding. Only CodeRabbit's own reviews are read — + * a human review quoting the same line must not count. * * Only the most recent review for the live head is considered (the head a * findings-review covers is the head that must be clean), so an older review - * of a superseded commit cannot keep the box unticked forever. + * of a superseded commit cannot keep the box unticked forever. Reviews with a + * missing or unparsable `submitted_at` sort last deterministically so the + * "most recent" pick is never arbitrary. */ function coderabbitOutsideDiffFindings({ reviews = [], liveHeadSha }) { if (!liveHeadSha || !Array.isArray(reviews) || reviews.length === 0) { return { code: null, unresolved: 0, byBot: {} }; } + const submittedAt = review => { + const parsed = Date.parse(String(review?.submitted_at ?? "")); + return Number.isNaN(parsed) ? -Infinity : parsed; + }; const latestForHead = reviews - .filter(review => review?.commit_id === liveHeadSha) - .sort( - (a, b) => - Date.parse(String(b?.submitted_at ?? "")) - - Date.parse(String(a?.submitted_at ?? "")) - )[0]; + .filter( + review => + review?.commit_id === liveHeadSha && + review?.user?.login === CODE_RABBIT_LOGIN + ) + .sort((a, b) => submittedAt(b) - submittedAt(a))[0]; const body = String(latestForHead?.body ?? ""); const match = CODE_RABBIT_ACTIONABLE_RE.exec(body); if (!match) return { code: null, unresolved: 0, byBot: {} }; @@ -268,7 +284,7 @@ function coderabbitOutsideDiffFindings({ reviews = [], liveHeadSha }) { return { code: "review_findings", unresolved: count, - byBot: { "coderabbitai[bot]": count } + byBot: { [CODE_RABBIT_LOGIN]: count } }; } @@ -280,8 +296,11 @@ function coderabbitOutsideDiffFindings({ reviews = [], liveHeadSha }) { * only in its review body (outside the diff range); those are added by the * `coderabbitOutsideDiffFindings` supplement so they cannot slip through. * The supplement is subordinate: it never subtracts, only adds unresolved - * counts for the live head, and once the reviewer resolves the threads the - * next run re-checks. + * counts for the live head while a bot thread is still open. A review body is + * immutable, so the count can never fall to zero on its own once posted; the + * supplement therefore only counts while an unresolved bot thread exists — the + * author resolves that thread to clear the box, matching the checklist wording + * ("I resolved all correct ... findings") without requiring an empty commit. */ function unresolvedFindingsClaim({ threads = [], reviews = [], liveHeadSha }) { const byBot = {}; @@ -294,11 +313,13 @@ function unresolvedFindingsClaim({ threads = [], reviews = [], liveHeadSha }) { unresolved += 1; } } - const outside = coderabbitOutsideDiffFindings({ reviews, liveHeadSha }); - if (outside.code) { - for (const [login, count] of Object.entries(outside.byBot)) { - byBot[login] = (byBot[login] ?? 0) + count; - unresolved += count; + if (unresolved > 0) { + const outside = coderabbitOutsideDiffFindings({ reviews, liveHeadSha }); + if (outside.code) { + for (const [login, count] of Object.entries(outside.byBot)) { + byBot[login] = (byBot[login] ?? 0) + count; + unresolved += count; + } } } return unresolved > 0 @@ -349,6 +370,7 @@ module.exports = { GATE_STATE_PATTERN, READINESS_STATE_VERSION, REVIEW_FINDINGS_BOT_LOGINS, + CODE_RABBIT_LOGIN, CODE_RABBIT_ACTIONABLE_RE, coderabbitOutsideDiffFindings, parseState, diff --git a/.github/scripts/pr-quality-state.test.cjs b/.github/scripts/pr-quality-state.test.cjs index 33d6942ce3..1b0a1a2c82 100644 --- a/.github/scripts/pr-quality-state.test.cjs +++ b/.github/scripts/pr-quality-state.test.cjs @@ -370,6 +370,7 @@ describe("coderabbitOutsideDiffFindings", () => { body: "**Actionable comments posted: 3**\n\nSome walkthrough.", commit_id: HEAD, submitted_at: "2026-08-04T06:24:02Z", + user: { login: "coderabbitai[bot]" }, }, ], liveHeadSha: HEAD, @@ -388,6 +389,7 @@ describe("coderabbitOutsideDiffFindings", () => { body: "**Actionable comments posted: 3**", commit_id: "1111111111111111111111111111111111111111", submitted_at: "2026-08-04T06:24:02Z", + user: { login: "coderabbitai[bot]" }, }, ], liveHeadSha: HEAD, @@ -397,7 +399,7 @@ describe("coderabbitOutsideDiffFindings", () => { it("ignores a review reporting zero actionable comments", () => { const claim = coderabbitOutsideDiffFindings({ - reviews: [{ body: "**Actionable comments posted: 0**", commit_id: HEAD }], + reviews: [{ body: "**Actionable comments posted: 0**", commit_id: HEAD, user: { login: "coderabbitai[bot]" } }], liveHeadSha: HEAD, }); assert.deepEqual(claim, { code: null, unresolved: 0, byBot: {} }); @@ -406,8 +408,8 @@ describe("coderabbitOutsideDiffFindings", () => { it("uses the most recent review of the live head", () => { const claim = coderabbitOutsideDiffFindings({ reviews: [ - { body: "**Actionable comments posted: 2**", commit_id: HEAD, submitted_at: "2026-08-04T06:00:00Z" }, - { body: "**Actionable comments posted: 5**", commit_id: HEAD, submitted_at: "2026-08-04T07:00:00Z" }, + { body: "**Actionable comments posted: 2**", commit_id: HEAD, submitted_at: "2026-08-04T06:00:00Z", user: { login: "coderabbitai[bot]" } }, + { body: "**Actionable comments posted: 5**", commit_id: HEAD, submitted_at: "2026-08-04T07:00:00Z", user: { login: "coderabbitai[bot]" } }, ], liveHeadSha: HEAD, }); @@ -420,28 +422,53 @@ describe("coderabbitOutsideDiffFindings", () => { unresolved: 0, byBot: {}, }); - assert.deepEqual(coderabbitOutsideDiffFindings({ reviews: [{ body: "**Actionable comments posted: 1**", commit_id: HEAD }] }), { + assert.deepEqual(coderabbitOutsideDiffFindings({ reviews: [{ body: "**Actionable comments posted: 1**", commit_id: HEAD, user: { login: "coderabbitai[bot]" } }] }), { code: null, unresolved: 0, byBot: {}, }); }); + + it("ignores a human review that quotes the actionable-comments line", () => { + const claim = coderabbitOutsideDiffFindings({ + reviews: [ + { + body: "CodeRabbit said **Actionable comments posted: 2** — let's discuss.", + commit_id: HEAD, + submitted_at: "2026-08-04T06:24:02Z", + user: { login: "wibias" }, + }, + ], + liveHeadSha: HEAD, + }); + assert.deepEqual(claim, { code: null, unresolved: 0, byBot: {} }); + }); + + it("sorts undated reviews last deterministically", () => { + const claim = coderabbitOutsideDiffFindings({ + reviews: [ + { body: "**Actionable comments posted: 2**", commit_id: HEAD, user: { login: "coderabbitai[bot]" } }, + { body: "**Actionable comments posted: 5**", commit_id: HEAD, submitted_at: "2026-08-04T07:00:00Z", user: { login: "coderabbitai[bot]" } }, + ], + liveHeadSha: HEAD, + }); + // The dated review wins over the undated one, so 5 is the count. + assert.equal(claim.unresolved, 5); + }); }); describe("unresolvedFindingsClaim with outside-diff supplement", () => { const HEAD = "3f1c0de0a6a4d0a3f9a1b2c3d4e5f60718293a4b"; - it("adds the outside-diff count to a clean thread set", () => { + it("does not count outside-diff when no unresolved bot thread exists", () => { + // The review body is immutable; once the author resolves every thread the + // supplement must not keep the box unticked forever (no empty commit). const claim = unresolvedFindingsClaim({ threads: [], - reviews: [{ body: "**Actionable comments posted: 2**", commit_id: HEAD, submitted_at: "2026-08-04T06:24:02Z" }], + reviews: [{ body: "**Actionable comments posted: 2**", commit_id: HEAD, submitted_at: "2026-08-04T06:24:02Z", user: { login: "coderabbitai[bot]" } }], liveHeadSha: HEAD, }); - assert.deepEqual(claim, { - code: "review_findings", - unresolved: 2, - byBot: { "coderabbitai[bot]": 2 }, - }); + assert.deepEqual(claim, { code: null, unresolved: 0, byBot: {} }); }); it("adds the outside-diff count to an unresolved thread count", () => { @@ -449,7 +476,7 @@ describe("unresolvedFindingsClaim with outside-diff supplement", () => { threads: [ { isResolved: false, author: { login: "coderabbitai[bot]" } }, ], - reviews: [{ body: "**Actionable comments posted: 2**", commit_id: HEAD, submitted_at: "2026-08-04T06:24:02Z" }], + reviews: [{ body: "**Actionable comments posted: 2**", commit_id: HEAD, submitted_at: "2026-08-04T06:24:02Z", user: { login: "coderabbitai[bot]" } }], liveHeadSha: HEAD, }); assert.deepEqual(claim, { @@ -464,7 +491,7 @@ describe("unresolvedFindingsClaim with outside-diff supplement", () => { threads: [ { isResolved: true, author: { login: "coderabbitai[bot]" } }, ], - reviews: [{ body: "**Actionable comments posted: 2**", commit_id: "1111111111111111111111111111111111111111", submitted_at: "2026-08-04T06:24:02Z" }], + reviews: [{ body: "**Actionable comments posted: 2**", commit_id: "1111111111111111111111111111111111111111", submitted_at: "2026-08-04T06:24:02Z", user: { login: "coderabbitai[bot]" } }], liveHeadSha: HEAD, }); assert.deepEqual(claim, { code: null, unresolved: 0, byBot: {} }); @@ -515,6 +542,16 @@ describe("gate state", () => { assert.equal(merged.reviewReadyLabeled, false); }); + it("keeps enforcer-owned auto-draft when the readiness record says false", () => { + const merged = migrateLegacyGateState( + { version: 1, active: true, autoDraftedByBot: true }, + { version: 2, autoDraftedByBot: false, maintainersPinged: true }, + ); + // Ownership is a union: the enforcer converted the PR to draft for a + // quality failure, so the readiness record must not drop the restore path. + assert.equal(merged.autoDraftedByBot, true); + }); + it("migrates with either legacy state absent", () => { const onlyEnforcer = migrateLegacyGateState( { version: 1, active: true, titlePrefixedByBot: true }, diff --git a/.github/scripts/pr-quality.cjs b/.github/scripts/pr-quality.cjs index 16adf2ebc3..eaceab06ad 100644 --- a/.github/scripts/pr-quality.cjs +++ b/.github/scripts/pr-quality.cjs @@ -186,9 +186,11 @@ function hasGuiCue(title, body) { * title/description is a false positive and a screenshot is not required. The * negation word must appear within a short window before `gui`, so a comment * like "this touches gui but only the config" (no negation) keeps the gate. + * The window cannot cross a sentence or line boundary: "This does not change + * the API. Please add a gui screenshot." must not waive the gate. */ const GUI_OVERRIDE_RE = - /\b(?:no|not|doesn'?t|does not|never|without)\b[\s\S]{0,40}?\bgui\b/i; + /\b(?:no|not|doesn'?t|does not|never|without)\b[^.!?\n]{0,40}?\bgui\b/i; /** * True when a maintainer (OWNER / COLLABORATOR / MEMBER) issue comment waives diff --git a/.github/scripts/pr-quality.test.cjs b/.github/scripts/pr-quality.test.cjs index 9b2455d2fd..e013fab88a 100644 --- a/.github/scripts/pr-quality.test.cjs +++ b/.github/scripts/pr-quality.test.cjs @@ -186,6 +186,17 @@ describe("hasGuiOverride", () => { ); }); + it("does not match a negation that belongs to another sentence or line", () => { + assert.equal( + hasGuiOverride({ comments: [{ author_association: "OWNER", body: "This does not change the API. Please add a gui screenshot." }] }), + false, + ); + assert.equal( + hasGuiOverride({ comments: [{ author_association: "OWNER", body: "- no rebase needed\n- gui tweak included" }] }), + false, + ); + }); + it("is clean for no comments or a comment without a body", () => { assert.equal(hasGuiOverride({ comments: [] }), false); assert.equal(hasGuiOverride({ comments: [{ author_association: "OWNER" }] }), false); @@ -813,6 +824,50 @@ describe("collectPrQualityFailures", () => { assert.ok(failures.some((f) => f.code === "missing_ui_screenshot")); }); + it("waives the screenshot gate for a maintainer override comment", () => { + const failures = collectPrQualityFailures({ + baseRef: "dev", + allowedBases: allowed, + title: "GUI: fix provider list spacing", + body: [ + "## Summary", + "This change adjusts gui/ spacing tokens used by the dashboard.", + "", + "## Test plan", + "- Ran bun test tests/ci-workflows.test.ts", + ].join("\n"), + behindMain: 0, + behindBase: 0, + authorPermission: "read", + guiOverrideComments: [ + { author_association: "OWNER", body: "no gui changes here" }, + ], + }); + assert.ok(!failures.some((f) => f.code === "missing_ui_screenshot")); + }); + + it("keeps the screenshot gate when only the PR author claims no gui change", () => { + const failures = collectPrQualityFailures({ + baseRef: "dev", + allowedBases: allowed, + title: "GUI: fix provider list spacing", + body: [ + "## Summary", + "This change adjusts gui/ spacing tokens used by the dashboard.", + "", + "## Test plan", + "- Ran bun test tests/ci-workflows.test.ts", + ].join("\n"), + behindMain: 0, + behindBase: 0, + authorPermission: "read", + guiOverrideComments: [ + { author_association: "CONTRIBUTOR", body: "no gui changes here" }, + ], + }); + assert.ok(failures.some((f) => f.code === "missing_ui_screenshot")); + }); + it("accepts a gui title when a screenshot image is embedded", () => { const failures = collectPrQualityFailures({ baseRef: "dev", diff --git a/.github/workflows/enforce-pr-target.yml b/.github/workflows/enforce-pr-target.yml index 6a1c228918..9b12e23968 100644 --- a/.github/workflows/enforce-pr-target.yml +++ b/.github/workflows/enforce-pr-target.yml @@ -8,11 +8,17 @@ on: - edited - ready_for_review - synchronize - # Review events let the gate catch bot findings (Codex / CodeRabbit - # review threads) that land after a PR was marked ready, without waiting - # for the author's next push. - - pull_request_review - - pull_request_review_comment + # Review events let the gate catch bot findings (Codex / CodeRabbit review + # threads) that land after a PR was marked ready, without waiting for the + # author's next push. These are top-level webhook events, not activity types + # of pull_request_target — listing them under `types:` is silently ignored by + # GitHub (actionlint: invalid activity type). Both payloads carry + # `pull_request`, so the base.sha trusted checkout and + # `context.payload.pull_request` reads below are unchanged. + pull_request_review: + types: [submitted, edited, dismissed] + pull_request_review_comment: + types: [created, edited] # pull-requests:write covers title/comment/label updates. # contents:write is required for convertPullRequestToDraft / @@ -203,20 +209,29 @@ jobs: } async function setReviewReadyLabel(shouldHave, hasLabel) { - if (shouldHave && !hasLabel) { - await github.rest.issues.addLabels({ - owner, - repo, - issue_number: pull_number, - labels: [REVIEW_READY_LABEL] - }); - } else if (!shouldHave && hasLabel) { - await github.rest.issues.removeLabel({ - owner, - repo, - issue_number: pull_number, - name: REVIEW_READY_LABEL - }); + // The label is a review trigger, not a gate decision. A label + // write failure must not abort the run before the gate comment + // or the draft/ready conversion happens. + try { + if (shouldHave && !hasLabel) { + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: pull_number, + labels: [REVIEW_READY_LABEL] + }); + } else if (!shouldHave && hasLabel) { + await github.rest.issues.removeLabel({ + owner, + repo, + issue_number: pull_number, + name: REVIEW_READY_LABEL + }); + } + } catch (error) { + core.warning( + `Could not ${shouldHave ? "add" : "remove"} the ${inlineCode(REVIEW_READY_LABEL)} label: ${error.message}` + ); } } @@ -580,26 +595,51 @@ jobs: let findingsClaim = null; let findingsUnverifiable = false; try { - const reviewData = await github.graphql( - ` - query($owner: String!, $repo: String!, $number: Int!) { - repository(owner: $owner, name: $repo) { - pullRequest(number: $number) { - reviewThreads(first: 100) { - nodes { - isResolved - comments(first: 1) { - nodes { - author { login } + // Paginate the review threads: a busy PR can carry more than + // 100 threads (CodeRabbit posts many reviews), and a truncated + // read would silently miss unresolved bot threads — a fail-open + // gap in a fail-closed check. + const allThreadNodes = []; + let threadCursor = null; + let threadPage = null; + let reviewThreadsPage = null; + do { + threadPage = await github.graphql( + ` + query($owner: String!, $repo: String!, $number: Int!, $cursor: String) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + reviewThreads(first: 100, after: $cursor) { + pageInfo { + hasNextPage + endCursor + } + nodes { + isResolved + comments(first: 1) { + nodes { + author { login } + } } } } } } } + `, + { + owner, + repo, + number: pull_number, + cursor: threadCursor } - `, - { owner, repo, number: pull_number } + ); + reviewThreadsPage = threadPage?.repository?.pullRequest?.reviewThreads; + allThreadNodes.push(...(reviewThreadsPage?.nodes ?? [])); + threadCursor = reviewThreadsPage?.pageInfo?.endCursor ?? null; + } while ( + reviewThreadsPage?.pageInfo?.hasNextPage === true && + threadCursor ); const reviewsData = await github.paginate( github.rest.pulls.listReviews, @@ -611,10 +651,7 @@ jobs: } ); findingsClaim = unresolvedFindingsClaim({ - threads: ( - reviewData?.repository?.pullRequest?.reviewThreads?.nodes ?? - [] - ).map(node => ({ + threads: allThreadNodes.map(node => ({ isResolved: node.isResolved, author: node.comments?.nodes?.[0]?.author ?? null })), @@ -1021,16 +1058,3 @@ jobs: ); return; } - - // Fallback for a clean contributor PR whose checklist is complete - // but which already left the mustDraft branch above (defensive). - if (checklistRequired && checklistComplete) { - await upsertGateComment(readyState, { - status: "READY", - statusReason: "all PR quality gates passed; the review readiness checklist is complete.", - actions: [], - readiness, - checklistRequired, - notices: [] - }); - } diff --git a/docs-site/src/content/docs/contributing/pr-quality.md b/docs-site/src/content/docs/contributing/pr-quality.md index 1bc5ac3958..d7eb652083 100644 --- a/docs-site/src/content/docs/contributing/pr-quality.md +++ b/docs-site/src/content/docs/contributing/pr-quality.md @@ -39,23 +39,33 @@ tells you exactly what to change: a real description: a **Summary** of what changed and why, plus a **Test plan** (or equivalent substance). When the title or description mentions `gui`, the description must include a screenshot of the UI change; the check - keeps the PR a draft and comments until the screenshot is present. + keeps the PR a draft and comments until the screenshot is present. A + maintainer (OWNER / COLLABORATOR / MEMBER) can waive the screenshot + requirement with an issue comment saying the change does not touch the GUI + (for example "no gui changes"); the PR author cannot self-waive. Contributor PRs (authors without repository push permission) open in draft and stay there until a four-box review-readiness checklist in the description is complete: local CI green, the branch on the latest `dev` commit, all correct Codex and CodeRabbit findings fixed, and the ready-for-review confirmation. Once every box is ticked the check marks the PR ready for review and notifies the maintainers listed in `MAINTAINERS.md` - (excluding the author). Completion is bound to the exact commit the PR head + (excluding the author). The gate's status and "what to do" live in a single + consolidated bot comment that is rewritten on every run, so there is exactly + one place to look. Completion is bound to the exact commit the PR head pointed at: if new commits are pushed afterwards, the gate moves the PR back to draft, resets the checklist and the maintainer notification, and asks you to test and tick the boxes again against the latest code. A retarget to `dev` clears the wrong-branch message automatically and is remembered by the gate; the draft stays until the checklist is complete. - Before a completion is accepted, the gate verifies the two checklist claims - it can check itself: the head's `ci` check must be green, and the branch - must be on the latest `dev` commit or at most 10 commits behind it. A - disproved claim unticks the matching box and keeps the PR a draft. + Before a completion is accepted, the gate verifies the checklist claims it + can check itself: the head's `ci` check must be green, the branch must be on + the latest `dev` commit or at most 10 commits behind it, and every Codex and + CodeRabbit review thread on the PR must be resolved. A disproved claim + unticks the matching box and keeps the PR a draft. The gate also re-runs on + review events, so a bot finding posted after the PR was marked ready is + caught without waiting for the next push. When the checklist is complete and + every gate is green, the gate adds a `review-ready` label that opts the PR + into CodeRabbit review. - **Hygiene.** Behavior changes need a test; new lint or type suppressions, focused or skipped tests, empty catch blocks, edited generated output, and a diff --git a/structure/06_docs-and-release.md b/structure/06_docs-and-release.md index fed4d9960b..b52466fdc9 100644 --- a/structure/06_docs-and-release.md +++ b/structure/06_docs-and-release.md @@ -44,7 +44,7 @@ bun run build | `.github/workflows/release.yml` | Manual dispatch only | npm publish/dry-run workflow. It requires the exact `GITHUB_SHA` to have a successful Cross-platform CI run before publish or dry-run. | | `.github/workflows/deploy-docs.yml` | `push` to `main` touching `docs-site/**` or the workflow, or manual dispatch | Build and publish the Astro/Starlight docs site to GitHub Pages. | | `.github/workflows/service-lifecycle.yml` | `pull_request` to `main`/`dev` and `push`, both filtered on the service path set (`src/service.ts`, `src/cli.ts`, `src/cli/index.ts`, `src/lib/bun-runtime.ts`, `package.json`, `bun.lock`, the workflow), or manual dispatch | Service-lifecycle smoke on three platforms: Linux systemd, macOS launchd, and Windows Scheduled Tasks. Each installs, verifies, stops via `ocx stop`, and uninstalls. The path list is kept in sync with the `release.yml` service-gate regex. | -| `.github/workflows/enforce-pr-target.yml` | `pull_request_target` (opened, reopened, edited, ready_for_review, synchronize) | The `enforce-target` gate: rejects pull requests whose head ancestry sits on the `main` tip while far behind `dev`, and rejects empty or malformed descriptions. Stacked child PRs targeting another open PR's head skip the wrong-base gate. | +| `.github/workflows/enforce-pr-target.yml` | `pull_request_target` (opened, reopened, edited, ready_for_review, synchronize), `pull_request_review` (submitted, edited, dismissed), `pull_request_review_comment` (created, edited) | The `enforce-target` gate: rejects pull requests whose head ancestry sits on the `main` tip while far behind `dev`, rejects empty or malformed descriptions, requires a GUI screenshot when the title/body mentions `gui` (waivable by a maintainer comment), keeps contributor PRs in draft until a four-box readiness checklist is complete, verifies the CI / latest-dev / Codex+CodeRabbit-findings claims, and adds a `review-ready` label at the ready moment to trigger CodeRabbit review. Stacked child PRs targeting another open PR's head skip the wrong-base gate. | | `.github/workflows/enforce-issue-quality.yml` | `issues` (opened, edited, reopened), `issue_comment` (created, edited), or manual dispatch with an issue number | Issue-template compliance gate. | | `.github/workflows/issue-quality-tests.yml` | `pull_request` and `push` filtered on the issue/PR automation scripts, templates, and their workflows | Tests the issue and PR automation scripts themselves, so the gates cannot rot silently. | | `.github/workflows/issue-triage.yml` | `issues` (opened) | Duplicate detection and triage labeling for new issues. | diff --git a/tests/ci-workflows.test.ts b/tests/ci-workflows.test.ts index 44dac748e1..30536ddd7d 100644 --- a/tests/ci-workflows.test.ts +++ b/tests/ci-workflows.test.ts @@ -830,7 +830,15 @@ describe("GitHub Actions hardening", () => { // pull_request_target runs with the base repo's token. Checking out or // executing the PR's code under it is the classic escalation. - expect(Object.keys(workflow.on ?? {})).toEqual(["pull_request_target"]); + // The two review events are sibling top-level events (not + // `pull_request_target` activity types — those are silently ignored by + // GitHub), and both payloads carry `pull_request`, so the trusted base.sha + // checkout and the `context.payload.pull_request` reads stay unchanged. + expect(Object.keys(workflow.on ?? {}).sort()).toEqual([ + "pull_request_review", + "pull_request_review_comment", + "pull_request_target", + ]); // And the trigger is exactly a `types:` list — nothing else. // @@ -842,6 +850,9 @@ describe("GitHub Actions hardening", () => { // additive, both look like ordinary scoping in a diff, and neither failed a // single assertion. expect(Object.keys(workflow.on?.pull_request_target ?? {})).toEqual(["types"]); + // The sibling review events are also exactly `types:` lists. + expect(Object.keys(workflow.on?.pull_request_review ?? {})).toEqual(["types"]); + expect(Object.keys(workflow.on?.pull_request_review_comment ?? {})).toEqual(["types"]); // Exactly the scopes this gate needs. `pull-requests: write` covers title // and comment updates. `contents: write` is required for the draft GraphQL @@ -938,13 +949,25 @@ describe("GitHub Actions hardening", () => { expect([...types].sort()).toEqual([ "edited", "opened", - "pull_request_review", - "pull_request_review_comment", "ready_for_review", "reopened", "synchronize", ]); + // Review events are top-level webhook events, not `pull_request_target` + // activity types — GitHub silently ignores invalid types, so the gate + // would never re-run on a bot finding posted after ready. Assert the + // sibling events and their activity-type lists. + expect(workflow.on?.pull_request_review?.types).toEqual([ + "submitted", + "edited", + "dismissed", + ]); + expect(workflow.on?.pull_request_review_comment?.types).toEqual([ + "created", + "edited", + ]); + // The verdict is a live PR read plus ancestry/description checks. expect(script).toContain("github.rest.pulls.get"); expect(script).toContain("collectPrQualityFailures"); @@ -1892,15 +1915,18 @@ describe("GitHub Actions hardening", () => { expect(readinessBody).toContain("**4/4** boxes ticked"); }); - test("CodeRabbit outside-diff findings untick the box even with clean threads", async () => { + test("CodeRabbit outside-diff findings do not untick the box once threads are clean", async () => { // CodeRabbit posts some findings only in its review body ("outside the - // diff range"), which never become review threads. The supplement reads - // `pulls.listReviews` for a live-head CodeRabbit review reporting - // actionable comments and treats it as an unresolved finding. + // diff range"), which never become review threads. A review body is + // immutable, so the count can never fall to zero on its own once posted; + // the supplement therefore only counts while an unresolved bot thread + // exists. Clean threads + a live-head review body with a positive count + // must stay green — otherwise the author could never clear the box + // without pushing an empty commit. const result = await run({ pr: { base: { ref: "dev" }, - draft: false, + draft: true, body: readinessChecklistBody(4), }, maintainersFile: MAINTAINERS_FIXTURE, @@ -1909,6 +1935,7 @@ describe("GitHub Actions hardening", () => { body: "**Actionable comments posted: 2**\n\nWalkthrough.", commit_id: "3f1c0de0a6a4d0a3f9a1b2c3d4e5f60718293a4b", submitted_at: "2026-08-04T06:24:02Z", + user: { login: "coderabbitai[bot]" }, }, ], }); @@ -1917,20 +1944,44 @@ describe("GitHub Actions hardening", () => { "checks.listForRef", "graphql", "pulls.listReviews", - "pulls.get", - "pulls.update", + "issues.addLabels", "graphql", "issues.createComment", ])); + expect(callsTo(result, "pulls.update")).toEqual([]); + const readinessBody = lastReadinessCommentBody(result); + expect(readinessBody).toContain("**4/4** boxes ticked"); + expect(result.warnings.some(w => w.startsWith("setFailed:"))).toBe(false); + }); + + test("CodeRabbit outside-diff findings add to an unresolved thread count", async () => { + const result = await run({ + pr: { + base: { ref: "dev" }, + draft: false, + body: readinessChecklistBody(4), + }, + maintainersFile: MAINTAINERS_FIXTURE, + reviewThreads: [ + { isResolved: false, author: { login: "coderabbitai[bot]" } }, + ], + reviews: [ + { + body: "**Actionable comments posted: 2**\n\nWalkthrough.", + commit_id: "3f1c0de0a6a4d0a3f9a1b2c3d4e5f60718293a4b", + submitted_at: "2026-08-04T06:24:02Z", + user: { login: "coderabbitai[bot]" }, + }, + ], + }); + const [bodyUpdate] = callsTo(result, "pulls.update") as [{ body: string }]; expect(bodyUpdate.body).toContain("- [ ] I resolved all correct Codex and CodeRabbit findings."); - expect(bodyUpdate.body).toContain("- [x] My PR is ready for review."); const readinessBody = lastReadinessCommentBody(result); expect(readinessBody).toContain( - "CodeRabbit has 2 unresolved findings; the **Codex/CodeRabbit findings** box has been unticked.", + "CodeRabbit has 3 unresolved findings; the **Codex/CodeRabbit findings** box has been unticked.", ); expect(readinessBody).toContain("**3/4** boxes ticked"); - expect(result.warnings.some(w => w.startsWith("setFailed:"))).toBe(false); }); test("a CodeRabbit outside-diff review of a stale head does not untick the box", async () => { @@ -1948,6 +1999,7 @@ describe("GitHub Actions hardening", () => { body: "**Actionable comments posted: 2**", commit_id: "1111111111111111111111111111111111111111", submitted_at: "2026-08-04T06:24:02Z", + user: { login: "coderabbitai[bot]" }, }, ], }); @@ -1996,6 +2048,39 @@ describe("GitHub Actions hardening", () => { expect(lastReadinessCommentBody(result)).toContain("**4/4** boxes ticked"); }); + test("a human review quoting the actionable-comments line does not untick the box", async () => { + // The outside-diff supplement filters by author: a maintainer quoting + // CodeRabbit's summary in their own review must not count as CodeRabbit + // findings, or the box would be unticked by a human's quote. + const result = await run({ + pr: { + base: { ref: "dev" }, + draft: true, + body: readinessChecklistBody(4), + }, + maintainersFile: MAINTAINERS_FIXTURE, + reviewThreads: [ + { isResolved: false, author: { login: "coderabbitai[bot]" } }, + ], + reviews: [ + { + body: "CodeRabbit said **Actionable comments posted: 2** — let's discuss.", + commit_id: "3f1c0de0a6a4d0a3f9a1b2c3d4e5f60718293a4b", + submitted_at: "2026-08-04T06:24:02Z", + user: { login: "wibias" }, + }, + ], + }); + + // Only the unresolved thread counts (1), not the human's quoted line. + const [bodyUpdate] = callsTo(result, "pulls.update") as [{ body: string }]; + expect(bodyUpdate.body).toContain("- [ ] I resolved all correct Codex and CodeRabbit findings."); + const readinessBody = lastReadinessCommentBody(result); + expect(readinessBody).toContain( + "CodeRabbit has 1 unresolved finding; the **Codex/CodeRabbit findings** box has been unticked.", + ); + }); + test("a review-threads lookup failure fails closed for the findings claim", async () => { const result = await run({ pr: { diff --git a/tests/helpers/enforce-pr-target-harness.ts b/tests/helpers/enforce-pr-target-harness.ts index c7acccd25f..4794913c61 100644 --- a/tests/helpers/enforce-pr-target-harness.ts +++ b/tests/helpers/enforce-pr-target-harness.ts @@ -135,10 +135,17 @@ export type RunOptions = { reviewThreads?: Array<{ isResolved: boolean | null; author: { login: string } | null }>; /** * Pull-request reviews `pulls.listReviews` reports for the PR. Each entry is - * `{ body, commit_id, submitted_at }`; the workflow reads CodeRabbit's - * "Actionable comments posted: N" body line as the outside-diff supplement. + * `{ body, commit_id, submitted_at, user }`; the workflow reads CodeRabbit's + * "Actionable comments posted: N" body line as the outside-diff supplement + * and filters by `user.login` so a human review quoting the line does not + * count. */ - reviews?: Array<{ body: string; commit_id: string; submitted_at?: string }>; + reviews?: Array<{ + body: string; + commit_id: string; + submitted_at?: string; + user?: { login: string }; + }>; /** * Labels the PR already carries (from `pulls.get`). The gate reads these to * decide whether to add/remove the `review-ready` label. @@ -692,9 +699,12 @@ export async function runEnforcePrTarget( */ class Octokit { rest = rest; - graphql = (query: unknown, variables: unknown) => { + graphql = async (query: unknown, variables: unknown) => { const text = String(query ?? ""); - const recorded = record("graphql", { query, variables }); + // Routed through `respond` so a `failOn: ["graphql"]` simulated failure + // REJECTS rather than throwing synchronously, like every other method + // (real Octokit graphql returns a promise and rejects it). + await respond("graphql", { query, variables }); // Fail a specific mutation after recording so the failed call appears in // the recording (same semantics as `failOn`). The review-threads read is // the first graphql call; targeting a mutation by query text lets a test @@ -705,8 +715,9 @@ export async function runEnforcePrTarget( // The review-threads query is answered with the shape the workflow // reads. `github.graphql` resolves to the raw data payload (no `data` // wrapper, unlike `github.rest.*`), so the threads object is returned - // directly. Everything else (the draft/ready mutations) records raw; the - // workflow ignores the return value of those. + // directly. Mutations also resolve to a raw GraphQL payload, never a + // REST envelope, so a workflow that reads a mutation result gets the + // same shape production produces. if (text.includes("reviewThreads")) { const threads = (options.reviewThreads ?? []).map(thread => ({ isResolved: thread.isResolved, @@ -722,7 +733,7 @@ export async function runEnforcePrTarget( }, }; } - return recorded; + return {}; }; request = (route: unknown, params: unknown) => respond("request", { route, params }); From 9f6df9992fc2d89e1871f9aae3bc44b830346ac5 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 12:17:58 +0900 Subject: [PATCH 031/317] feat(usage): raise the management read entry cap from 200k to 500k MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cap is a reader bound, not retention: it silently clipped what one management read could return for small-row logs inside the already-bounded 64 MiB byte window. Benchmarked at the cap-binding worst case (63.5 MiB / 629k tiny rows, unique request ids): read 1.4s + summarize 0.6s, ~0.9 GiB transient RSS, request-scoped — inside the recorded acceptance threshold. Devlog: devlog/_plan/260806_disposition_sweep/060_usage_cap_500k.md --- .../060_usage_cap_500k.md | 51 +++++++++++++++++++ src/usage/log.ts | 2 +- tests/usage-log.test.ts | 8 +-- 3 files changed, 56 insertions(+), 5 deletions(-) create mode 100644 devlog/_plan/260806_disposition_sweep/060_usage_cap_500k.md diff --git a/devlog/_plan/260806_disposition_sweep/060_usage_cap_500k.md b/devlog/_plan/260806_disposition_sweep/060_usage_cap_500k.md new file mode 100644 index 0000000000..c5caac2759 --- /dev/null +++ b/devlog/_plan/260806_disposition_sweep/060_usage_cap_500k.md @@ -0,0 +1,51 @@ +# 060 — usage management read cap: 200k → 500k entries + +User authorization (2026-08-06): adopt the user's uncommitted 500k edits from +the main checkout, "의견을 종합해서" — synthesize the review context and land +it properly. Authority includes push + PR + merge for this item. + +## Synthesis (why 500k is the right call now) + +- The cap is a READER bound (`MANAGEMENT_USAGE_MAX_ENTRIES` in + `src/usage/log.ts`), not a retention bound: raw `usage.jsonl` is never + deleted by it. Raising it widens what one management read can return. +- The BYTE cap (`managementUsageMaxReadBytes`, 64 MiB default) bounds the raw + INPUT of a read before the entry cap applies (log.ts:532-558); the entry cap + trims parsed rows afterwards (log.ts:500-519). Corrected per audit: 64 MiB + bounds input bytes, while 500k raises the TRANSIENT parse/summary heap and + CPU — up to 300k more normalized JS objects can be retained per read, and + `/api/usage` does several full passes with per-request Map/Set aggregation + (summary.ts:301-583). This is a bounded, transient cost on a management + read, not an unbounded leak — but it must be measured, not assumed. +- Acceptance criterion added: a representative 500k-entry benchmark (small + rows, worst-case unique request ids) must show acceptable wall time and + peak RSS delta for `readUsageSnapshotForManagement` + `summarizeUsage` + before landing. +- Prior lesson (usage-log cap incident): "a reader cap can hide data without + deleting it" — the 200k clip was exactly that failure shape for the GUI. +- With the #1008 rollup sidecar (still open) history older than the window + will come from folded rows anyway; until it lands, the raw window IS the + history, which strengthens the case for the wider entry cap now. +- Overlap handling: this branch cuts from current dev, applying the same + content as the user's dirty edits. The main checkout stays untouched; once + this lands, the user's local diff becomes content-identical to HEAD. + +## Work + +1. Branch `codex/usage-entry-cap-500k` off current dev; apply the two-file + change (constant + test expectations). +2. 500k benchmark (bench script in .tmp/, not committed): parse + summarize + wall time and heap delta at 200k vs 500k; record numbers in this ledger. +3. Focused tests + typecheck; terra regression audit. Collision check done: + #1008's log.ts diff starts at the reader (~line 523) and does not touch + the cap line or this test — no hunk collision (audit-verified). +4. Push, PR to dev, CI green, merge with `--match-head-commit` pin. + +## Ledger + +| Step | Evidence | +|------|----------| +| Benchmark (bench.ts in mktemp, not committed) | Baseline 200k-cap: 200k entries, read 475ms, summarize 358ms, ΔRSS 547MiB. With 500k cap on a 96.7MiB/500k-row file: byte cap binds first → 330,585 entries, read 835ms, summarize 609ms, ΔRSS ~1.1GiB transient. 600k-row file: identical 330,585 entries — the 64MiB byte window is the effective bound for realistic rows; the 500k entry cap is a secondary guard, not the binding limit | +| Cap-binding benchmark (bench2, audit-required) | Tiny rows densely packing 63.5MiB → 629,205 rows in the byte window; parse-all-then-slice yields exactly 500,000 entries (129,205 dropped). read 1,436ms, summarize 637ms, ΔRSS 897MiB transient. This exercises the exact widened case (entry cap binding, unique request ids) | +| Acceptance threshold + verdict | Threshold: ≤3s combined read+summarize, ≤1.5GiB transient RSS on the worst case. Measured: 2.07s combined, 897MiB — inside threshold. Request-scoped, no steady-state retention; acceptable for an on-demand admin endpoint | +| Stale comment | tests/usage-log.test.ts:138 timing comment updated to the 500k shape (audit minor) | diff --git a/src/usage/log.ts b/src/usage/log.ts index b86aa05d3b..b0e8778ab4 100644 --- a/src/usage/log.ts +++ b/src/usage/log.ts @@ -421,7 +421,7 @@ export type UsageLogRevision = { let usageReadCacheStats = { fullReads: 0, tailReads: 0, parsedLines: 0 }; const MANAGEMENT_USAGE_MAX_READ_BYTES = 64 * 1024 * 1024; const MANAGEMENT_USAGE_READ_CHUNK_BYTES = 1024 * 1024; -const MANAGEMENT_USAGE_MAX_ENTRIES = 200_000; +const MANAGEMENT_USAGE_MAX_ENTRIES = 500_000; const MANAGEMENT_USAGE_FLIGHT_STALE_MS = 30_000; export interface ManagementUsageSnapshot { entries: PersistedUsageEntry[]; diff --git a/tests/usage-log.test.ts b/tests/usage-log.test.ts index 370df85e02..8e5c8687f4 100644 --- a/tests/usage-log.test.ts +++ b/tests/usage-log.test.ts @@ -126,16 +126,16 @@ describe("usage log", () => { test("usage byte-prefix truncation and entry-count truncation report independent metadata", async () => { writeFileSync( usageLogPath(), - `${Array.from({ length: 200_001 }, (_, index) => JSON.stringify({ requestId: String(index) })).join("\n")}\n`, + `${Array.from({ length: 500_001 }, (_, index) => JSON.stringify({ requestId: String(index) })).join("\n")}\n`, ); const snapshot = await readUsageSnapshotForManagement(); - expect(snapshot.entries).toHaveLength(200_000); + expect(snapshot.entries).toHaveLength(500_000); expect(snapshot.entries[0]?.requestId).toBe("1"); - expect(snapshot.entries.at(-1)?.requestId).toBe("200000"); + expect(snapshot.entries.at(-1)?.requestId).toBe("500000"); expect(snapshot.truncatedPrefixBytes).toBe(0); expect(snapshot.entriesTruncated).toBe(true); expect(snapshot.entriesDropped).toBe(1); - }, STORE_BUDGET_MS); // parsing 200,001 rows IS the entry-cap assertion; windows-latest measured ~5.05s against Bun's 5s default. + }, STORE_BUDGET_MS); // parsing 500,001 rows IS the entry-cap assertion; the 200k-row variant measured ~5.05s on windows-latest against Bun's 5s default. test("stale usage-read flight is replaced and old completion cannot clear new owner", async () => { writeFileSync( From 5788def426fc6cb946384bced693280fc7af2889 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 12:24:22 +0900 Subject: [PATCH 032/317] fix(codex): keep external-provider restore free of history work --- src/codex/inject.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/codex/inject.ts b/src/codex/inject.ts index eec98e609c..a05cc7608b 100644 --- a/src/codex/inject.ts +++ b/src/codex/inject.ts @@ -1252,6 +1252,11 @@ export async function restoreNativeCodexAsync( options: { revalidateDesiredState?: boolean } = {}, ): Promise { const inline = restoreNativeCodex({ skipHistory: true, revalidateDesiredState: options.revalidateDesiredState }); + // External-provider courtesy: the inline body already reported all three + // artifacts as skipped and touched nothing but the stale journal. Launching + // the history worker here would turn a read-mostly courtesy result into a + // history mutation (or a spurious busy failure) on a home we do not own. + if (inline.externalProvider) return inline; const outcome = await runCodexHistoryJob({ ...resolveCodexHistoryJobTarget(), ...(options.revalidateDesiredState ? { expectedDesiredEnabled: false } : {}), From d2137315928fdf34dfe552fca0ad2e63b6d73c1a Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 12:30:32 +0900 Subject: [PATCH 033/317] fix(codex): serialize restore config writes under the write lock --- src/codex/codex-write-lock.ts | 4 +- src/codex/inject-coordination.ts | 6 +- src/codex/inject.ts | 257 +++++++++++++++++++++++-------- src/codex/sync.ts | 3 +- 4 files changed, 201 insertions(+), 69 deletions(-) diff --git a/src/codex/codex-write-lock.ts b/src/codex/codex-write-lock.ts index 4b429a9cb9..8794979550 100644 --- a/src/codex/codex-write-lock.ts +++ b/src/codex/codex-write-lock.ts @@ -66,7 +66,7 @@ export type CodexWriteLockRefusalReason = export type CodexWriteLockResult = | { status: "acquired"; value: T; waitedMs: number; lockId: string } - | { status: "skipped"; reason: "desired_disabled"; waitedMs: number } + | { status: "skipped"; reason: "desired_disabled" | "desired_enabled"; waitedMs: number } | { status: "busy"; reason: "deadline" | "cancelled"; retryable: true; waitedMs: number } | { status: "refused"; @@ -127,7 +127,7 @@ export interface CodexWriteCommitContext { /** A synchronous under-lock policy re-read proved the requested apply stale. */ export class CodexWriteLockSkipped extends Error { - constructor(readonly reason: "desired_disabled") { + constructor(readonly reason: "desired_disabled" | "desired_enabled") { super(reason); this.name = "CodexWriteLockSkipped"; } diff --git a/src/codex/inject-coordination.ts b/src/codex/inject-coordination.ts index 28e3a902db..91f9374bc6 100644 --- a/src/codex/inject-coordination.ts +++ b/src/codex/inject-coordination.ts @@ -230,14 +230,16 @@ export function recomputeInjectWitness(options: { export function codexInjectLockOutcome( result: Exclude, { status: "acquired" }>, ): { success: false; message: string; retryable: boolean } | { - success: true; status: "skipped"; skippedReason: "desired_disabled"; message: string; + success: true; status: "skipped"; skippedReason: "desired_disabled" | "desired_enabled"; message: string; } { if (result.status === "skipped") { return { success: true, status: "skipped", skippedReason: result.reason, - message: "Codex integration is OFF; no Codex config, catalog, cache, or history was changed.", + message: result.reason === "desired_disabled" + ? "Codex integration is OFF; no Codex config, catalog, cache, or history was changed." + : "Codex integration was re-enabled; native restore was skipped.", }; } if (result.status === "busy") { diff --git a/src/codex/inject.ts b/src/codex/inject.ts index a05cc7608b..e62f9e1c69 100644 --- a/src/codex/inject.ts +++ b/src/codex/inject.ts @@ -620,7 +620,7 @@ export interface CodexInjectResult { success: boolean; message: string; status?: "skipped"; - skippedReason?: "desired_disabled"; + skippedReason?: "desired_disabled" | "desired_enabled"; nativeSubagentDefaultsWarning?: string; } @@ -1247,21 +1247,194 @@ function failedHistoryRestore(reason?: CodexHistoryFailureReason): CodexRestoreH }; } -/** Restore native Codex, running history in a Worker under H. */ +function externalProviderRestoreResult(activeProvider: string): CodexNativeRestoreResult { + const message = `External Codex provider ${tomlString(activeProvider)} preserved; no native restore was needed.`; + return { + success: true, + message, + externalProvider: activeProvider, + artifacts: { + config: { state: "skipped", changed: false, action: "external-provider-preserved", message }, + catalog: { state: "skipped", changed: false, removed: 0, kept: 0, path: null, message }, + history: { state: "skipped", changed: false, rows: 0, files: 0, ejectedRows: 0, message }, + }, + }; +} + +function desiredEnabledRestoreSkip(): CodexNativeRestoreResult { + const message = "Codex integration was re-enabled; native restore was skipped."; + return { + success: true, + message, + artifacts: { + config: { state: "skipped", changed: false, action: "owned-fields-stripped", message }, + catalog: { state: "skipped", changed: false, removed: 0, kept: 0, path: null, message }, + history: { state: "skipped", changed: false, rows: 0, files: 0, ejectedRows: 0, message }, + }, + }; +} + +/** The config/profile half of a native restore, reported as one artifact. */ +function restoreCodexConfigInline(): CodexRestoreConfigResult { + try { + const journal = restoreJournalState(); + const restored = journal.configRestored + ? { success: true, message: "Codex config restored from opencodex journal." } + : removeCodexConfig({ preserveProfile: journal.profileRestored || journal.profileChanged }); + return restored.success + ? { + state: "ok", + changed: journal.configRestored || journal.profileRestored || journal.profileChanged || restored.message.startsWith("Removed"), + action: journal.configRestored ? "journal-restored" : "owned-fields-stripped", + message: restored.message, + } + : { state: "failed", changed: false, action: "failed", message: restored.message }; + } catch (error) { + return { state: "failed", changed: false, action: "failed", message: error instanceof Error ? error.message : String(error) }; + } +} + +/** The catalog half, always inside its own K acquisition. */ +function restoreCodexCatalogArtifact(revalidateDesiredState: boolean): CodexRestoreCatalogResult { + const owningCodexHome = getCodexHome(); + try { + const restored = withCatalogWriteSerialization(owningCodexHome, permit => + revalidateDesiredState && shouldSyncCodexOnStart(loadConfig()) + ? null + : restoreCodexCatalogWithPermit(permit, owningCodexHome)); + return restored.kind === "completed" && restored.value !== null + ? { state: "ok", changed: restored.value.removed > 0, ...restored.value, message: "Codex catalog restored." } + : restored.kind === "completed" + ? { + state: "skipped", changed: false, removed: 0, kept: 0, path: null, + message: "Codex integration was re-enabled; native catalog restoration was skipped.", + } + : { + state: "failed", changed: false, removed: 0, kept: 0, path: DEFAULT_CATALOG_PATH, + message: `Codex catalog could not be restored: ${restored.reason}.`, + }; + } catch (error) { + return { + state: "failed", changed: false, removed: 0, kept: 0, path: DEFAULT_CATALOG_PATH, + message: error instanceof Error ? error.message : String(error), + }; + } +} + +/** + * Restore native Codex, running history in a Worker under H. + * + * On a coordinated home the config/profile restore happens INSIDE the Codex + * write lock, publishing a `remove` transition — the same serialization inject + * uses. Without it, an older restore could overwrite a config a concurrent + * enable had just written under the lock, and then honestly report success + * while desired intent said ON. The desired-state re-read under the lock turns + * that lost race into the discriminated `desired_enabled` skip. + */ export async function restoreNativeCodexAsync( options: { revalidateDesiredState?: boolean } = {}, ): Promise { - const inline = restoreNativeCodex({ skipHistory: true, revalidateDesiredState: options.revalidateDesiredState }); - // External-provider courtesy: the inline body already reported all three - // artifacts as skipped and touched nothing but the stale journal. Launching - // the history worker here would turn a read-mostly courtesy result into a - // history mutation (or a spurious busy failure) on a home we do not own. - if (inline.externalProvider) return inline; + const activeProvider = currentExternalCodexModelProvider(); + if (activeProvider) { + // External-provider courtesy: only the stale journal is removed. The + // history worker must not launch — it would turn a read-mostly courtesy + // result into a history mutation on a home we do not own. + removeJournal(); + return externalProviderRestoreResult(activeProvider); + } + + const eligibility = codexWriteCoordinationEligibility({ + coordinatorPath: () => + resolveCodexCoordinatorDatabasePath(resolveEffectiveUserIdentity(), getCodexHome()), + residue: () => classifyNativeRoutedResidue(), + integrationRecord: () => readIntegrationRecord(), + }); + + let config: CodexRestoreConfigResult; + let transitionReceipt: { nativeGeneration: number; currentTxId: string } | undefined; + + if (eligibility.kind === "coordinated") { + // The restore has no candidate bytes to witness; freshness comes from the + // filesystem reads and the desired-state re-read performed under the lock. + const witness = { authoritySnapshotId: "codex-native-restore" }; + const coordinated = await withCodexWriteLock( + { + timeoutMs: DEFAULT_INJECT_LOCK_TIMEOUT_MS, + admitted: witness, + readAdmissionUnderLock: () => witness, + }, + (ctx) => { + if (options.revalidateDesiredState && shouldSyncCodexOnStart(loadConfig())) { + throw new CodexWriteLockSkipped("desired_enabled"); + } + const published = ctx.coordinator.beginTransition( + { + nativeGeneration: ctx.expectation.nativeBefore, + currentTxId: ctx.currentTxId, + }, + { + txId: ctx.expectation.txId, + direction: "remove", + authoritySnapshotId: ctx.admission.authoritySnapshotId, + nextRetryAt: new Date().toISOString(), + }, + ); + if (published.kind !== "updated") { + throw new CodexWriteConflictError( + `The Codex transition could not be published: ${published.kind}.`, + ); + } + const preImages = captureCodexPreImages(); + let restored: CodexRestoreConfigResult; + try { + restored = restoreCodexConfigInline(); + } catch (error) { + const compensated = restoreCodexPreImages(preImages); + if (!compensated.complete) throw new CodexPartialWriteError(compensated.unrestored); + throw error; + } + return { + config: restored, + receipt: { + nativeGeneration: ctx.expectation.nativeAfter, + currentTxId: ctx.expectation.txId, + }, + }; + }, + ); + if (coordinated.status === "skipped") return desiredEnabledRestoreSkip(); + if (coordinated.status !== "acquired") { + config = { + state: "failed", + changed: false, + action: "failed", + message: coordinated.status === "busy" + ? `Another process is writing Codex configuration right now (waited ${coordinated.waitedMs}ms). Retry shortly.` + : `Codex configuration was not restored: ${coordinated.message}`, + }; + } else { + config = coordinated.value.config; + transitionReceipt = coordinated.value.receipt; + } + } else { + // Legacy-uncoordinated (or unresolvable) homes keep the unserialized path + // they have always had; restore is the escape hatch and must not strand + // them. The plain re-read still honors an intervening re-enable. + if (options.revalidateDesiredState && shouldSyncCodexOnStart(loadConfig())) { + return desiredEnabledRestoreSkip(); + } + config = restoreCodexConfigInline(); + } + + const catalog = restoreCodexCatalogArtifact(options.revalidateDesiredState === true); const outcome = await runCodexHistoryJob({ ...resolveCodexHistoryJobTarget(), ...(options.revalidateDesiredState ? { expectedDesiredEnabled: false } : {}), operation: deriveCodexHistoryOperation({ direction: "restore", resumeHistory: true, legacyMode: false }), }); + if (transitionReceipt) { + resolveCodexHistoryTransition(transitionReceipt, outcome); + } const history: CodexRestoreHistoryResult = outcome.kind === "converged" ? { state: "ok", changed: outcome.rows > 0, rows: outcome.rows, files: outcome.files, ejectedRows: 0, @@ -1283,14 +1456,16 @@ export async function restoreNativeCodexAsync( : outcome.kind === "failed" ? failedHistoryRestore(outcome.historyFailureReason) : failedHistoryRestore(); - const success = inline.artifacts.config.state !== "failed" - && inline.artifacts.catalog.state !== "failed" + const base = catalog.removed > 0 + ? `${config.message} Catalog restored to ${catalog.kept} native model(s) (dropped ${catalog.removed} proxy-routed).` + : config.message; + const success = config.state !== "failed" + && catalog.state !== "failed" && history.state !== "failed"; return { - ...inline, success, - message: `${inline.message}${history.state === "failed" ? ` ⚠️ ${history.message}` : ""}`, - artifacts: { ...inline.artifacts, history }, + message: `${base}${history.state === "failed" ? ` ⚠️ ${history.message}` : ""}`, + artifacts: { config, catalog, history }, }; } @@ -1298,59 +1473,13 @@ export function restoreNativeCodex(options: { skipHistory?: boolean; revalidateD const activeProvider = currentExternalCodexModelProvider(); if (activeProvider) { removeJournal(); - const message = `External Codex provider ${tomlString(activeProvider)} preserved; no native restore was needed.`; - return { - success: true, - message, - externalProvider: activeProvider, - artifacts: { - config: { state: "skipped", changed: false, action: "external-provider-preserved", message }, - catalog: { state: "skipped", changed: false, removed: 0, kept: 0, path: null, message }, - history: { state: "skipped", changed: false, rows: 0, files: 0, ejectedRows: 0, message }, - }, - }; - } - let config: CodexRestoreConfigResult; - try { - const journal = restoreJournalState(); - const restored = journal.configRestored - ? { success: true, message: "Codex config restored from opencodex journal." } - : removeCodexConfig({ preserveProfile: journal.profileRestored || journal.profileChanged }); - config = restored.success - ? { - state: "ok", - changed: journal.configRestored || journal.profileRestored || journal.profileChanged || restored.message.startsWith("Removed"), - action: journal.configRestored ? "journal-restored" : "owned-fields-stripped", - message: restored.message, - } - : { state: "failed", changed: false, action: "failed", message: restored.message }; - } catch (error) { - config = { state: "failed", changed: false, action: "failed", message: error instanceof Error ? error.message : String(error) }; + return externalProviderRestoreResult(activeProvider); } - const owningCodexHome = getCodexHome(); - let catalog: CodexRestoreCatalogResult; - try { - const restored = withCatalogWriteSerialization(owningCodexHome, permit => - options.revalidateDesiredState && shouldSyncCodexOnStart(loadConfig()) - ? null - : restoreCodexCatalogWithPermit(permit, owningCodexHome)); - catalog = restored.kind === "completed" && restored.value !== null - ? { state: "ok", changed: restored.value.removed > 0, ...restored.value, message: "Codex catalog restored." } - : restored.kind === "completed" - ? { - state: "skipped", changed: false, removed: 0, kept: 0, path: null, - message: "Codex integration was re-enabled; native catalog restoration was skipped.", - } - : { - state: "failed", changed: false, removed: 0, kept: 0, path: DEFAULT_CATALOG_PATH, - message: `Codex catalog could not be restored: ${restored.reason}.`, - }; - } catch (error) { - catalog = { - state: "failed", changed: false, removed: 0, kept: 0, path: DEFAULT_CATALOG_PATH, - message: error instanceof Error ? error.message : String(error), - }; + if (options.revalidateDesiredState && shouldSyncCodexOnStart(loadConfig())) { + return desiredEnabledRestoreSkip(); } + const config = restoreCodexConfigInline(); + const catalog = restoreCodexCatalogArtifact(options.revalidateDesiredState === true); // Design B (loopback) steady state: threads are already tagged openai, so prove the // no-op with a readonly probe instead of write-opening a DB the Codex app may hold // (Windows: WAL writer lock -> seconds of stalling + a false warning on every stop). diff --git a/src/codex/sync.ts b/src/codex/sync.ts index 87b1fb865a..b63ad39aa3 100644 --- a/src/codex/sync.ts +++ b/src/codex/sync.ts @@ -129,7 +129,8 @@ export async function syncModelsToCodex( if (result.status === "skipped") { return { status: "skipped", - skippedReason: result.skippedReason ?? "desired_disabled", + // The apply direction's only under-lock policy skip is desired OFF. + skippedReason: "desired_disabled", ok: true, added: 0, catalogPath: null, From e26ea2c08d4e35d89e61161f7f2f8ac75554ac03 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Thu, 6 Aug 2026 05:30:44 +0200 Subject: [PATCH 034/317] fix(ci): drop review-event triggers, fix re-review findings Address the CodeRabbit re-review of the first fix round: - Remove pull_request_review / pull_request_review_comment triggers: review events load the workflow from the PR head branch (like pull_request), so the head-controlled workflow YAML ran under a write token against base-pinned scripts, crashing the gate (parseGateState is not a function) and breaking the trusted-base model. The findings claim still runs on every pull_request_target event. - .coderabbit.yaml: drop the positive labels filter, which would restrict ALL CodeRabbit reviews to labeled PRs and starve maintainer PRs (they never carry review-ready). The label remains a status marker only. - Persist draft ownership in the consolidated comment BEFORE convertToDraft so a successful convert followed by a failed comment write still leaves the bot-created draft owned and restorable; reflect a failed conversion by rewriting the comment with autoDraftedByBot:false. - Preserve bot draft/title-prefix ownership through checklist resets (head-drift and claim-check) instead of resetting to a fresh default. - Guard the maintainer recovery branch so it never calls markReadyForReview twice on the stale draft value. - Deduplicate the failure-path comment writes via a shared draftComment helper and a single failure-status-reason builder. - Docs: sync pr-quality.md and the structure workflow map (no review-event triggers; label is a status marker; review-body supplement documented). - Tests: cover the ownership-preserving reset, update wrong-base sequences for the persist-before-convert ordering, and pin the trigger allowlist. Gates: node --test .github/scripts (420 pass), bun test tests/ci-workflows (113 pass), bun run typecheck, bun run privacy:scan, actionlint clean. --- .coderabbit.yaml | 7 - .github/scripts/enforce-pr-target.test.cjs | 26 ++- .github/workflows/enforce-pr-target.yml | 184 +++++++++++------- .../content/docs/contributing/pr-quality.md | 18 +- structure/06_docs-and-release.md | 2 +- tests/ci-workflows.test.ts | 136 +++++++------ 6 files changed, 219 insertions(+), 154 deletions(-) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 5536bd2a40..ab02b02657 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -15,13 +15,6 @@ reviews: auto_review: enabled: true drafts: false - # The PR gate adds this label at the ready moment (checklist complete and - # quality gates green) and removes it otherwise. A label addition triggers - # a CodeRabbit review even while the PR is still a draft, which is how a - # ready-but-draft PR gets reviewed without requiring a manual - # `@coderabbitai review` comment. - labels: - - "review-ready" # Default branch (main) is included automatically; these are additional # base branches (anchored regex). base_branches: diff --git a/.github/scripts/enforce-pr-target.test.cjs b/.github/scripts/enforce-pr-target.test.cjs index 7a771d6516..52cfe393bc 100644 --- a/.github/scripts/enforce-pr-target.test.cjs +++ b/.github/scripts/enforce-pr-target.test.cjs @@ -48,15 +48,16 @@ describe("enforce-pr-target workflow", () => { assert.match(workflow, /synchronize/); }); - it("listens for review events so bot findings after ready are caught", () => { - // These are top-level webhook events, not `pull_request_target` activity - // types — listing them under `types:` is silently ignored by GitHub. Each - // assertion anchors to the YAML list-item form so one trigger cannot - // satisfy the other's assertion. - assert.match(workflow, /^ pull_request_review:\s*$/m); - assert.match(workflow, /^ pull_request_review_comment:\s*$/m); - assert.match(workflow, /types: \[submitted, edited, dismissed\]/); - assert.match(workflow, /types: \[created, edited\]/); + it("does not add review events that would break the trusted-base model", () => { + // `pull_request_review` / `pull_request_review_comment` load the workflow + // from the PR head branch (like `pull_request`), while this workflow's + // checkout pins the base SHA — head YAML + base scripts mismatch, so the + // gate crashes (`parseGateState is not a function`) and the head controls + // the workflow definition under a write token. The findings claim runs on + // every `pull_request_target` event instead (opened/edited/synchronize/ + // ready_for_review). + assert.doesNotMatch(workflow, /^ pull_request_review:/m); + assert.doesNotMatch(workflow, /^ pull_request_review_comment:/m); }); it("queries review threads and feeds them to the findings claim check", () => { @@ -88,11 +89,16 @@ describe("enforce-pr-target workflow", () => { assert.doesNotMatch(workflow, /Recording ownership state/); }); - it("manages the review-ready label for the CodeRabbit opt-in trigger", () => { + it("manages the review-ready status label at the ready moment", () => { assert.match(workflow, /REVIEW_READY_LABEL\s*=\s*"review-ready"/); assert.match(workflow, /github\.rest\.issues\.addLabels/); assert.match(workflow, /github\.rest\.issues\.removeLabel/); assert.match(workflow, /reviewReadyDesired/); + // A positive labels filter in .coderabbit.yaml would restrict ALL reviews + // to labeled PRs (maintainer PRs never carry this label), so the label is + // kept as a visible status marker only and never wired as a CodeRabbit + // auto-review filter. + assert.doesNotMatch(workflow, /labels:\s*\["?review-ready"?\]/); }); it("migrates legacy two-comment PRs and deletes the old comments", () => { diff --git a/.github/workflows/enforce-pr-target.yml b/.github/workflows/enforce-pr-target.yml index 9b12e23968..3e451931c5 100644 --- a/.github/workflows/enforce-pr-target.yml +++ b/.github/workflows/enforce-pr-target.yml @@ -8,17 +8,6 @@ on: - edited - ready_for_review - synchronize - # Review events let the gate catch bot findings (Codex / CodeRabbit review - # threads) that land after a PR was marked ready, without waiting for the - # author's next push. These are top-level webhook events, not activity types - # of pull_request_target — listing them under `types:` is silently ignored by - # GitHub (actionlint: invalid activity type). Both payloads carry - # `pull_request`, so the base.sha trusted checkout and - # `context.payload.pull_request` reads below are unchanged. - pull_request_review: - types: [submitted, edited, dismissed] - pull_request_review_comment: - types: [created, edited] # pull-requests:write covers title/comment/label updates. # contents:write is required for convertPullRequestToDraft / @@ -513,7 +502,16 @@ jobs: const freshReadiness = extractReviewReadiness( freshPr.body ?? "" ); - readinessStateOverride = defaultGateState(); + // Reset only the checklist/notification state. The bot's draft + // ownership and title-prefix ownership survive the reset so a + // wrong-base-drafted PR that was retargeted keeps its restore + // path and its prefix ownership. + readinessStateOverride = { + ...defaultGateState(), + active: gateState.active, + autoDraftedByBot: gateState.autoDraftedByBot, + titlePrefixedByBot: gateState.titlePrefixedByBot + }; headDriftNotice = buildStaleNotice({ completionHeadSha, liveHeadSha: freshPr.head.sha, @@ -683,7 +681,12 @@ jobs: const freshReadiness = extractReviewReadiness( freshPr.body ?? "" ); - readinessStateOverride = defaultGateState(); + readinessStateOverride = { + ...defaultGateState(), + active: gateState.active, + autoDraftedByBot: gateState.autoDraftedByBot, + titlePrefixedByBot: gateState.titlePrefixedByBot + }; claimNotice = [ ...(claimViolations.includes("review_findings") ? findingsUnverifiable @@ -769,8 +772,11 @@ jobs: return actions; } - // The `review-ready` label is the CodeRabbit/Codex opt-in trigger: - // add it at the ready moment, remove it while the PR is not ready. + // The `review-ready` label marks the ready moment for humans and + // bots. It is not a CodeRabbit auto-review filter: a positive + // `labels:` entry in `.coderabbit.yaml` would restrict ALL reviews + // to labeled PRs (maintainer PRs never carry this label), so the + // label is kept as a visible status marker only. const readyMoment = checklistRequired && checklistComplete && failures.length === 0; const reviewReadyDesired = readyMoment; @@ -825,6 +831,47 @@ jobs: if (failures.length > 0) { let draftConversionFailed = false; + // One-line reason for each failure, used in the status line. + const failureStatusReason = failures + .map(failure => { + if (failure.code === "wrong_base") { + return `wrong target branch (${pr.base.ref}); retarget to ${inlineCode(DEFAULT_BASE)}.`; + } + if (failure.code === "wrong_ancestry") { + return "wrong branch ancestry; rebase onto the latest dev."; + } + if (failure.code === "bad_description") { + return `PR description needs work (${failure.reason}).`; + } + if (failure.code === "missing_ui_screenshot") { + return "UI screenshot required."; + } + return failure.code; + }) + .join(" "); + + // Shared notices for the failure comment: revalidation reason, + // the waiver flag, and the prefix notice when the bot owns it. + const failureNotices = [ + ...revalidationNotice, + ...(screenshotWaived + ? ["UI screenshot waived by a maintainer comment."] + : []), + ...(hasWrongBase && state.titlePrefixedByBot + ? [`Its title has been prefixed with ${inlineCode(TITLE_PREFIX.trim())}.`] + : []) + ]; + + const draftComment = (notices) => + upsertGateComment(state, { + status: "DRAFT", + statusReason: failureStatusReason, + actions: buildActions(), + readiness, + checklistRequired, + notices + }); + // Apply the bot-owned title prefix so the PR itself carries a // durable signal of the wrong base (claim ownership before the // write; a failed write keeps ownership for the next retry). @@ -837,10 +884,21 @@ jobs: }); } - if (!pr.draft) { - // Claim draft ownership before the mutation so a successful - // convert followed by a failed comment still restores later. + const wantsDraftConversion = !pr.draft; + if (wantsDraftConversion) { + // Persist the ownership claim BEFORE the mutation so a + // successful convert followed by a failed comment write + // still leaves the bot-created draft owned and restorable. state.autoDraftedByBot = true; + await draftComment([ + ...failureNotices, + "This pull request is being kept as a draft automatically. Once every issue above is resolved, it will be marked ready for review again.", + ...(checklistRequired && !checklistComplete + ? [ + `@${pr.user.login} Tick the boxes once your local CI is green, your branch is on the latest ${inlineCode(DEFAULT_BASE)} commit, and every correct Codex and CodeRabbit finding is resolved.` + ] + : []) + ]); try { await convertToDraft(); draftConverted = true; @@ -850,56 +908,24 @@ jobs: core.warning( `Could not convert pull request to draft: ${error.message}` ); + // Reflect the failed conversion in the persisted state. + await draftComment([ + ...failureNotices, + "Automatic draft conversion failed (token cannot change draft status). Please convert this pull request to a draft manually. The required `enforce-target` check will keep failing until every issue above is resolved." + ]); } + } else { + await draftComment([ + ...failureNotices, + "This pull request was already a draft. Its draft status will be preserved after every issue above is resolved.", + ...(checklistRequired && !checklistComplete + ? [ + `@${pr.user.login} Tick the boxes once your local CI is green, your branch is on the latest ${inlineCode(DEFAULT_BASE)} commit, and every correct Codex and CodeRabbit finding is resolved.` + ] + : []) + ]); } - const draftExplanation = draftConversionFailed - ? "Automatic draft conversion failed (token cannot change draft status). Please convert this pull request to a draft manually. The required `enforce-target` check will keep failing until every issue above is resolved." - : state.autoDraftedByBot - ? "This pull request is being kept as a draft automatically. Once every issue above is resolved, it will be marked ready for review again." - : "This pull request was already a draft. Its draft status will be preserved after every issue above is resolved."; - - const notices = [ - ...revalidationNotice, - ...(screenshotWaived - ? ["UI screenshot waived by a maintainer comment."] - : []), - ...(hasWrongBase && state.titlePrefixedByBot - ? [`Its title has been prefixed with ${inlineCode(TITLE_PREFIX.trim())}.`] - : []), - draftExplanation, - ...(checklistRequired && !checklistComplete - ? [ - `@${pr.user.login} Tick the boxes once your local CI is green, your branch is on the latest ${inlineCode(DEFAULT_BASE)} commit, and every correct Codex and CodeRabbit finding is resolved.` - ] - : []) - ]; - - await upsertGateComment(state, { - status: "DRAFT", - statusReason: failures - .map(failure => { - if (failure.code === "wrong_base") { - return `wrong target branch (${pr.base.ref}); retarget to ${inlineCode(DEFAULT_BASE)}.`; - } - if (failure.code === "wrong_ancestry") { - return "wrong branch ancestry; rebase onto the latest dev."; - } - if (failure.code === "bad_description") { - return `PR description needs work (${failure.reason}).`; - } - if (failure.code === "missing_ui_screenshot") { - return "UI screenshot required."; - } - return failure.code; - }) - .join(" "), - actions: buildActions(), - readiness, - checklistRequired, - notices - }); - core.setFailed( `PR quality gate failed: ${failureSummary(failures, { pr })}` ); @@ -1027,15 +1053,29 @@ jobs: // surface but there is nothing to tick; only render it if the // author is a maintainer and no checklist is required. if (!checklistRequired) { + // `pr.draft` is the value from before the ready path ran; when + // that path already converted the PR (`readyConverted`), the + // recovery must not call markReadyForReview a second time on the + // stale draft state. When the ready path already attempted and + // failed (`readyConversionFailed`), the failure state is carried + // in `readyState`/`recoveredState` and only the comment is + // rewritten, so a later run retries. if (gateState.autoDraftedByBot && pr.draft) { let recoveryFailed = false; - try { - await markReadyForReview(); - } catch (error) { - recoveryFailed = true; - core.warning( - `Could not mark pull request ready for review: ${error.message}` - ); + if (!readyConverted && !readyConversionFailed) { + try { + await markReadyForReview(); + } catch (error) { + recoveryFailed = true; + core.warning( + `Could not mark pull request ready for review: ${error.message}` + ); + } + } else { + // The ready path already handled the mutation: either it + // converted (nothing left to do) or it failed and this run + // must not attempt a second mutation on the stale draft. + recoveryFailed = readyConversionFailed; } const recoveredState = { ...gateState, diff --git a/docs-site/src/content/docs/contributing/pr-quality.md b/docs-site/src/content/docs/contributing/pr-quality.md index d7eb652083..be4c6b7bb9 100644 --- a/docs-site/src/content/docs/contributing/pr-quality.md +++ b/docs-site/src/content/docs/contributing/pr-quality.md @@ -40,7 +40,8 @@ tells you exactly what to change: plan** (or equivalent substance). When the title or description mentions `gui`, the description must include a screenshot of the UI change; the check keeps the PR a draft and comments until the screenshot is present. A - maintainer (OWNER / COLLABORATOR / MEMBER) can waive the screenshot + maintainer (OWNER / COLLABORATOR / MEMBER — repository owners, + collaborators, and members) can waive the screenshot requirement with an issue comment saying the change does not touch the GUI (for example "no gui changes"); the PR author cannot self-waive. Contributor PRs (authors without repository push permission) open in draft @@ -52,7 +53,7 @@ tells you exactly what to change: (excluding the author). The gate's status and "what to do" live in a single consolidated bot comment that is rewritten on every run, so there is exactly one place to look. Completion is bound to the exact commit the PR head - pointed at: if new commits are pushed afterwards, the gate moves the PR back + pointed at: if new commits are pushed afterward, the gate moves the PR back to draft, resets the checklist and the maintainer notification, and asks you to test and tick the boxes again against the latest code. A retarget to `dev` clears the wrong-branch message automatically and is remembered by the @@ -60,12 +61,13 @@ tells you exactly what to change: Before a completion is accepted, the gate verifies the checklist claims it can check itself: the head's `ci` check must be green, the branch must be on the latest `dev` commit or at most 10 commits behind it, and every Codex and - CodeRabbit review thread on the PR must be resolved. A disproved claim - unticks the matching box and keeps the PR a draft. The gate also re-runs on - review events, so a bot finding posted after the PR was marked ready is - caught without waiting for the next push. When the checklist is complete and - every gate is green, the gate adds a `review-ready` label that opts the PR - into CodeRabbit review. + CodeRabbit review thread on the PR must be resolved. CodeRabbit findings + that fall outside the diff range and are reported only in a review body on + the current head are counted the same way while a bot review thread is open; + resolving every bot thread clears them. A disproved claim unticks the + matching box and keeps the PR a draft. When the checklist is complete and + every gate is green, the gate adds a `review-ready` label as a visible + status marker at the ready moment. - **Hygiene.** Behavior changes need a test; new lint or type suppressions, focused or skipped tests, empty catch blocks, edited generated output, and a diff --git a/structure/06_docs-and-release.md b/structure/06_docs-and-release.md index b52466fdc9..1393c17a95 100644 --- a/structure/06_docs-and-release.md +++ b/structure/06_docs-and-release.md @@ -44,7 +44,7 @@ bun run build | `.github/workflows/release.yml` | Manual dispatch only | npm publish/dry-run workflow. It requires the exact `GITHUB_SHA` to have a successful Cross-platform CI run before publish or dry-run. | | `.github/workflows/deploy-docs.yml` | `push` to `main` touching `docs-site/**` or the workflow, or manual dispatch | Build and publish the Astro/Starlight docs site to GitHub Pages. | | `.github/workflows/service-lifecycle.yml` | `pull_request` to `main`/`dev` and `push`, both filtered on the service path set (`src/service.ts`, `src/cli.ts`, `src/cli/index.ts`, `src/lib/bun-runtime.ts`, `package.json`, `bun.lock`, the workflow), or manual dispatch | Service-lifecycle smoke on three platforms: Linux systemd, macOS launchd, and Windows Scheduled Tasks. Each installs, verifies, stops via `ocx stop`, and uninstalls. The path list is kept in sync with the `release.yml` service-gate regex. | -| `.github/workflows/enforce-pr-target.yml` | `pull_request_target` (opened, reopened, edited, ready_for_review, synchronize), `pull_request_review` (submitted, edited, dismissed), `pull_request_review_comment` (created, edited) | The `enforce-target` gate: rejects pull requests whose head ancestry sits on the `main` tip while far behind `dev`, rejects empty or malformed descriptions, requires a GUI screenshot when the title/body mentions `gui` (waivable by a maintainer comment), keeps contributor PRs in draft until a four-box readiness checklist is complete, verifies the CI / latest-dev / Codex+CodeRabbit-findings claims, and adds a `review-ready` label at the ready moment to trigger CodeRabbit review. Stacked child PRs targeting another open PR's head skip the wrong-base gate. | +| `.github/workflows/enforce-pr-target.yml` | `pull_request_target` (opened, reopened, edited, ready_for_review, synchronize) | The `enforce-target` gate: rejects pull requests whose head ancestry sits on the `main` tip while far behind `dev`, rejects empty or malformed descriptions, requires a GUI screenshot when the title/body mentions `gui` (waivable by a maintainer comment), keeps contributor PRs in draft until a four-box readiness checklist is complete, verifies the CI / latest-dev / Codex+CodeRabbit-findings claims (review threads plus current-head CodeRabbit review-body findings outside the diff range), and adds a `review-ready` status label at the ready moment. Stacked child PRs targeting another open PR's head skip the wrong-base gate. | | `.github/workflows/enforce-issue-quality.yml` | `issues` (opened, edited, reopened), `issue_comment` (created, edited), or manual dispatch with an issue number | Issue-template compliance gate. | | `.github/workflows/issue-quality-tests.yml` | `pull_request` and `push` filtered on the issue/PR automation scripts, templates, and their workflows | Tests the issue and PR automation scripts themselves, so the gates cannot rot silently. | | `.github/workflows/issue-triage.yml` | `issues` (opened) | Duplicate detection and triage labeling for new issues. | diff --git a/tests/ci-workflows.test.ts b/tests/ci-workflows.test.ts index 30536ddd7d..ccf0345fb5 100644 --- a/tests/ci-workflows.test.ts +++ b/tests/ci-workflows.test.ts @@ -829,16 +829,12 @@ describe("GitHub Actions hardening", () => { ]); // pull_request_target runs with the base repo's token. Checking out or - // executing the PR's code under it is the classic escalation. - // The two review events are sibling top-level events (not - // `pull_request_target` activity types — those are silently ignored by - // GitHub), and both payloads carry `pull_request`, so the trusted base.sha - // checkout and the `context.payload.pull_request` reads stay unchanged. - expect(Object.keys(workflow.on ?? {}).sort()).toEqual([ - "pull_request_review", - "pull_request_review_comment", - "pull_request_target", - ]); + // executing the PR's code under it is the classic escalation. Review + // events are deliberately NOT added: they load the workflow from the PR + // head branch (like `pull_request`), which would run head-controlled + // workflow YAML under a write token against base-pinned scripts — a + // mismatch that crashes the gate and breaks the trusted-base model. + expect(Object.keys(workflow.on ?? {})).toEqual(["pull_request_target"]); // And the trigger is exactly a `types:` list — nothing else. // @@ -850,9 +846,6 @@ describe("GitHub Actions hardening", () => { // additive, both look like ordinary scoping in a diff, and neither failed a // single assertion. expect(Object.keys(workflow.on?.pull_request_target ?? {})).toEqual(["types"]); - // The sibling review events are also exactly `types:` lists. - expect(Object.keys(workflow.on?.pull_request_review ?? {})).toEqual(["types"]); - expect(Object.keys(workflow.on?.pull_request_review_comment ?? {})).toEqual(["types"]); // Exactly the scopes this gate needs. `pull-requests: write` covers title // and comment updates. `contents: write` is required for the draft GraphQL @@ -953,20 +946,11 @@ describe("GitHub Actions hardening", () => { "reopened", "synchronize", ]); - - // Review events are top-level webhook events, not `pull_request_target` - // activity types — GitHub silently ignores invalid types, so the gate - // would never re-run on a bot finding posted after ready. Assert the - // sibling events and their activity-type lists. - expect(workflow.on?.pull_request_review?.types).toEqual([ - "submitted", - "edited", - "dismissed", - ]); - expect(workflow.on?.pull_request_review_comment?.types).toEqual([ - "created", - "edited", - ]); + // Review events must NOT be added: they load the workflow from the PR + // head branch, breaking the base-pinned checkout (`pull_request_review` + // runs head YAML + base scripts → `parseGateState is not a function`). + expect(workflow.on?.pull_request_review).toBeUndefined(); + expect(workflow.on?.pull_request_review_comment).toBeUndefined(); // The verdict is a live PR read plus ancestry/description checks. expect(script).toContain("github.rest.pulls.get"); @@ -1223,14 +1207,15 @@ describe("GitHub Actions hardening", () => { /** * The writes a fresh wrong-base contributor PR triggers: inject the - * checklist, then the title-prefix + draft conversion plus the single - * consolidated comment. + * checklist, then the title prefix, then the ownership checkpoint comment + * (claiming `autoDraftedByBot` before the mutation), then the draft + * conversion. */ const CONTRIBUTOR_WRONG_BASE_TAIL = [ "pulls.update", "pulls.update", - "graphql", "issues.createComment", + "graphql", ]; function botComment(state: Record, title = "Add a thing") { @@ -1501,9 +1486,9 @@ describe("GitHub Actions hardening", () => { "pulls.get", "pulls.update", "pulls.update", - "graphql", "issues.createComment", "issues.deleteComment", + "graphql", ])); expect(lastEnforcerCommentBody(result)).toContain("wrong target branch"); expect(lastEnforcerCommentBody(result)).toContain("[WRONG BRANCH]"); @@ -1635,6 +1620,39 @@ describe("GitHub Actions hardening", () => { expect(result.warnings.some(w => w.startsWith("setFailed:"))).toBe(false); }); + test("a revalidation reset preserves bot ownership of the title prefix", async () => { + // A wrong-base PR that the bot prefixed and later had retargeted to dev + // with a complete checklist hits a revalidation failure (red CI unchecks + // a box). The reset must preserve `titlePrefixedByBot` long enough for + // the mustDraft strip to fire — otherwise the stale `[WRONG BRANCH] ` + // prefix stays on the title forever because ownership was forgotten. + const result = await run({ + pr: { + base: { ref: "dev" }, + draft: true, + title: "[WRONG BRANCH] Add a thing", + body: readinessChecklistBody(4), + }, + maintainersFile: MAINTAINERS_FIXTURE, + checkRuns: [{ name: "ci", status: "completed", conclusion: "failure" }], + comments: [botComment({ + version: 1, + active: true, + autoDraftedByBot: true, + titlePrefixedByBot: true, + })], + }); + + // The stale prefix is stripped (the ownership survived the reset long + // enough for the strip to run), and the state records ownership cleared. + const titleUpdates = callsTo(result, "pulls.update") as Array<{ title?: string; body?: string }>; + expect(titleUpdates.some(u => u.title === "Add a thing")).toBe(true); + const readinessBody = lastReadinessCommentBody(result); + expect(readinessBody).toContain('"titlePrefixedByBot":false'); + expect(readinessBody).toContain('"autoDraftedByBot":true'); + expect(readinessBody).toContain("GitHub CI is not green"); + }); + test("a complete checklist more than 10 commits behind dev unchecks the latest-dev box and re-drafts", async () => { const result = await run({ pr: { @@ -2122,8 +2140,8 @@ describe("GitHub Actions hardening", () => { }); expect(methodsOf(result)).toEqual(readsAllowedBase([ - "graphql", "issues.createComment", + "graphql", ])); expect(result.warnings.some(w => w.startsWith("setFailed:"))).toBe(true); const readinessBody = lastReadinessCommentBody(result); @@ -2436,8 +2454,8 @@ describe("GitHub Actions hardening", () => { // nothing else — no checklist injection, no readiness message. expect(methodsOf(result)).toEqual(readsWrongBase([ "pulls.update", - "graphql", "issues.createComment", + "graphql", ])); expect(callsTo(result, "pulls.update")).toEqual([ { owner: "lidge-jun", repo: "opencodex", pull_number: 42, title: "[WRONG BRANCH] Add a thing" }, @@ -2462,11 +2480,12 @@ describe("GitHub Actions hardening", () => { }); // No wrong base, so no title write — the checklist injection is the only - // `pulls.update`, and the contributor flow adds the single gate comment. + // `pulls.update`, and the contributor flow writes the ownership + // checkpoint comment before the draft conversion. expect(methodsOf(result)).toEqual(readsAllowedBase([ "pulls.update", - "graphql", "issues.createComment", + "graphql", ])); const commentBody = lastEnforcerCommentBody(result); expect(commentBody).toContain("wrong branch ancestry"); @@ -2771,8 +2790,8 @@ describe("GitHub Actions hardening", () => { expect(methodsOf(result)).toEqual(readsWrongBase([ "pulls.update", "pulls.update", - "graphql", "issues.createComment", + "graphql", ])); expect(lastEnforcerCommentBody(result)).toContain(`wrong target branch (${ref})`); expect(result.warnings.some((w) => w.startsWith("setFailed:"))).toBe(true); @@ -2850,9 +2869,9 @@ describe("GitHub Actions hardening", () => { expect(methodsOf(result)).toEqual(readsWrongBase([ "pulls.update", "pulls.update", - "graphql", "issues.createComment", "issues.deleteComment", + "graphql", ])); expect(callsTo(result, "pulls.update")).toEqual([ { @@ -2893,8 +2912,8 @@ describe("GitHub Actions hardening", () => { expect(methodsOf(result)).toEqual(readsWrongBase([ "pulls.update", "pulls.update", - "graphql", "issues.createComment", + "graphql", ])); // The title update carries the title and nothing else. `base`, `state` @@ -3023,8 +3042,8 @@ describe("GitHub Actions hardening", () => { expect(methodsOf(result)).toEqual(readsWrongBase([ "pulls.update", "pulls.update", - "graphql", "issues.createComment", + "graphql", ])); expect(callsTo(result, "pulls.update")).toEqual([ { @@ -3168,8 +3187,8 @@ describe("GitHub Actions hardening", () => { expect(methodsOf(wentWrong)).toEqual(readsWrongBase([ "pulls.update", "pulls.update", - "graphql", "issues.createComment", + "graphql", ])); expect(callsTo(wentWrong, "pulls.update")).toEqual([ { @@ -3280,9 +3299,9 @@ describe("GitHub Actions hardening", () => { expect(methodsOf(result)).toEqual(readsWrongBase([ "pulls.update", "pulls.update", - "graphql", "issues.createComment", "issues.deleteComment", + "graphql", ])); expect(result.warnings.join(" ")).toContain("Could not parse stored workflow state"); }); @@ -3488,9 +3507,9 @@ describe("GitHub Actions hardening", () => { expect(methodsOf(wrong)).toEqual(readsWrongBase([ "pulls.update", "pulls.update", - "graphql", "issues.createComment", "issues.deleteComment", + "graphql", ])); expect(lastEnforcerCommentBody(wrong)).toContain('"version":1'); expect(lastEnforcerCommentBody(wrong)).toContain('"active":true'); @@ -3559,19 +3578,20 @@ describe("GitHub Actions hardening", () => { }); test("ownership comment is checkpointed before mutations and finalized after", async () => { - // Ownership is written before title/draft. autoDraftedByBot is claimed and - // checkpointed before convertToDraft so a successful convert followed by a - // failed comment still restores later. + // Ownership is written before the draft mutation. autoDraftedByBot is + // claimed and checkpointed in the consolidated comment BEFORE + // convertToDraft, so a successful convert followed by a failed comment + // write still leaves the bot-created draft owned and restorable. const result = await run({ pr: { base: { ref: "main" }, draft: false } }); // The exact call order pins the ownership discipline: the title is - // prefixed, `autoDraftedByBot` is claimed in state before convertToDraft, - // and the single consolidated comment is written after the mutation. + // prefixed, the ownership comment is written (claiming + // `autoDraftedByBot`), then convertToDraft runs. expect(methodsOf(result)).toEqual(readsWrongBase(CONTRIBUTOR_WRONG_BASE_TAIL)); const methods = methodsOf(result); const ownershipIndex = methods.indexOf("issues.createComment"); const draftIndex = methods.indexOf("graphql"); - expect(ownershipIndex).toBeGreaterThan(draftIndex); + expect(ownershipIndex).toBeLessThan(draftIndex); // The single comment records that the bot drafted. const commentBody = lastReadinessCommentBody(result); @@ -3599,8 +3619,8 @@ describe("GitHub Actions hardening", () => { ]); expect(methodsOf(result)).toEqual(readsWrongBase([ "pulls.update", - "graphql", "issues.createComment", + "graphql", ])); expect(lastEnforcerCommentBody(result)).toContain('"titlePrefixedByBot":false'); expect(lastEnforcerCommentBody(result)).toContain('"autoDraftedByBot":true'); @@ -3673,8 +3693,8 @@ describe("GitHub Actions hardening", () => { expect(methodsOf(result)).toEqual(readsWrongBase([ "pulls.update", - "graphql", "issues.createComment", + "graphql", ])); // Already prefixed by the `startsWith` test, so no third prefix is added. expect(callsTo(result, "pulls.update")).toEqual([ @@ -3898,8 +3918,9 @@ describe("GitHub Actions hardening", () => { expect(methodsOf(result)).toEqual(readsWrongBase([ "pulls.update", "pulls.update", - "graphql", "issues.createComment", + "graphql", + "issues.updateComment", ])); const commentBody = lastEnforcerCommentBody(result); expect(commentBody).toContain('"autoDraftedByBot":false'); @@ -4033,19 +4054,22 @@ describe("GitHub Actions hardening", () => { ); expect(script).toMatch(/pr-quality-messages\.cjs/); - // Ownership is claimed in state before the draft mutation; the single - // consolidated comment is written once after the mutations (#631: only a - // successful conversion records autoDraftedByBot). + // Ownership is claimed and checkpointed in the consolidated comment BEFORE + // the draft mutation, so a successful convert followed by a failed comment + // write still leaves the bot-created draft owned and restorable. Only a + // failed conversion rewrites the comment with autoDraftedByBot:false. const branchStart = script.indexOf("if (failures.length > 0) {"); expect(branchStart).toBeGreaterThan(-1); const branch = script.slice(branchStart); const ownershipClaimIndex = branch.indexOf("state.autoDraftedByBot = true;"); const draftCallIndex = branch.indexOf("await convertToDraft()"); - const gateWriteIndex = branch.indexOf("await upsertGateComment("); + // The failure path writes the ownership checkpoint through the shared + // `draftComment` helper, which is defined before the draft mutation runs. + const gateWriteIndex = branch.indexOf("const draftComment = (notices) =>"); expect(ownershipClaimIndex).toBeGreaterThan(-1); expect(draftCallIndex).toBeGreaterThan(-1); expect(ownershipClaimIndex).toBeLessThan(draftCallIndex); - expect(gateWriteIndex).toBeGreaterThan(draftCallIndex); + expect(gateWriteIndex).toBeLessThan(draftCallIndex); }); test("docs deployment is pinned, bounded, and scoped to Pages", async () => { From 134956b3ed29f7361b39ae3076af5d7b2ca0328e Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 12:31:56 +0900 Subject: [PATCH 035/317] fix(codex): re-read desired state inside the catalog commit --- src/codex/catalog/sync.ts | 19 ++++++++++++++++++- src/codex/refresh.ts | 7 +++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index 79113d42f4..03b74f6def 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -2,7 +2,8 @@ import { execFileSync } from "node:child_process"; import { createHash } from "node:crypto"; import { existsSync, readFileSync } from "node:fs"; import { delimiter, dirname, join, resolve } from "node:path"; -import { expandUserPath, readConfigDiagnostics, websocketsEnabled } from "../../config"; +import { expandUserPath, loadConfig, readConfigDiagnostics, websocketsEnabled } from "../../config"; +import { shouldSyncCodexOnStart } from "../desired-state"; import { CODEX_CONFIG_PATH, CODEX_MODELS_CACHE_PATH, DEFAULT_CATALOG_PATH, getCodexHome, readRootTomlString, resolveCodexConfigPath } from "../paths"; import { clearModelCache, DEFAULT_MODEL_CACHE_TTL_MS, getFreshCached, getStaleCached, isModelsFetchCoolingDown, markModelsFetchFailure, setCached } from "../model-cache"; import { buildModelsRequest, resolveModelsAuthToken } from "../../oauth"; @@ -652,6 +653,8 @@ interface RetainedCatalogSyncResult { path: string; catalogWritten: boolean; comboOmissions: ComboCatalogOmission[]; + /** `desired_disabled` observed under K after the provider await; nothing was written. */ + skippedReason?: "desired_disabled"; } interface RetainedCatalogSyncWrite { @@ -965,6 +968,20 @@ export async function syncCatalogModels(config: OcxConfig): Promise { + // Desired state can flip OFF during the provider await above. The catalog + // evidence revalidation below cannot see that — intent lives in our config, + // not in the catalog files — so the policy is re-read here, under K, right + // before the only write. A lost race becomes the discriminated skip instead + // of a routed catalog/cache surviving a completed disable. + if (!shouldSyncCodexOnStart(loadConfig())) { + return { + added: 0, + path: prepared.catalogPath, + catalogWritten: false, + comboOmissions, + skippedReason: "desired_disabled" as const, + }; + } const current = revalidateRetainedCatalogSync(config, prepared); if (current === null) return null; return writeRetainedCatalogSync({ diff --git a/src/codex/refresh.ts b/src/codex/refresh.ts index 1cb582e8ed..4b5ae47321 100644 --- a/src/codex/refresh.ts +++ b/src/codex/refresh.ts @@ -12,6 +12,8 @@ export interface CodexCatalogRefreshResult { catalogWritten: boolean; cacheSynced: boolean; comboOmissions: ComboCatalogOmission[]; + /** Desired OFF observed under K during the catalog commit; no cache write either. */ + skippedReason?: "desired_disabled"; } interface RefreshDeps { @@ -45,6 +47,11 @@ export async function refreshCodexModelCatalog( const catalogExists = deps.existsSync(result.path); const catalogWritten = result.catalogWritten === true; const comboOmissions = result.comboOmissions ?? []; + if (result.skippedReason === "desired_disabled") { + // The commit path observed OFF under K. Invalidate nothing: rewriting the + // models cache here would be exactly the routed-cache write the skip refused. + return { ...result, catalogExists, catalogWritten: false, cacheSynced: false, comboOmissions }; + } if (!catalogExists) { return { ...result, catalogExists, catalogWritten: false, cacheSynced: false, comboOmissions }; } From f11bb6d6b380ecd7370db9fc54236ea43df20110 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 12:38:18 +0900 Subject: [PATCH 036/317] test(codex): behavioral coverage for lost-transition and restore gating --- tests/cli-restore-back.test.ts | 23 ++++++++-- tests/codex-history-job.test.ts | 81 +++++++++++++++++++++++++-------- tests/codex-sync-api.test.ts | 64 +++++++++++++++++++++++++- 3 files changed, 143 insertions(+), 25 deletions(-) diff --git a/tests/cli-restore-back.test.ts b/tests/cli-restore-back.test.ts index 66b31a7df1..2cb92850ac 100644 --- a/tests/cli-restore-back.test.ts +++ b/tests/cli-restore-back.test.ts @@ -4,7 +4,6 @@ import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node import { tmpdir } from "node:os"; import { join } from "node:path"; -const helpSource = readFileSync(join(import.meta.dir, "..", "src", "cli", "help.ts"), "utf8"); const repoRoot = join(import.meta.dir, ".."); describe("ocx restore back", () => { @@ -84,8 +83,24 @@ describe("ocx restore back", () => { }); test("help documents both directions of the switch", () => { - expect(helpSource).toContain("ocx restore [back]"); - expect(helpSource).toContain("ocx eject [back]"); - expect(helpSource).toContain("ocx restore back"); + const ocxHome = mkdtempSync(join(tmpdir(), "ocx-cli-help-home-")); + try { + writeFileSync(join(ocxHome, "config.json"), JSON.stringify({ + providers: {}, defaultProvider: "openai", checkForUpdates: false, + }), "utf8"); + const run = (...cliArgs: string[]) => spawnSync(process.execPath, ["run", "src/cli/index.ts", ...cliArgs], { + cwd: repoRoot, + env: { ...process.env, OPENCODEX_HOME: ocxHome, CI: "1" }, + encoding: "utf8", + }); + const usage = run("help"); + expect(usage.status).toBe(0); + expect(`${usage.stdout}\n${usage.stderr}`).toContain("ocx restore back"); + const restoreHelp = run("help", "restore"); + expect(restoreHelp.status).toBe(0); + expect(`${restoreHelp.stdout}\n${restoreHelp.stderr}`).toContain("ocx restore [back]"); + } finally { + rmSync(ocxHome, { recursive: true, force: true }); + } }); }); diff --git a/tests/codex-history-job.test.ts b/tests/codex-history-job.test.ts index da92ea62b5..6cae854c62 100644 --- a/tests/codex-history-job.test.ts +++ b/tests/codex-history-job.test.ts @@ -1,4 +1,5 @@ import { afterEach, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; import { chmodSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -135,27 +136,67 @@ test("an overrun Worker returns a typed timeout rather than hanging", async () = /** * The async restore wrapper owns history; the synchronous body must not also do - * it, or every restore would run the transition twice — once unserialized on the - * caller thread, which is the path this phase exists to remove. + * it when told to stand down, or every restore would run the transition twice — + * once unserialized on the caller thread, which is the path this phase removed. * - * Asserted against the SOURCE rather than by running it. The synchronous body - * resolves its state database from a module-load constant - * (`history-provider.ts:16`), so a test that moves `CODEX_HOME` cannot observe - * which database it would have touched — a behavioural version of this passed - * with `skipHistory` ignored entirely, which is worse than no test. Removing the - * guard changes this text, and that is something a check can actually see. + * Proven by BEHAVIOR in a child process. The provider resolves its state + * database from a module-load constant, so the fixture `CODEX_HOME` must be in + * the environment before the module loads — a spawned child gives exactly that. + * The fixture DB holds a restorable opencodex-tagged row; `skipHistory: true` + * must leave it tagged, and the default must restore it. */ test("the synchronous restore body is gated on skipHistory", () => { - const source = readFileSync(join(import.meta.dir, "..", "src", "codex", "inject.ts"), "utf8"); - const body = source.slice(source.indexOf("export function restoreNativeCodex(")); - const historyCall = body.indexOf("syncCodexHistoryProvider(\"openai\""); - expect(historyCall).toBeGreaterThan(-1); - - // The inline call is reachable only through the gate. - const gate = body.indexOf("options.skipHistory"); - expect(gate).toBeGreaterThan(-1); - expect(gate).toBeLessThan(historyCall); - - // And the async wrapper is the thing that sets it. - expect(source).toContain("restoreNativeCodex({ skipHistory: true,"); + const repoRoot = join(import.meta.dir, ".."); + const root = mkdtempSync(join(tmpdir(), "ocx-restore-skiphistory-")); + const fixtureCodexHome = join(root, ".codex"); + const fixtureOcxHome = join(root, ".opencodex"); + mkdirSync(fixtureCodexHome, { recursive: true }); + mkdirSync(fixtureOcxHome, { recursive: true }); + try { + writeFileSync(join(fixtureCodexHome, "config.toml"), 'model = "gpt-5"\n', "utf8"); + const rollout = join(fixtureCodexHome, "rollout.jsonl"); + writeFileSync(rollout, JSON.stringify({ + type: "session_meta", + payload: { id: "thread-1", model_provider: "opencodex", source: "cli", cwd: fixtureCodexHome }, + }) + "\n"); + const dbPath = join(fixtureCodexHome, "state_5.sqlite"); + const db = new Database(dbPath); + db.run(`CREATE TABLE threads ( + id TEXT PRIMARY KEY, rollout_path TEXT NOT NULL, model_provider TEXT NOT NULL, + source TEXT NOT NULL, first_user_message TEXT NOT NULL, has_user_event INTEGER NOT NULL DEFAULT 0)`); + db.run(`INSERT INTO threads VALUES ('thread-1', ?, 'opencodex', 'cli', 'hello', 1)`, rollout); + db.close(); + + const runRestore = (optionsLiteral: string) => spawnSync(process.execPath, ["--eval", [ + 'const { restoreNativeCodex } = require("./src/codex/inject");', + `const result = restoreNativeCodex(${optionsLiteral});`, + 'console.log(JSON.stringify({ history: result.artifacts.history.state }));', + ].join("\n")], { + cwd: repoRoot, + env: { ...process.env, CODEX_HOME: fixtureCodexHome, OPENCODEX_HOME: fixtureOcxHome }, + encoding: "utf8", + }); + const provider = () => { + const check = new Database(dbPath, { readonly: true }); + const row = check.query<{ model_provider: string }, []>( + "SELECT model_provider FROM threads WHERE id = 'thread-1'", + ).get(); + check.close(); + return row?.model_provider; + }; + + // skipHistory: the wrapper owns history, so the synchronous body writes none. + const skipped = runRestore("{ skipHistory: true }"); + expect(skipped.status).toBe(0); + expect(JSON.parse(skipped.stdout.trim().split("\n").filter(Boolean).pop() ?? "{}")).toEqual({ history: "skipped" }); + expect(provider()).toBe("opencodex"); + + // Default: the same body restores history itself. + const restored = runRestore("{}"); + expect(restored.status).toBe(0); + expect(JSON.parse(restored.stdout.trim().split("\n").filter(Boolean).pop() ?? "{}")).toEqual({ history: "ok" }); + expect(provider()).toBe("openai"); + } finally { + rmSync(root, { recursive: true, force: true }); + } }); diff --git a/tests/codex-sync-api.test.ts b/tests/codex-sync-api.test.ts index 70f13d781e..796133307b 100644 --- a/tests/codex-sync-api.test.ts +++ b/tests/codex-sync-api.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { spawnSync } from "node:child_process"; -import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import { syncModelsToCodex } from "../src/codex/sync"; import { MANAGED_AGENTS_TABLE_MARKER, MANAGED_SUBAGENT_DEFAULT_MARKER } from "../src/codex/subagent-defaults"; @@ -9,6 +10,7 @@ import type { OrcaCodexHomeDiagnostic } from "../src/codex/home"; const TEST_DIR = join(import.meta.dir, ".tmp-codex-sync-api"); const TEST_CODEX_HOME = join(TEST_DIR, "codex"); +const repoRoot = join(import.meta.dir, ".."); let prevCodexHome: string | undefined; const config = { @@ -106,6 +108,66 @@ describe("GUI/CLI Codex sync backend", () => { expect(injected).toBe(false); }); + /** + * The lost-transition race, with a REAL second process. The caller's config + * snapshot says ON; while provider discovery is awaited, another process + * persists OFF. The under-lock re-read inside the real injector must observe + * the fresh persisted intent and skip — the snapshot must not win. + * + * Runs entirely in a child process with its own temp CODEX_HOME, because the + * injector resolves its config path at module load: an in-process variant + * would silently address the suite's isolated home instead of the fixture. + */ + test("a competing OFF during catalog discovery becomes the discriminated skip", async () => { + const raceRoot = mkdtempSync(join(tmpdir(), "ocx-sync-lost-transition-")); + const raceCodexHome = join(raceRoot, ".codex"); + const raceOcxHome = join(raceRoot, ".opencodex"); + mkdirSync(raceCodexHome, { recursive: true }); + mkdirSync(raceOcxHome, { recursive: true }); + try { + writeFileSync(join(raceCodexHome, "config.toml"), 'model = "gpt-5"\n', "utf8"); + writeFileSync(join(raceOcxHome, "config.json"), JSON.stringify({ + providers: {}, defaultProvider: "openai", checkForUpdates: false, + })); + const script = [ + 'const { spawnSync } = require("node:child_process");', + 'const { loadConfig } = require("./src/config");', + 'const { syncModelsToCodex } = require("./src/codex/sync");', + 'const { injectCodexConfig } = require("./src/codex/inject");', + '(async () => {', + ' const snapshot = loadConfig(); // admitted BEFORE the flip: reads as ON', + ' const result = await syncModelsToCodex(12345, snapshot, null, {', + ' refreshCodexModelCatalog: async () => {', + ' // The provider-discovery window: a second real process persists OFF.', + ' const flip = spawnSync(process.execPath, ["--eval",', + ' \'const { setIntegrationEnabled } = require("./src/codex/desired-state");\'', + ' + \'const r = setIntegrationEnabled("codex", false);\'', + ' + \'if (!r.ok) { console.error(JSON.stringify(r)); process.exit(1); }\',', + ' ], { cwd: process.cwd(), env: process.env, encoding: "utf8" });', + ' if (flip.status !== 0) throw new Error("flip failed: " + flip.stderr);', + ' return { added: 0, path: "/tmp/none.json", catalogExists: false, catalogWritten: false, cacheSynced: false, comboOmissions: [] };', + ' },', + ' injectCodexConfig, // the REAL injector; its under-lock re-read is the claim', + ' });', + ' console.log(JSON.stringify({ status: result.status, skippedReason: result.skippedReason, ok: result.ok }));', + '})();', + ].join("\n"); + const before = readFileSync(join(raceCodexHome, "config.toml"), "utf8"); + const child = spawnSync(process.execPath, ["--eval", script], { + cwd: repoRoot, + env: { ...process.env, CODEX_HOME: raceCodexHome, OPENCODEX_HOME: raceOcxHome }, + encoding: "utf8", + }); + expect(child.status).toBe(0); + const line = child.stdout.trim().split("\n").filter(Boolean).pop() ?? "{}"; + expect(JSON.parse(line)).toMatchObject({ status: "skipped", skippedReason: "desired_disabled", ok: true }); + // The stale ON snapshot wrote nothing: the fixture config is untouched. + expect(readFileSync(join(raceCodexHome, "config.toml"), "utf8")).toBe(before); + } finally { + rmSync(raceRoot, { recursive: true, force: true }); + } + }); + test("surfaces combo catalog omissions in sync result and CLI stderr (#484)", async () => { const logs: string[] = []; const errors: string[] = []; From b2220024fe7959ece2620b4be3bd54ada045963e Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Thu, 6 Aug 2026 05:41:02 +0200 Subject: [PATCH 037/317] fix(ci): remove duplicate titleUpdates declaration, assert coderabbit config Address the second CodeRabbit re-review round: - tests/ci-workflows.test.ts: remove the duplicated const titleUpdates declaration (parse error) in the ownership-preserving-reset test. - enforce-pr-target.test.cjs: assert .coderabbit.yaml's auto_review has no positive labels filter directly (the workflow never writes a labels block), so a future config change that starves maintainer PRs of reviews is caught. - docs: scope the self-waive sentence to contributor authors; a maintainer who authors the PR can waive (holds push permission, not checklist-gated). Gates: node --test .github/scripts (421 pass), bun test tests/ci-workflows (113 pass), bun run typecheck, bun run privacy:scan, actionlint clean. --- .github/scripts/enforce-pr-target.test.cjs | 21 ++++++++++++++----- .../content/docs/contributing/pr-quality.md | 4 +++- tests/ci-workflows.test.ts | 2 +- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/.github/scripts/enforce-pr-target.test.cjs b/.github/scripts/enforce-pr-target.test.cjs index 52cfe393bc..bcda24f68d 100644 --- a/.github/scripts/enforce-pr-target.test.cjs +++ b/.github/scripts/enforce-pr-target.test.cjs @@ -94,11 +94,22 @@ describe("enforce-pr-target workflow", () => { assert.match(workflow, /github\.rest\.issues\.addLabels/); assert.match(workflow, /github\.rest\.issues\.removeLabel/); assert.match(workflow, /reviewReadyDesired/); - // A positive labels filter in .coderabbit.yaml would restrict ALL reviews - // to labeled PRs (maintainer PRs never carry this label), so the label is - // kept as a visible status marker only and never wired as a CodeRabbit - // auto-review filter. - assert.doesNotMatch(workflow, /labels:\s*\["?review-ready"?\]/); + }); + + it("keeps CodeRabbit auto-review unfiltered so maintainer PRs are not starved", () => { + // A positive `labels:` filter under `reviews.auto_review` in + // `.coderabbit.yaml` would restrict ALL automatic reviews to PRs carrying + // that label. Maintainer PRs never carry `review-ready` (no checklist), so + // such a filter would silently stop CodeRabbit from reviewing maintainer + // PRs. The label is a status marker only; assert the reviewer config + // directly, since the workflow never writes a labels block. + const coderabbit = fs.readFileSync( + path.join(__dirname, "../../.coderabbit.yaml"), + "utf8", + ); + const autoReview = coderabbit.match(/auto_review:[\s\S]*?(?=\n\S|\n\s{2}\S)/); + assert.ok(autoReview, ".coderabbit.yaml must declare auto_review"); + assert.doesNotMatch(autoReview[0], /labels:/); }); it("migrates legacy two-comment PRs and deletes the old comments", () => { diff --git a/docs-site/src/content/docs/contributing/pr-quality.md b/docs-site/src/content/docs/contributing/pr-quality.md index be4c6b7bb9..613f93529b 100644 --- a/docs-site/src/content/docs/contributing/pr-quality.md +++ b/docs-site/src/content/docs/contributing/pr-quality.md @@ -43,7 +43,9 @@ tells you exactly what to change: maintainer (OWNER / COLLABORATOR / MEMBER — repository owners, collaborators, and members) can waive the screenshot requirement with an issue comment saying the change does not touch the GUI - (for example "no gui changes"); the PR author cannot self-waive. + (for example "no gui changes"); a contributor PR author cannot self-waive + (a maintainer who authors the PR can waive, but they already hold push + permission and are not gated by the contributor checklist). Contributor PRs (authors without repository push permission) open in draft and stay there until a four-box review-readiness checklist in the description is complete: local CI green, the branch on the latest `dev` diff --git a/tests/ci-workflows.test.ts b/tests/ci-workflows.test.ts index ccf0345fb5..b5a8e8c2a6 100644 --- a/tests/ci-workflows.test.ts +++ b/tests/ci-workflows.test.ts @@ -1643,9 +1643,9 @@ describe("GitHub Actions hardening", () => { })], }); + const titleUpdates = callsTo(result, "pulls.update") as Array<{ title?: string; body?: string }>; // The stale prefix is stripped (the ownership survived the reset long // enough for the strip to run), and the state records ownership cleared. - const titleUpdates = callsTo(result, "pulls.update") as Array<{ title?: string; body?: string }>; expect(titleUpdates.some(u => u.title === "Add a thing")).toBe(true); const readinessBody = lastReadinessCommentBody(result); expect(readinessBody).toContain('"titlePrefixedByBot":false'); From 9c6fc9b9c20635249a1fe618b8cdfa194c6d5963 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 12:55:41 +0900 Subject: [PATCH 038/317] fix(codex): gate the cache reacquisition write on desired state The commit-path OFF check runs under the first catalog permit; the models_cache rewrite reacquires K after release, so a disable landing in the gap could still publish a routed cache. Re-read intent under the second permit too. --- src/codex/catalog/sync.ts | 5 +++++ tests/codex-models-cache-invalidate.test.ts | 21 +++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index 03b74f6def..8ad5becfad 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -1066,6 +1066,11 @@ export function invalidateCodexModelsCacheWithPermit( owningCodexHome: string, ): boolean { try { + // This permit is a REACQUISITION: refreshCodexModelCatalog's commit released + // K before this rewrite runs, so the commit-path desired-state check cannot + // cover it. A disable landing in that gap must not be overwritten by a + // routed cache write — re-read intent under this permit, same as the commit. + if (!shouldSyncCodexOnStart(loadConfig())) return false; const catalogPath = readCodexCatalogPath(); if (!existsSync(catalogPath)) return false; const catalog = JSON.parse(readFileSync(catalogPath, "utf8")); diff --git a/tests/codex-models-cache-invalidate.test.ts b/tests/codex-models-cache-invalidate.test.ts index e013ac2266..0bff240fc8 100644 --- a/tests/codex-models-cache-invalidate.test.ts +++ b/tests/codex-models-cache-invalidate.test.ts @@ -54,6 +54,27 @@ describe("invalidateCodexModelsCache write gate (#476 / #518)", () => { expect(cache.models).toEqual([{ slug: "gpt-5.5" }]); }); + test("refuses the cache rewrite when desired state flipped OFF between commit and reacquisition", () => { + // The commit-path desired-state check runs under the FIRST catalog permit; + // refreshCodexModelCatalog then releases K before invalidateCodexModelsCache + // reacquires it. An OFF landing in that gap must gate this second write too — + // otherwise a routed models_cache survives a completed disable while the + // injector honestly reports status:"skipped". + writeFileSync(join(codexHome, "opencodex-catalog.json"), JSON.stringify({ + models: [{ slug: "gpt-5.5" }], + }, null, 2) + "\n"); + mkdirSync(join(opencodexHome, ".opencodex"), { recursive: true }); + writeFileSync(join(opencodexHome, "config.json"), JSON.stringify({ + port: 10100, + defaultProvider: "openai", + providers: {}, + clientIntegrations: { codex: false }, + }, null, 2) + "\n"); + + expect(invalidateCodexModelsCache()).toBe(false); + expect(existsSync(join(codexHome, "models_cache.json"))).toBe(false); + }); + test("returns false for a missing catalog and does not warn/restart app-servers", () => { const errors: string[] = []; const logs: string[] = []; From 4167b8607bd71f99c7f383347992543a1c4cc8fe Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 13:02:48 +0900 Subject: [PATCH 039/317] =?UTF-8?q?docs(devlog):=20020=20amendments=20?= =?UTF-8?q?=E2=80=94=20Desktop=20toggle=20adapted=20to=20the=20real=20thre?= =?UTF-8?q?e-client=20contract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../020_desktop_toggle_amendments.md | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 devlog/_plan/260806_wp13_toggles_resume/020_desktop_toggle_amendments.md diff --git a/devlog/_plan/260806_wp13_toggles_resume/020_desktop_toggle_amendments.md b/devlog/_plan/260806_wp13_toggles_resume/020_desktop_toggle_amendments.md new file mode 100644 index 0000000000..7fd169a24f --- /dev/null +++ b/devlog/_plan/260806_wp13_toggles_resume/020_desktop_toggle_amendments.md @@ -0,0 +1,87 @@ +# 020 — Claude Desktop toggle: design amendments over 050 + +`050_desktop_toggle.md` stays the diff-level source for the standard-mode pivot +order, the credential-cleanup contract, status classification, refusal copy, GUI +wiring, and the i18n keys. Its structural premises are replaced here; where this +document and 050 disagree, this document wins. + +## Amendment 1 — the WP3/WP5 shared contract is narrower than 050 assumes + +050's OUT section claims WP3 already owns `clientIntegrations["claude-desktop"]`, +`clientIntegrationEnabled`, `mutateClientIntegrationEnabled`, a four-client +union, required `desiredEnabled`, and a per-client flight. None of that four-client +contract exists. What exists after WP-B (this branch): + +- `clientIntegrationsSchema` with `codex`/`grok` keys plus `.passthrough()` + (`src/config.ts:986-989`) — so adding `"claude-desktop": z.boolean().optional() + .catch(undefined)` is additive and old configs stay valid. +- Desired-state owner `setIntegrationEnabled` / read helpers in + `src/codex/desired-state.ts` (Codex+Grok today; extend the id union). +- The native route union `"claude" | "grok" | "codex"` + (`native-integration-routes.ts:31`) with typed success/refusal envelopes and a + route-local single flight (`:199-224`). WP-C extends this union with + `"claude-desktop"` and reuses the same envelope/flight pattern; no new + coordinator is invented (mirrors 010 Amendment 1). +- 050's `runClientIntegrationFlight(...)` call in the auto-apply diff + (`050:757-783`) is replaced by: the route-local flight for HTTP callers, plus + the two persisted-intent re-reads it already specifies (before `fetchAllModels` + and immediately before the writer). The second re-read is the real guard; the + flight is idempotency, not correctness. + +## Amendment 2 — inspector first, writer never on the read path + +Confirmed by the WP-A audit: current reads are non-mutating, but there is no +classifying inspector. WP-C adds `inspectDesktop3pConfigLibrary()` in +`src/claude/desktop-3p.ts` as the single read-only owner: + +- absent library dir → `not_installed`; reads NEVER create directories or files + (the writer's eager `mkdirSync` at `desktop-3p.ts:331-345` stays write-only). +- `_meta.json` present → resolve the applied id, prove the selected `.json` + exists and parses as an object, classify `standard` (`{}` / no + `inferenceProvider`), `gateway_ours` (opencodex fingerprint current), + `gateway_drifted`, `foreign`, or `broken` (selected file missing/unparseable). +- The official schema is verified current (Anthropic configuration reference, + 2026-08-06, Luna lane 1): configLibrary paths per-OS, `_meta.json` + sibling + `.json`, gateway fields, `supports1m`/`prefer1m`. Missing-selected-file + behavior is officially UNVERIFIED → never leave `appliedId` dangling. + +## Amendment 3 — OFF pivot and cleanup, unchanged from 050 but restated as the contract + +1. OFF with `not_installed` or no owned state → successful idempotent no-op; + desired OFF persisted; no filesystem footprint. +2. OFF with our applied profile → write+select a credential-free `{}` standard + profile FIRST (new id, `_meta.json` updated atomically), THEN remove our old + `.json` and `.json.bak`. Success requires both absent; residue → + `cleanup_incomplete` refusal with paths only (never contents/credentials), + desired stays OFF, old metadata row kept as the retry locator. +3. Enable direction: explicit CLI apply (`src/cli/claude-desktop.ts`) and + management `/apply` persist desired ON (+ `desktopAutoApply` semantics per + 050) before writing. +4. Auto-apply (`agent-settings-routes.ts:131-150,518-528`) gains the gates from + 050: skip on desired OFF, `desktopAutoApply === false`, missing profile, and + `not_installed`/`no_owned_state`/`foreign` library kinds; re-read persisted + intent after the `fetchAllModels` await, immediately before the writer. + +## Amendment 4 — GUI consumes the three-plus-one union + +050's GUI diffs assume a WP3 four-client `native-api.ts` contract. Actual: the +runtime allowlists currently admit `claude|grok|codex`; WP-C extends them with +`claude-desktop` and the Desktop-specific refusal reasons +(`metadata_unreadable`, `cleanup_incomplete`, residual detail). Toggle lands in +`overview-clients.ts` `claudeDesktopRow` with desired state separate from +observed `applied`; `ClaudeDesktop.tsx` shows desired OFF honestly; six locales +get the exact keys 050 lists. A GUI screenshot is REQUIRED in the PR (gui is +touched). + +## Test plan (per 050 IN, adjusted) + +- `tests/desktop-3p-removal.test.ts` NEW: pivot order (standard profile selected + before removal), crash-boundary residue → `cleanup_incomplete`, idempotent + no-op OFF, `not_installed` reads create nothing (assert directory absent + after status). +- `tests/native-claude-desktop-toggle.test.ts` NEW: route union, persistence + ordering (intent before artifacts), refusal envelopes, auto-apply suppression + including the post-await re-read (in-process race). +- `gui/tests/*` per 050 IN list; `bun run lint:gui` joins the battery. +- Broken-change check: mutate the post-await re-read guard → auto-apply race + test goes red; restore → green. From d66e7640393e4492711ceb81b6ea99f6720ce368 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 13:05:17 +0900 Subject: [PATCH 040/317] =?UTF-8?q?docs(devlog):=20020=20r2=20=E2=80=94=20?= =?UTF-8?q?desiredEnabled=20widening,=20full=20inspector=20state=20set,=20?= =?UTF-8?q?transport-liveness=20test=20restored?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../020_desktop_toggle_amendments.md | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/devlog/_plan/260806_wp13_toggles_resume/020_desktop_toggle_amendments.md b/devlog/_plan/260806_wp13_toggles_resume/020_desktop_toggle_amendments.md index 7fd169a24f..61ae20c481 100644 --- a/devlog/_plan/260806_wp13_toggles_resume/020_desktop_toggle_amendments.md +++ b/devlog/_plan/260806_wp13_toggles_resume/020_desktop_toggle_amendments.md @@ -22,6 +22,15 @@ contract exists. What exists after WP-B (this branch): route-local single flight (`:199-224`). WP-C extends this union with `"claude-desktop"` and reuses the same envelope/flight pattern; no new coordinator is invented (mirrors 010 Amendment 1). +- **Envelope widening is explicit WP-C work.** The current envelopes carry no + desired state on the server (`native-integration-routes.ts:42-76`) or in the + GUI parser (`gui/src/pages/integrations/native-api.ts:20-46`), while 050 + requires `desiredEnabled` on every Desktop status, success, and post-commit + refusal (`050:685-690`). WP-C adds `desiredEnabled: boolean` to the shared + status/success envelope and to post-commit refusals for ALL clients (the + field is derived from the same persisted read each route already does), and + widens the GUI parser accordingly. Pre-commit refusals that never read config + may omit it; everything after the intent read includes it. - 050's `runClientIntegrationFlight(...)` call in the auto-apply diff (`050:757-783`) is replaced by: the route-local flight for HTTP callers, plus the two persisted-intent re-reads it already specifies (before `fetchAllModels` @@ -39,7 +48,15 @@ classifying inspector. WP-C adds `inspectDesktop3pConfigLibrary()` in - `_meta.json` present → resolve the applied id, prove the selected `.json` exists and parses as an object, classify `standard` (`{}` / no `inferenceProvider`), `gateway_ours` (opencodex fingerprint current), - `gateway_drifted`, `foreign`, or `broken` (selected file missing/unparseable). + `gateway_drifted`, `foreign`, `no_owned_state` (library exists but nothing we + own — the OFF no-op case), or `broken` (selected file missing/unparseable). +- Typed unsafe handling per 050 (`050:704-715,1044-1054`): malformed + `_meta.json` → `metadata_unreadable` refusal (never guess); an applied id that + fails the safe-filename shape → refuse without touching the path (no + traversal); multiple rows matching our fingerprint after an interrupted + cleanup → the remover prefers the SELECTED opencodex row and reports the rest + as residue; invalid `inferenceProvider`/credential-field shapes → classified + `foreign`, never parsed further, never echoed into envelopes or logs. - The official schema is verified current (Anthropic configuration reference, 2026-08-06, Luna lane 1): configLibrary paths per-OS, `_meta.json` + sibling `.json`, gateway fields, `supports1m`/`prefer1m`. Missing-selected-file @@ -78,10 +95,17 @@ touched). - `tests/desktop-3p-removal.test.ts` NEW: pivot order (standard profile selected before removal), crash-boundary residue → `cleanup_incomplete`, idempotent no-op OFF, `not_installed` reads create nothing (assert directory absent - after status). + after status), interrupted-cleanup double-row preference. - `tests/native-claude-desktop-toggle.test.ts` NEW: route union, persistence ordering (intent before artifacts), refusal envelopes, auto-apply suppression including the post-await re-read (in-process race). +- `tests/claude-messages-endpoint.test.ts` MODIFY (050 IN list, restored): prove + Desktop OFF leaves the shared `/v1/messages` transport and health live — + the toggle disables a client's lifecycle, never the proxy surface + (`050:1092-1097`). +- Profile preservation stays binding: `src/claude/desktop-profile.ts` + assignments/defaults are consumed unchanged (`050:92-95`); the standard `{}` + profile is written by the remover path, not by re-deriving profile fields. - `gui/tests/*` per 050 IN list; `bun run lint:gui` joins the battery. - Broken-change check: mutate the post-await re-read guard → auto-apply race test goes red; restore → green. From 4ce7c8d581afd223a12ddeed484d944bff69a426 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 13:06:26 +0900 Subject: [PATCH 041/317] =?UTF-8?q?docs(devlog):=20020=20r3=20=E2=80=94=20?= =?UTF-8?q?owned-but-drifted=20credentials=20are=20unsafe,=20not=20foreign?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../020_desktop_toggle_amendments.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/devlog/_plan/260806_wp13_toggles_resume/020_desktop_toggle_amendments.md b/devlog/_plan/260806_wp13_toggles_resume/020_desktop_toggle_amendments.md index 61ae20c481..4a5e8cab40 100644 --- a/devlog/_plan/260806_wp13_toggles_resume/020_desktop_toggle_amendments.md +++ b/devlog/_plan/260806_wp13_toggles_resume/020_desktop_toggle_amendments.md @@ -55,8 +55,13 @@ classifying inspector. WP-C adds `inspectDesktop3pConfigLibrary()` in fails the safe-filename shape → refuse without touching the path (no traversal); multiple rows matching our fingerprint after an interrupted cleanup → the remover prefers the SELECTED opencodex row and reports the rest - as residue; invalid `inferenceProvider`/credential-field shapes → classified - `foreign`, never parsed further, never echoed into envelopes or logs. + as residue; invalid `inferenceProvider`/credential-field shapes are split by + ownership per `050:704-715`: a profile our fingerprint/metadata claims but + whose provider or credential shape no longer matches is **`unsafe`** — it + refuses convergence without any Desktop write and is never masked as + `foreign`/`no_owned_state`; only a profile with NO ownership marker is + `foreign` (a valid user-selected third-party profile). In neither case are + field values parsed further or echoed into envelopes or logs. - The official schema is verified current (Anthropic configuration reference, 2026-08-06, Luna lane 1): configLibrary paths per-OS, `_meta.json` + sibling `.json`, gateway fields, `supports1m`/`prefer1m`. Missing-selected-file From 6ecc79c5216876797b2a01ceb7386d3fc80af9a1 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Thu, 6 Aug 2026 06:09:18 +0200 Subject: [PATCH 042/317] fix(ci): persist checklist-draft ownership, clear stale maintainer comments, docs accuracy Address the Codex re-review round on the consolidated gate: - Checklist-only draft path: persist the ownership checkpoint (autoDraftedByBot true) in the consolidated comment BEFORE convertToDraft, matching the failure path, so a successful convert followed by a failed comment write still leaves the bot-created draft owned and restorable. A failed conversion rewrites the comment to release ownership. An already-draft PR still updates the comment. - Maintainer gate-comment clear: a maintainer PR that was already a draft while it failed a gate (active:true, autoDraftedByBot:false) now clears its stale DRAFT comment to READY once the failures pass, instead of leaving the old wrong-base/description actions up. - Docs (pr-quality.md): describe the findings claim accurately - bot-authored Codex/CodeRabbit threads on the current head must be resolved; the CodeRabbit review-body supplement adds to the count while a bot thread is open, and resolving every bot thread clears the box (it cannot independently block). Tests: node --test .github/scripts (421 pass), bun test tests/ci-workflows (113 pass), bun run typecheck, bun run privacy:scan, actionlint clean. --- .github/workflows/enforce-pr-target.yml | 101 +++++++++++++----- .../content/docs/contributing/pr-quality.md | 15 +-- tests/ci-workflows.test.ts | 34 +++--- 3 files changed, 100 insertions(+), 50 deletions(-) diff --git a/.github/workflows/enforce-pr-target.yml b/.github/workflows/enforce-pr-target.yml index 3e451931c5..cc85e946d2 100644 --- a/.github/workflows/enforce-pr-target.yml +++ b/.github/workflows/enforce-pr-target.yml @@ -933,10 +933,41 @@ jobs: } // No quality failure; the draft is owed by the open checklist. - if (!pr.draft && !draftConverted) { - // Claim draft ownership before the mutation so a successful - // convert followed by a failed comment still restores later. + if (pr.draft) { + // Already a draft: update the gate comment with the open + // checklist status and no conversion needed. + await upsertGateComment(state, { + status: "DRAFT", + statusReason: checklistRequired + ? `review readiness checklist open (${readiness.checked}/${readiness.total} boxes ticked).` + : "PR is kept in draft.", + actions: buildActions(), + readiness, + checklistRequired, + notices: [ + ...revalidationNotice, + "This PR stays in draft until every box above is ticked." + ] + }); + } else if (!draftConverted) { + // Persist the ownership checkpoint BEFORE the mutation so a + // successful convert followed by a failed comment write still + // leaves the bot-created draft owned and restorable. The + // failure path above uses the same ordering via draftComment. state.autoDraftedByBot = true; + await upsertGateComment(state, { + status: "DRAFT", + statusReason: checklistRequired + ? `review readiness checklist open (${readiness.checked}/${readiness.total} boxes ticked).` + : "PR is kept in draft.", + actions: buildActions(), + readiness, + checklistRequired, + notices: [ + ...revalidationNotice, + "This PR stays in draft until every box above is ticked." + ] + }); try { await convertToDraft(); draftConverted = true; @@ -951,23 +982,25 @@ jobs: } } - const notices = [ - ...revalidationNotice, - pr.draft || draftConverted - ? "This PR stays in draft until every box above is ticked." - : "Automatic draft conversion failed. Please convert this pull request to a draft manually until every box above is ticked." - ]; - - await upsertGateComment(state, { - status: "DRAFT", - statusReason: checklistRequired - ? `review readiness checklist open (${readiness.checked}/${readiness.total} boxes ticked).` - : "PR is kept in draft.", - actions: buildActions(), - readiness, - checklistRequired, - notices - }); + // A failed conversion must still surface in the persisted state: + // the checkpoint above claimed ownership, so a failure rewrites + // it to release ownership and tell the author what to do. + if (!draftConverted && !pr.draft) { + state.autoDraftedByBot = false; + await upsertGateComment(state, { + status: "DRAFT", + statusReason: checklistRequired + ? `review readiness checklist open (${readiness.checked}/${readiness.total} boxes ticked).` + : "PR is kept in draft.", + actions: buildActions(), + readiness, + checklistRequired, + notices: [ + ...revalidationNotice, + "Automatic draft conversion failed. Please convert this pull request to a draft manually until every box above is ticked." + ] + }); + } return; } @@ -1053,13 +1086,13 @@ jobs: // surface but there is nothing to tick; only render it if the // author is a maintainer and no checklist is required. if (!checklistRequired) { - // `pr.draft` is the value from before the ready path ran; when - // that path already converted the PR (`readyConverted`), the - // recovery must not call markReadyForReview a second time on the - // stale draft state. When the ready path already attempted and - // failed (`readyConversionFailed`), the failure state is carried - // in `readyState`/`recoveredState` and only the comment is - // rewritten, so a later run retries. + // A maintainer PR the gate drafted (`autoDraftedByBot`) must be + // restored when its failures clear. A maintainer PR that was + // already a draft while it failed a gate still carries an active + // gate comment (`active: true` with stale DRAFT actions) that + // must be cleared to READY even though the bot never converted + // it — otherwise the old comment keeps telling the author to fix + // already-passed gates. if (gateState.autoDraftedByBot && pr.draft) { let recoveryFailed = false; if (!readyConverted && !readyConversionFailed) { @@ -1093,6 +1126,20 @@ jobs: }); return; } + if (gateState.active) { + await upsertGateComment( + { ...gateState, active: false, autoDraftedByBot: false }, + { + status: "READY", + statusReason: "all PR quality gates passed.", + actions: [], + readiness, + checklistRequired, + notices: [] + } + ); + return; + } core.info( "All PR quality gates passed and there is no active bot state." ); diff --git a/docs-site/src/content/docs/contributing/pr-quality.md b/docs-site/src/content/docs/contributing/pr-quality.md index 613f93529b..a7948d96b3 100644 --- a/docs-site/src/content/docs/contributing/pr-quality.md +++ b/docs-site/src/content/docs/contributing/pr-quality.md @@ -63,13 +63,14 @@ tells you exactly what to change: Before a completion is accepted, the gate verifies the checklist claims it can check itself: the head's `ci` check must be green, the branch must be on the latest `dev` commit or at most 10 commits behind it, and every Codex and - CodeRabbit review thread on the PR must be resolved. CodeRabbit findings - that fall outside the diff range and are reported only in a review body on - the current head are counted the same way while a bot review thread is open; - resolving every bot thread clears them. A disproved claim unticks the - matching box and keeps the PR a draft. When the checklist is complete and - every gate is green, the gate adds a `review-ready` label as a visible - status marker at the ready moment. + CodeRabbit review thread authored by a review bot on the current head must be + resolved (unresolved threads from other authors do not block). CodeRabbit + findings that fall outside the diff range and are reported only in a review + body on the current head add to the unresolved count while a bot review + thread is open; resolving every bot thread clears the box. A disproved claim + unticks the matching box and keeps the PR a draft. When the checklist is + complete and every gate is green, the gate adds a `review-ready` label as a + visible status marker at the ready moment. - **Hygiene.** Behavior changes need a test; new lint or type suppressions, focused or skipped tests, empty catch blocks, edited generated output, and a diff --git a/tests/ci-workflows.test.ts b/tests/ci-workflows.test.ts index b5a8e8c2a6..c5b635d65c 100644 --- a/tests/ci-workflows.test.ts +++ b/tests/ci-workflows.test.ts @@ -1196,13 +1196,14 @@ describe("GitHub Actions hardening", () => { /** * The writes a fresh contributor PR triggers on `dev` with no quality - * failures: inject the checklist, convert to draft, then write the single - * consolidated comment. + * failures: inject the checklist, then the ownership checkpoint comment + * (claiming `autoDraftedByBot` before the mutation), then the draft + * conversion. */ const CONTRIBUTOR_CLEAN_TAIL = [ "pulls.update", - "graphql", "issues.createComment", + "graphql", ]; /** @@ -1278,8 +1279,9 @@ describe("GitHub Actions hardening", () => { expect(methodsOf(result)).toEqual(readsAllowedBase([ "pulls.update", - "graphql", "issues.createComment", + "graphql", + "issues.updateComment", ])); // Only a successful conversion records autoDraftedByBot; a failed one // clears it so a later permission recovery cannot leave the bot-created @@ -1392,9 +1394,9 @@ describe("GitHub Actions hardening", () => { expect(methodsOf(result)).toEqual(readsAllowedBase([ "pulls.get", "pulls.update", - "graphql", "issues.createComment", "issues.deleteComment", + "graphql", ])); const [resetBody] = callsTo(result, "pulls.update") as [{ body: string }]; expect(resetBody.body).toContain(CHECKLIST_START); @@ -1524,8 +1526,8 @@ describe("GitHub Actions hardening", () => { expect(methodsOf(result)).toEqual(readsAllowedBase([ "pulls.get", "pulls.update", - "graphql", "issues.createComment", + "graphql", ])); const drafts = callsTo(result, "graphql") as [{ query: string }]; expect(drafts).toHaveLength(1); @@ -1559,8 +1561,8 @@ describe("GitHub Actions hardening", () => { expect(methodsOf(result)).toEqual(readsAllowedBase([ "pulls.get", "pulls.update", - "graphql", "issues.createComment", + "graphql", ])); const drafts = callsTo(result, "graphql") as [{ query: string }]; expect(drafts).toHaveLength(1); @@ -1597,8 +1599,8 @@ describe("GitHub Actions hardening", () => { "pulls.listReviews", "pulls.get", "pulls.update", - "graphql", "issues.createComment", + "graphql", ])); const [bodyUpdate] = callsTo(result, "pulls.update") as [{ body: string }]; // Only the CI box is unticked; the other three stay checked. @@ -1672,8 +1674,8 @@ describe("GitHub Actions hardening", () => { "pulls.listReviews", "pulls.get", "pulls.update", - "graphql", "issues.createComment", + "graphql", ])); const [bodyUpdate] = callsTo(result, "pulls.update") as [{ body: string }]; // Only the latest-dev box is unticked; CI stays checked. @@ -1712,8 +1714,8 @@ describe("GitHub Actions hardening", () => { "pulls.listReviews", "pulls.get", "pulls.update", - "graphql", "issues.createComment", + "graphql", ])); const [bodyUpdate] = callsTo(result, "pulls.update") as [{ body: string }]; expect(bodyUpdate.body).toContain("- [ ] All CI tests are green on my local testing."); @@ -1745,8 +1747,8 @@ describe("GitHub Actions hardening", () => { "pulls.listReviews", "pulls.get", "pulls.update", - "graphql", "issues.createComment", + "graphql", ])); const [bodyUpdate] = callsTo(result, "pulls.update") as [{ body: string }]; expect(bodyUpdate.body).toContain("- [ ] All CI tests are green on my local testing."); @@ -1830,8 +1832,8 @@ describe("GitHub Actions hardening", () => { "pulls.listReviews", "pulls.get", "pulls.update", - "graphql", "issues.createComment", + "graphql", ])); const [bodyUpdate] = callsTo(result, "pulls.update") as [{ body: string }]; expect(bodyUpdate.body).toContain("- [ ] All CI tests are green on my local testing."); @@ -1858,8 +1860,8 @@ describe("GitHub Actions hardening", () => { "pulls.listReviews", "pulls.get", "pulls.update", - "graphql", "issues.createComment", + "graphql", ])); const [bodyUpdate] = callsTo(result, "pulls.update") as [{ body: string }]; // Only the findings box is unticked; CI and latest-dev stay checked. @@ -2177,9 +2179,9 @@ describe("GitHub Actions hardening", () => { expect(methodsOf(result)).toEqual(readsAllowedBase([ "pulls.get", - "graphql", "issues.createComment", "issues.deleteComment", + "graphql", ])); // No body rewrite: the boxes are already unticked from the failed reset. expect(callsTo(result, "pulls.update")).toEqual([]); @@ -2318,8 +2320,8 @@ describe("GitHub Actions hardening", () => { }); expect(methodsOf(duringFailure)).toEqual(readsAllowedBase([ "pulls.update", - "graphql", "issues.createComment", + "graphql", ])); expect(lastReadinessCommentBody(duringFailure)).toContain('"autoDraftedByBot":true'); @@ -3363,9 +3365,9 @@ describe("GitHub Actions hardening", () => { expect(methodsOf(result)).toEqual(readsAllowedBase([ "pulls.update", "pulls.update", - "graphql", "issues.createComment", "issues.deleteComment", + "graphql", ])); const cleared = lastReadinessCommentBody(result); expect(cleared).toContain('"active":true'); From bab425e17617c1d5d2e3898d71153ef127fe2fdb Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 13:09:54 +0900 Subject: [PATCH 043/317] feat(claude): add Desktop desired-state schema --- src/codex/desired-state.ts | 14 ++++++++++++++ src/config.ts | 1 + src/types.ts | 2 ++ 3 files changed, 17 insertions(+) diff --git a/src/codex/desired-state.ts b/src/codex/desired-state.ts index 3512e82128..ecfd74edb4 100644 --- a/src/codex/desired-state.ts +++ b/src/codex/desired-state.ts @@ -143,6 +143,20 @@ export function setGrokIntegrationEnabled(enabled: boolean): CodexDesiredStateRe return setIntegrationEnabled("grok", enabled); } +/** Whether Claude Desktop's managed gateway profile is wanted. */ +export function claudeDesktopIntegrationEnabled(config: Pick): boolean { + return integrationEnabled(config, "claude-desktop"); +} + +/** The same question when no admitted config snapshot is in hand. */ +export function claudeDesktopIntegrationEnabledNow(): boolean { + return claudeDesktopIntegrationEnabled(loadConfig()); +} + +export function setClaudeDesktopIntegrationEnabled(enabled: boolean): CodexDesiredStateResult { + return setIntegrationEnabled("claude-desktop", enabled); +} + /** * The startup gate, as a function rather than an `if` buried in `handleStart`. * diff --git a/src/config.ts b/src/config.ts index 8f9513deb2..e054d23633 100644 --- a/src/config.ts +++ b/src/config.ts @@ -986,6 +986,7 @@ const apiKeyEntrySchema = z.object({ const clientIntegrationsSchema = z.object({ codex: z.boolean().optional().catch(undefined), grok: z.boolean().optional().catch(undefined), + "claude-desktop": z.boolean().optional().catch(undefined), }).passthrough(); const configSchema = z.object({ diff --git a/src/types.ts b/src/types.ts index 171628caff..4d763a9274 100644 --- a/src/types.ts +++ b/src/types.ts @@ -553,6 +553,8 @@ export interface OcxClientIntegrationsConfig { codex?: boolean; /** Durable desired state for Grok Build. MISSING MEANS ON. */ grok?: boolean; + /** Durable desired state for Claude Desktop. MISSING MEANS ON. */ + "claude-desktop"?: boolean; } export interface OcxConfig { From f1803c99410bc375f4bcec9e13a965c05954f578 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 13:11:24 +0900 Subject: [PATCH 044/317] feat(claude): inspect and safely remove Desktop gateway profiles --- src/claude/desktop-3p.ts | 199 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 198 insertions(+), 1 deletion(-) diff --git a/src/claude/desktop-3p.ts b/src/claude/desktop-3p.ts index f8a36c6028..ffe03e7b68 100644 --- a/src/claude/desktop-3p.ts +++ b/src/claude/desktop-3p.ts @@ -1,5 +1,5 @@ import { createHash, randomUUID } from "node:crypto"; -import { copyFileSync, existsSync, mkdirSync, readFileSync } from "node:fs"; +import { copyFileSync, existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import { atomicWriteFile } from "../config"; @@ -103,6 +103,37 @@ interface Desktop3pMetadata { [key: string]: unknown; } +export type Desktop3pLibraryKind = + | "not_installed" + | "standard" + | "gateway_ours" + | "gateway_drifted" + | "foreign" + | "no_owned_state" + | "broken" + | "unsafe"; + +export interface Desktop3pLibraryInspection { + kind: Desktop3pLibraryKind; + libraryPath: string; + selectedProfilePath: string | null; + appliedId: string | null; + /** Paths of opencodex-owned rows that are not selected by Desktop. */ + residualPaths: string[]; + /** Bounded reason code; never includes metadata or profile contents. */ + reason?: "metadata_unreadable" | "unsafe_applied_id" | "invalid_owned_profile"; + fingerprint?: string; +} + +export interface Desktop3pRemovalResult { + ok: boolean; + changed: boolean; + kind: "removed" | "noop" | "cleanup_incomplete" | "unsafe" | "write_failed"; + libraryPath: string; + residualPaths?: string[]; + reason?: string; +} + let desktop3pRegistry = new Map(); let desktop3pAliasesByRoute = new Map(); @@ -327,6 +358,172 @@ function parseMetadata(path: string): Desktop3pMetadata { return { ...parsed, entries: parsed.entries }; } +const SAFE_DESKTOP_PROFILE_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isOwnedDesktopEntry(entry: Desktop3pMetadataEntry | undefined): boolean { + return entry?.name === "opencodex"; +} + +function profilePath(libraryPath: string, id: string): string { + return join(libraryPath, `${id}.json`); +} + +/** + * Read Desktop's selected config without changing its library. + * + * This is intentionally separate from the eager writer below: status probes must + * never manufacture a config-library directory on a machine without Desktop. + */ +export function inspectDesktop3pConfigLibrary( + options: Desktop3pConfigLibraryOptions & { appliedFingerprint?: string | null } = {}, +): Desktop3pLibraryInspection { + const libraryPath = resolveDesktop3pConfigLibraryPath(options); + if (!existsSync(libraryPath)) { + return { kind: "not_installed", libraryPath, selectedProfilePath: null, appliedId: null, residualPaths: [] }; + } + + const metadataPath = join(libraryPath, "_meta.json"); + if (!existsSync(metadataPath)) { + return { kind: "no_owned_state", libraryPath, selectedProfilePath: null, appliedId: null, residualPaths: [] }; + } + + let metadata: Desktop3pMetadata; + try { + metadata = parseMetadata(metadataPath); + } catch { + return { + kind: "unsafe", libraryPath, selectedProfilePath: null, appliedId: null, residualPaths: [], reason: "metadata_unreadable", + }; + } + const appliedId = typeof metadata.appliedId === "string" ? metadata.appliedId : null; + if (appliedId === null) { + return { kind: "no_owned_state", libraryPath, selectedProfilePath: null, appliedId: null, residualPaths: [] }; + } + if (!SAFE_DESKTOP_PROFILE_ID.test(appliedId)) { + return { + kind: "unsafe", libraryPath, selectedProfilePath: null, appliedId, residualPaths: [], reason: "unsafe_applied_id", + }; + } + + const selectedProfilePath = profilePath(libraryPath, appliedId); + const selected = metadata.entries.find(entry => entry?.id === appliedId); + const residualPaths = metadata.entries + .filter(entry => isOwnedDesktopEntry(entry) && entry.id !== appliedId && SAFE_DESKTOP_PROFILE_ID.test(entry.id)) + .flatMap(entry => [profilePath(libraryPath, entry.id), `${profilePath(libraryPath, entry.id)}.bak`]) + .filter(existsSync); + if (!existsSync(selectedProfilePath)) { + return { kind: "broken", libraryPath, selectedProfilePath, appliedId, residualPaths }; + } + + let profile: Record; + let fingerprint: string; + try { + const source = readFileSync(selectedProfilePath, "utf8"); + const parsed = JSON.parse(source) as unknown; + if (!isRecord(parsed)) return { kind: "broken", libraryPath, selectedProfilePath, appliedId, residualPaths }; + profile = parsed; + fingerprint = createHash("sha256").update(source).digest("hex").slice(0, 16); + } catch { + return { kind: "broken", libraryPath, selectedProfilePath, appliedId, residualPaths }; + } + if (profile.inferenceProvider === undefined) { + return { kind: "standard", libraryPath, selectedProfilePath, appliedId, residualPaths, fingerprint }; + } + if (!isOwnedDesktopEntry(selected)) { + return { kind: "foreign", libraryPath, selectedProfilePath, appliedId, residualPaths, fingerprint }; + } + const validGateway = profile.inferenceProvider === "gateway" + && profile.inferenceCredentialKind === "static" + && typeof profile.inferenceGatewayBaseUrl === "string" + && typeof profile.inferenceGatewayApiKey === "string"; + if (!validGateway) { + return { + kind: "unsafe", libraryPath, selectedProfilePath, appliedId, residualPaths, fingerprint, reason: "invalid_owned_profile", + }; + } + return { + kind: options.appliedFingerprint && options.appliedFingerprint === fingerprint ? "gateway_ours" : "gateway_drifted", + libraryPath, selectedProfilePath, appliedId, residualPaths, fingerprint, + }; +} + +/** + * Select a credential-free standard profile before deleting an owned gateway. + * The old metadata row intentionally remains as a retry locator until both its + * profile and backup are absent. + */ +export function removeDesktop3pStandardPivot( + options: Desktop3pConfigLibraryOptions & { appliedFingerprint?: string | null } = {}, +): Desktop3pRemovalResult { + const inspected = inspectDesktop3pConfigLibrary(options); + if (inspected.kind === "not_installed" || inspected.kind === "no_owned_state") { + return { ok: true, changed: false, kind: "noop", libraryPath: inspected.libraryPath }; + } + if (inspected.kind === "foreign" || inspected.kind === "broken" || inspected.kind === "unsafe") { + return { ok: false, changed: false, kind: "unsafe", libraryPath: inspected.libraryPath, reason: inspected.reason }; + } + if (inspected.kind === "standard" && inspected.residualPaths.length === 0) { + return { ok: true, changed: false, kind: "noop", libraryPath: inspected.libraryPath }; + } + if (!inspected.appliedId || !SAFE_DESKTOP_PROFILE_ID.test(inspected.appliedId)) { + return { ok: false, changed: false, kind: "unsafe", libraryPath: inspected.libraryPath, reason: "unsafe_applied_id" }; + } + + const metadataPath = join(inspected.libraryPath, "_meta.json"); + try { + const metadata = parseMetadata(metadataPath); + const selectedId = inspected.appliedId; + const selectedEntry = metadata.entries.find(entry => entry.id === selectedId); + if (!selectedEntry || !isOwnedDesktopEntry(selectedEntry)) { + // A selected standard profile with outstanding owned rows gets cleanup on + // retry; a selected foreign profile never grants us deletion authority. + if (inspected.kind !== "standard") { + return { ok: false, changed: false, kind: "unsafe", libraryPath: inspected.libraryPath }; + } + } + const targetIds = inspected.kind === "standard" + ? metadata.entries.filter(isOwnedDesktopEntry).map(entry => entry.id).filter(id => SAFE_DESKTOP_PROFILE_ID.test(id)) + : [selectedId]; + if (targetIds.length === 0) return { ok: true, changed: false, kind: "noop", libraryPath: inspected.libraryPath }; + + if (inspected.kind !== "standard") { + const standardId = randomUUID(); + const standardPath = profilePath(inspected.libraryPath, standardId); + atomicWriteFile(standardPath, "{}\n"); + const standardEntry: Desktop3pMetadataEntry = { id: standardId, name: "opencodex-standard" }; + atomicWriteFile( + metadataPath, + JSON.stringify({ ...metadata, appliedId: standardId, entries: [...metadata.entries, standardEntry] }, null, 2) + "\n", + ); + } + + const residualPaths: string[] = []; + for (const id of targetIds) { + for (const candidate of [profilePath(inspected.libraryPath, id), `${profilePath(inspected.libraryPath, id)}.bak`]) { + try { + if (existsSync(candidate)) unlinkSync(candidate); + } catch { + // Only the path is allowed to leave this credential-bearing cleanup boundary. + } + if (existsSync(candidate)) residualPaths.push(candidate); + } + } + if (residualPaths.length > 0 || inspected.residualPaths.length > 0) { + return { + ok: false, changed: true, kind: "cleanup_incomplete", libraryPath: inspected.libraryPath, + residualPaths: [...new Set([...residualPaths, ...inspected.residualPaths])], + }; + } + return { ok: true, changed: true, kind: "removed", libraryPath: inspected.libraryPath }; + } catch { + return { ok: false, changed: false, kind: "write_failed", libraryPath: inspected.libraryPath }; + } +} + /** Write and apply the opencodex config in Claude Desktop 3P's config library. */ export function writeDesktop3pConfig( port: number, From 7f1220b4c519807ecc71bd3d4e2ffb4f784fda12 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 13:14:21 +0900 Subject: [PATCH 045/317] feat(claude): add Desktop native integration toggle --- .../management/native-integration-routes.ts | 132 +++++++++++++++++- 1 file changed, 126 insertions(+), 6 deletions(-) diff --git a/src/server/management/native-integration-routes.ts b/src/server/management/native-integration-routes.ts index ca04cb396d..f29a34a5dc 100644 --- a/src/server/management/native-integration-routes.ts +++ b/src/server/management/native-integration-routes.ts @@ -17,8 +17,9 @@ * Design of record: devlog/_fin/260803_integrations_toggle_all/030 (routes), * 011 (Claude Code), 012 (Grok). */ -import { readRuntimePort, saveConfigPreservingClaudeCode } from "../../config"; +import { loadConfig, readRuntimePort, saveConfigPreservingClaudeCode } from "../../config"; import { filterCatalogVisibleModels, nativeOpenAiContextWindow, visibleNativeSlugs } from "../../codex/catalog"; +import { inspectDesktop3pConfigLibrary, removeDesktop3pStandardPivot, writeDesktop3pConfig } from "../../claude/desktop-3p"; import { injectGrokConfig, stripGrokConfig, type GrokInjectModel } from "../../grok/inject"; import { inspectGrokConfig } from "../../grok/inspect"; import { grokConfigPath } from "../../grok/status"; @@ -29,7 +30,7 @@ import { jsonResponse } from "../auth-cors"; import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; import type { ManagementContext } from "./context"; -export type NativeIntegrationClientId = "claude" | "grok" | "codex"; +export type NativeIntegrationClientId = "claude" | "grok" | "codex" | "claude-desktop"; /** Every reason this module can decline, in one place (audit r3 #6). */ export type NativeRefusalReason = @@ -37,13 +38,16 @@ export type NativeRefusalReason = | "orphaned_marker" | "home_mismatch" | "config_busy" - | "write_failed"; + | "write_failed" + | "metadata_unreadable" + | "cleanup_incomplete"; export interface NativeStatus { clientId: NativeIntegrationClientId; state: "absent" | "current" | "unsafe"; installed: boolean; configPath: string; + desiredEnabled: boolean; /** * Set when a disable would be refused right now. ADVISORY: the file can * change before the PUT, which re-checks and whose answer is authoritative. @@ -62,6 +66,7 @@ export interface NativeToggleEnvelope { changed: boolean; state: NativeStatus["state"]; message: string; + desiredEnabled: boolean; /** Present when the outcome needs more than success/failure to be honest. */ reason?: string; artifacts?: CodexNativeRestoreResult["artifacts"]; @@ -73,6 +78,9 @@ export interface NativeRefusalEnvelope { clientId: NativeIntegrationClientId; reason: NativeRefusalReason; message: string; + /** Available after intent persistence; absent only for pre-commit refusals. */ + desiredEnabled?: boolean; + residualPaths?: string[]; } function refusal( @@ -80,14 +88,38 @@ function refusal( clientId: NativeIntegrationClientId, reason: NativeRefusalReason, message: string, + extra: Pick = {}, ): Response { return jsonResponse({ error: status >= 500 ? "native integration change failed" : "native integration change refused", code: status >= 500 ? "native_integration_failed" : "native_integration_refused", - clientId, reason, message, + clientId, reason, message, ...extra, } satisfies NativeRefusalEnvelope, status); } +function desktopStatus(config: ManagementContext["config"]): NativeStatus { + const seen = inspectDesktop3pConfigLibrary({ + appliedFingerprint: config.claudeCode?.desktopProfile?.appliedFingerprint ?? null, + }); + const state: NativeStatus["state"] = seen.kind === "gateway_ours" + ? "current" + : seen.kind === "unsafe" || seen.kind === "broken" ? "unsafe" : "absent"; + const disableBlocked = seen.kind === "unsafe" || seen.kind === "broken" || seen.kind === "foreign" + ? { + reason: seen.kind === "unsafe" && seen.reason === "metadata_unreadable" ? "metadata_unreadable" as const : "write_failed" as const, + message: "Claude Desktop configuration cannot be changed safely.", + } + : null; + return { + clientId: "claude-desktop", + state, + installed: seen.kind !== "not_installed", + configPath: seen.libraryPath, + desiredEnabled: config.clientIntegrations?.["claude-desktop"] !== false, + disableBlocked, + }; +} + /** Absent means ON: the six read sites all treat only an explicit `false` as off. */ export function claudeCodeEnabled(config: ManagementContext["config"]): boolean { return config.claudeCode?.enabled !== false; @@ -100,6 +132,7 @@ function claudeStatus(config: ManagementContext["config"], configPath: string): // The surface exists wherever the proxy does; there is no separate install. installed: true, configPath, + desiredEnabled: claudeCodeEnabled(config), // Nothing can refuse this disable: no external file, no shared teardown. disableBlocked: null, }; @@ -110,7 +143,7 @@ function claudeStatus(config: ManagementContext["config"], configPath: string): * can change before the PUT, which re-checks with the same inspector and whose * answer is authoritative. */ -function grokStatus(): NativeStatus { +function grokStatus(config: ManagementContext["config"]): NativeStatus { const seen = inspectGrokConfig(); let disableBlocked: NativeStatus["disableBlocked"] = null; if (seen.kind === "orphaned_marker") { @@ -139,6 +172,7 @@ function grokStatus(): NativeStatus { state, installed: seen.kind !== "not_installed", configPath: grokConfigPath(), + desiredEnabled: config.clientIntegrations?.grok !== false, disableBlocked, }; } @@ -266,6 +300,7 @@ async function handleCodexToggle(ctx: ManagementContext): Promise { return jsonResponse({ ok: true, clientId: "codex", changed: durable && persisted.status === "committed", state: "absent", + desiredEnabled: enabled, message: "Codex integration is OFF; enable did not change Codex.", reason: "apply_incomplete", } satisfies NativeToggleEnvelope); @@ -273,6 +308,7 @@ async function handleCodexToggle(ctx: ManagementContext): Promise { return jsonResponse({ ok: true, clientId: "codex", changed: durable && persisted.status === "committed", state: applied.ok ? "current" : "absent", + desiredEnabled: enabled, message: applied.ok ? "Codex now routes through opencodex" : `Codex intent saved, but applying it did not complete: ${applied.message}`, @@ -288,6 +324,7 @@ async function handleCodexToggle(ctx: ManagementContext): Promise { return jsonResponse({ ok: true, clientId: "codex", changed: durable && persisted.status === "committed", state: restored.success ? "absent" : "unsafe", + desiredEnabled: enabled, message: restored.success ? "Codex restored to its native path; the proxy is still serving other clients" : `Codex intent saved, but restoring the native path did not complete: ${restored.message}`, @@ -398,6 +435,7 @@ async function handleGrokToggle(ctx: ManagementContext): Promise { return jsonResponse({ ok: true, clientId: "grok", changed: result.changed, state: "absent", + desiredEnabled: enabled, message: result.changed ? "Grok integration disabled — the opencodex block was removed. Re-enabling regenerates it from the current model list." : "Grok integration is already off", @@ -475,6 +513,7 @@ async function handleGrokToggle(ctx: ManagementContext): Promise { return jsonResponse({ ok: true, clientId: "grok", changed: result.changed, state: "current", reason: "non_loopback_superseded", + desiredEnabled: enabled, message: "opencodex is bound to a non-loopback address, so this request did not write a block — but a well-formed opencodex block is present in the Grok config, written by something else. The card shows what is on disk.", } satisfies NativeToggleEnvelope); case "not_installed": @@ -483,6 +522,7 @@ async function handleGrokToggle(ctx: ManagementContext): Promise { return jsonResponse({ ok: true, clientId: "grok", changed: result.changed, state: "absent", reason: "non_loopback_removed", + desiredEnabled: enabled, message: "opencodex is bound to a non-loopback address, so Grok cannot be auto-registered. The previously generated block was removed because it pointed at a loopback address that no longer serves.", } satisfies NativeToggleEnvelope); default: { @@ -506,6 +546,7 @@ async function handleGrokToggle(ctx: ManagementContext): Promise { return jsonResponse({ ok: true, clientId: "grok", changed: result.changed, state: "current", + desiredEnabled: enabled, message: result.changed ? "Grok integration enabled — the opencodex block was regenerated from the current model list." : "Grok integration is already on", } satisfies NativeToggleEnvelope); })(); @@ -516,13 +557,86 @@ async function handleGrokToggle(ctx: ManagementContext): Promise { } } +let claudeDesktopToggleFlight: Promise | null = null; + +async function handleClaudeDesktopToggle(ctx: ManagementContext): Promise { + if (claudeDesktopToggleFlight) { + return refusal(409, "claude-desktop", "config_busy", + "Another Claude Desktop change is already in flight. Nothing was written — try again in a moment."); + } + claudeDesktopToggleFlight = (async (): Promise => { + let body: { enabled?: unknown }; + try { + body = await readManagementJsonBody(ctx.req); + } catch (error) { + rethrowManagementBodyTooLarge(error); + return jsonResponse({ error: "invalid JSON body" }, 400); + } + if (typeof body.enabled !== "boolean") return jsonResponse({ error: "enabled must be a boolean" }, 400); + + const { setIntegrationEnabled } = await import("../../codex/desired-state"); + const persisted = setIntegrationEnabled("claude-desktop", body.enabled); + if (!persisted.ok) { + return refusal(persisted.retryable ? 409 : 500, "claude-desktop", persisted.retryable ? "config_busy" : "write_failed", persisted.message); + } + const desiredEnabled = loadConfig().clientIntegrations?.["claude-desktop"] !== false; + const current = loadConfig(); + const fingerprint = current.claudeCode?.desktopProfile?.appliedFingerprint ?? null; + + if (!body.enabled) { + const removed = removeDesktop3pStandardPivot({ appliedFingerprint: fingerprint }); + if (removed.kind === "cleanup_incomplete") { + return refusal(500, "claude-desktop", "cleanup_incomplete", + "Claude Desktop now points at standard mode, but credential cleanup is incomplete.", + { desiredEnabled, residualPaths: removed.residualPaths ?? [] }); + } + if (!removed.ok) { + return refusal(409, "claude-desktop", removed.reason === "metadata_unreadable" ? "metadata_unreadable" : "write_failed", + "Claude Desktop configuration could not be changed safely.", { desiredEnabled }); + } + return jsonResponse({ + ok: true, clientId: "claude-desktop", changed: removed.changed, state: "absent", desiredEnabled, + message: removed.changed ? "Claude Desktop integration disabled." : "Claude Desktop integration is already off.", + } satisfies NativeToggleEnvelope); + } + + const fetchModels = ctx.deps.fetchAllModels ?? defaultFetchAllModels; + try { + const routed = filterCatalogVisibleModels(await fetchModels(current), current).map(model => ({ + provider: model.provider, id: model.id, contextWindow: model.contextWindow, + })); + const runtime = (ctx.deps.readRuntimePort ?? readRuntimePort)(process.pid); + const result = writeDesktop3pConfig( + runtime?.port ?? current.port, + [...visibleNativeSlugs(current)], + routed, + current.apiKeys?.[0]?.key, + "static", + current.claudeCode?.desktopProfile, + ); + if (!result.written) return refusal(500, "claude-desktop", "write_failed", "Claude Desktop apply failed.", { desiredEnabled }); + return jsonResponse({ + ok: true, clientId: "claude-desktop", changed: true, state: "current", desiredEnabled, + message: "Claude Desktop integration enabled.", + } satisfies NativeToggleEnvelope); + } catch { + return refusal(500, "claude-desktop", "write_failed", "Claude Desktop apply failed.", { desiredEnabled }); + } + })(); + try { + return await claudeDesktopToggleFlight; + } finally { + claudeDesktopToggleFlight = null; + } +} + export async function handleNativeIntegrationRoutes(ctx: ManagementContext): Promise { const { req, url, config, deps } = ctx; if (url.pathname === "/api/native-integrations" && req.method === "GET") { const { getConfigPath } = await import("../../config"); return jsonResponse({ - clients: [claudeStatus(config, getConfigPath()), grokStatus()], + clients: [claudeStatus(config, getConfigPath()), grokStatus(config), desktopStatus(config)], } satisfies NativeStatusListEnvelope); } @@ -543,6 +657,7 @@ export async function handleNativeIntegrationRoutes(ctx: ManagementContext): Pro return jsonResponse({ ok: true, clientId: "claude", changed: false, state: enabled ? "current" : "absent", + desiredEnabled: enabled, message: enabled ? "Claude inbound is already on" : "Claude inbound is already off", } satisfies NativeToggleEnvelope); } @@ -582,6 +697,7 @@ export async function handleNativeIntegrationRoutes(ctx: ManagementContext): Pro return jsonResponse({ ok: true, clientId: "claude", changed: true, state: enabled ? "current" : "absent", + desiredEnabled: enabled, message: enabled ? "Claude inbound enabled" : "Claude inbound disabled", } satisfies NativeToggleEnvelope); } @@ -594,5 +710,9 @@ export async function handleNativeIntegrationRoutes(ctx: ManagementContext): Pro return handleCodexToggle(ctx); } + if (url.pathname === "/api/native-integrations/claude-desktop" && req.method === "PUT") { + return handleClaudeDesktopToggle(ctx); + } + return null; } From 9e7e699d3a001838f32859e27154952124a63950 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 13:14:42 +0900 Subject: [PATCH 046/317] feat(claude): gate Desktop auto-apply on persisted intent --- src/cli/claude-desktop.ts | 5 + .../management/agent-settings-routes.ts | 94 +++++++++---------- 2 files changed, 52 insertions(+), 47 deletions(-) diff --git a/src/cli/claude-desktop.ts b/src/cli/claude-desktop.ts index 4704eef44e..ea0993adef 100644 --- a/src/cli/claude-desktop.ts +++ b/src/cli/claude-desktop.ts @@ -1,6 +1,7 @@ import { readFileSync, writeFileSync } from "node:fs"; import { resolve } from "node:path"; import { loadConfig, saveConfigPreservingClaudeCode } from "../config"; +import { setIntegrationEnabled } from "../codex/desired-state"; import { DESKTOP_FAMILIES, moveDesktopRoute, @@ -42,6 +43,10 @@ export async function applyProfile( mode: Desktop3pConfigMode, deps: ApplyProfileDeps = {}, ): Promise<{ ok: boolean; path: string; reason?: string }> { + // Explicit apply is an enable action. Persist intent before any Desktop write + // so a process crash cannot leave a gateway profile that startup immediately removes. + const desired = setIntegrationEnabled("claude-desktop", true); + if (!desired.ok) return { ok: false, path: "", reason: desired.message }; const config = loadConfig(); const state = await buildClaudeDesktopState(config, profile); config.claudeCode = { ...(config.claudeCode ?? {}), desktopProfile: state.profile }; diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index d06d25630a..30058367f7 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -130,23 +130,38 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise /** Best-effort Desktop 3P config auto-reconcile when providers change. */ async function autoApplyDesktopBestEffort(): Promise { try { - if (config.claudeCode?.desktopAutoApply === false) return; - if (!config.claudeCode?.desktopProfile) return; - const { writeDesktop3pConfig } = await import("../../claude/desktop-3p"); + const { claudeDesktopIntegrationEnabled } = await import("../../codex/desired-state"); + const admitted = loadConfig(); + if (!claudeDesktopIntegrationEnabled(admitted)) return; + if (admitted.claudeCode?.desktopAutoApply === false) return; + if (!admitted.claudeCode?.desktopProfile) return; + const { inspectDesktop3pConfigLibrary, writeDesktop3pConfig } = await import("../../claude/desktop-3p"); + const beforeKind = inspectDesktop3pConfigLibrary({ + appliedFingerprint: admitted.claudeCode.desktopProfile.appliedFingerprint ?? null, + }).kind; + if (["not_installed", "no_owned_state", "foreign", "unsafe", "broken"].includes(beforeKind)) return; const { filterCatalogVisibleModels, desktopVisibleNativeSlugs } = await import("../../codex/catalog"); - const allModels = await fetchAllModels(config); - const routed = filterCatalogVisibleModels(allModels, config).map(m => ({ provider: m.provider, id: m.id, contextWindow: m.contextWindow })); + const allModels = await fetchAllModels(admitted); + const current = loadConfig(); + // This is the real guard: the catalog await admits a concurrent explicit OFF. + if (!claudeDesktopIntegrationEnabled(current)) return; + if (current.claudeCode?.desktopAutoApply === false || !current.claudeCode?.desktopProfile) return; + const afterKind = inspectDesktop3pConfigLibrary({ + appliedFingerprint: current.claudeCode.desktopProfile.appliedFingerprint ?? null, + }).kind; + if (["not_installed", "no_owned_state", "foreign", "unsafe", "broken"].includes(afterKind)) return; + const routed = filterCatalogVisibleModels(allModels, current).map(m => ({ provider: m.provider, id: m.id, contextWindow: m.contextWindow })); const result = writeDesktop3pConfig( - config.port ?? 10100, - [...desktopVisibleNativeSlugs(config)], + current.port ?? 10100, + [...desktopVisibleNativeSlugs(current)], routed, - config.apiKeys?.[0]?.key, + current.apiKeys?.[0]?.key, "static", - config.claudeCode.desktopProfile, + current.claudeCode.desktopProfile, ); if (result.written && result.fingerprint) { - config.claudeCode = { ...config.claudeCode, desktopProfile: { ...config.claudeCode.desktopProfile, appliedFingerprint: result.fingerprint, appliedAt: new Date().toISOString() } }; - saveConfigPreservingClaudeCode(config); + current.claudeCode = { ...current.claudeCode, desktopProfile: { ...current.claudeCode.desktopProfile, appliedFingerprint: result.fingerprint, appliedAt: new Date().toISOString() } }; + saveConfigPreservingClaudeCode(current); } } catch { /* best-effort */ } } @@ -700,6 +715,9 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise } if (url.pathname === "/api/claude-desktop/apply" && req.method === "POST") { try { + const { setIntegrationEnabled } = await import("../../codex/desired-state"); + const desired = setIntegrationEnabled("claude-desktop", true); + if (!desired.ok) return jsonResponse({ error: desired.message }, desired.retryable ? 409 : 500); // #859: the CLI delegates here so the registry is built in the serving // process. Accept an optional mode; default stays static for back-compat. let mode: "static" | "hybrid" | "discovery" = "static"; @@ -767,47 +785,29 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise // Desktop applied-state + health status. if (url.pathname === "/api/claude-desktop/status" && req.method === "GET") { try { - const { readFileSync: readFile, existsSync } = await import("node:fs"); - const { createHash } = await import("node:crypto"); - const { join } = await import("node:path"); - const { resolveDesktop3pConfigLibraryPath } = await import("../../claude/desktop-3p"); - const libraryPath = resolveDesktop3pConfigLibraryPath(); - const metaPath = join(libraryPath, "_meta.json"); - let onDiskFingerprint: string | null = null; - let configPath: string | null = null; - // Desktop serves ONLY the profile named by _meta.json's appliedId, so an - // opencodex entry that merely EXISTS does not mean Desktop is using it. - // null = undeterminable (no metadata / unreadable / no appliedId). - let activeProfile: boolean | null = null; - if (existsSync(metaPath)) { - try { - const meta = JSON.parse(readFile(metaPath, "utf8")); - const entry = Array.isArray(meta.entries) ? meta.entries.find((e: { name?: string }) => e?.name === "opencodex") : undefined; - const appliedId = typeof meta.appliedId === "string" ? meta.appliedId : null; - // A readable appliedId with no opencodex entry is a KNOWN false, not unknown. - activeProfile = appliedId === null ? null : (entry?.id ? appliedId === entry.id : false); - if (entry?.id) { - configPath = join(libraryPath, `${entry.id}.json`); - if (existsSync(configPath)) { - const onDisk = readFile(configPath, "utf8"); - onDiskFingerprint = createHash("sha256").update(onDisk).digest("hex").slice(0, 16); - } - } - } catch { /* unreadable metadata */ } - } - const savedFingerprint = config.claudeCode?.desktopProfile?.appliedFingerprint ?? null; - const appliedAt = config.claudeCode?.desktopProfile?.appliedAt ?? null; - const stale = savedFingerprint !== null && onDiskFingerprint !== null && savedFingerprint !== onDiskFingerprint; + const { claudeDesktopIntegrationEnabled } = await import("../../codex/desired-state"); + const { inspectDesktop3pConfigLibrary } = await import("../../claude/desktop-3p"); + const persisted = loadConfig(); + const savedFingerprint = persisted.claudeCode?.desktopProfile?.appliedFingerprint ?? null; + const observed = inspectDesktop3pConfigLibrary({ appliedFingerprint: savedFingerprint }); + const desiredEnabled = claudeDesktopIntegrationEnabled(persisted); + const applied = observed.kind === "gateway_ours" || observed.kind === "gateway_drifted"; + const stale = observed.kind === "gateway_drifted"; const { getDesktopHealth } = await import("../../claude/desktop-health"); const health = getDesktopHealth(); return jsonResponse({ - applied: savedFingerprint !== null, - appliedAt, + desiredEnabled, + installed: observed.kind !== "not_installed", + observedKind: observed.kind, + applied, + appliedAt: persisted.claudeCode?.desktopProfile?.appliedAt ?? null, savedFingerprint, - onDiskFingerprint, - configPath, + onDiskFingerprint: observed.fingerprint ?? null, + configPath: observed.selectedProfilePath, stale, - activeProfile, + activeProfile: applied, + drift: desiredEnabled ? !applied || stale : applied || observed.kind === "unsafe", + driftReason: desiredEnabled ? (!applied ? "desired_on_not_current" : stale ? "profile_drift" : null) : (applied ? "desired_off_gateway_selected" : null), health, }); } catch (error) { From b47129420a4630726390fa13ed3f2658d5cdc46f Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Thu, 6 Aug 2026 06:15:05 +0200 Subject: [PATCH 047/317] fix(issue-quality): treat media-only sections as empty so image-only goals cannot hide repeated prose (#1098) An HTML or markdown image in a section made it look non-empty, so repeated identical prose in the other sections escaped duplicate and title-repeat detection and the issue passed the quality gate. Strip media-only tokens in clean() so media-only sections participate in emptiness/duplicate checks like any other blank section. Closes the image-only-section bypass seen on #1098. --- .github/scripts/issue-quality.cjs | 51 ++++++++++++ .github/scripts/issue-quality.test.cjs | 111 +++++++++++++++++++++++++ 2 files changed, 162 insertions(+) diff --git a/.github/scripts/issue-quality.cjs b/.github/scripts/issue-quality.cjs index 232789f8f1..a0fae2760c 100644 --- a/.github/scripts/issue-quality.cjs +++ b/.github/scripts/issue-quality.cjs @@ -51,12 +51,61 @@ function isPlaceholderOnlyValue(raw) { return PLACEHOLDER_ONLY_RE.test(value); } +/** + * Strip image/media-only content from a markdown or HTML fragment so that a + * section whose only content is a screenshot or media embed is treated as + * empty by the validators. + * + * Handles: + * - Markdown images: `![alt](url)`, `![alt](url "title")` + * - HTML and ... blocks + * - Common media embeds (video/audio) when they are the only content + * + * Text mixed with media (for example a caption or repro steps around an + * image) is preserved; only the media tokens themselves are removed. + */ +function stripMediaTokens(text) { + if (typeof text !== "string") return ""; + return text + // HTML media tags: (self-closing or not), ..., + // , (kept as whole blocks so a lone + // media embed does not leave stray tags behind). + .replace(//gi, " ") + .replace(//gi, " ") + .replace(//gi, " ") + .replace(/]*>/gi, " ") + // Markdown images, optionally with a title: ![alt](url "title"). Alt text + // may contain balanced brackets (for example ![Image [screenshot]](url)). + .replace(/!\[(?:[^\[\]]|\[[^\]]*\])*\]\([^)]*\)/g, " ") + // HTML comment tokens that may wrap media. + .replace(//g, " "); +} + +/** + * True when a section contains no substantive text after removing media + * tokens and whitespace. Used to decide whether a media-only section should + * count as empty for quality validation. + */ +function isMediaOnly(text) { + if (typeof text !== "string") return false; + const stripped = stripMediaTokens(text); + return stripped.replace(/\s+/g, "").length === 0; +} + /** * Strip HTML comments, placeholder-only values, and trim whitespace. */ function clean(raw) { if (typeof raw !== "string") return ""; let s = raw.replace(//g, ""); + // Media-only sections (a lone screenshot or embed) carry no reportable + // text. Strip the media tokens so the section participates in emptiness and + // duplicate detection like any other blank section. This closes the + // image-only-section bypass (see #1098: an ``-only goal hid repeated + // prose in the other sections from duplicate detection). + if (isMediaOnly(s)) { + s = stripMediaTokens(s).replace(/\s+/g, " ").trim(); + } // Whole-value placeholders first (including a single enclosing fence), so // line-by-line stripping cannot leave bare fence markers behind. if (isPlaceholderOnlyValue(s)) return ""; @@ -1290,6 +1339,8 @@ module.exports = { clean, normalise, canonicalise, + stripMediaTokens, + isMediaOnly, extractSection, resolveSection, detectIssueKind, diff --git a/.github/scripts/issue-quality.test.cjs b/.github/scripts/issue-quality.test.cjs index 8ecc4e7aa3..dcd44ad9aa 100644 --- a/.github/scripts/issue-quality.test.cjs +++ b/.github/scripts/issue-quality.test.cjs @@ -20,6 +20,8 @@ const { isPlaceholder, isRawPlaceholder, isUnusableVersion, + stripMediaTokens, + isMediaOnly, countWords, hasConcreteDetail, hasActionableReproductionDetail, @@ -235,6 +237,115 @@ describe("validateIssue - feature", () => { assert.ok(result.reasons.length > 0); }); + it("rejects an image-only goal section that hides repeated prose (#1098)", () => { + // Regression for #1098: an HTML in the goal section made the goal + // look non-empty, so the repeated identical sentences in the other three + // sections were not caught as duplicates and the issue passed validation. + const repeated = + "It is hoped that the usage query will support time-based queries and statistics, as well as key-based queries and statistics"; + const img = + 'Image'; + const body = [ + "### Area", + "CLI", + "### What are you trying to accomplish?", + img, + "### What prevents this today?", + repeated, + "### What should OpenCodex do?", + repeated, + "### Example usage or interface", + repeated, + ].join("\n"); + const result = validateIssue({ title: repeated, body, labels: ["enhancement"] }); + assert.equal(result.kind, "feature"); + assert.equal(result.valid, false); + assert.ok( + result.reasons.some((r) => /missing or empty/i.test(r)), + `Expected missing/empty reason, got: ${result.reasons.join("; ")}`, + ); + assert.ok( + result.reasons.some((r) => /same content/i.test(r)), + `Expected duplicate-content reason, got: ${result.reasons.join("; ")}`, + ); + }); + + it("rejects a markdown-image-only goal section with repeated prose (#1098)", () => { + const repeated = + "It is hoped that the usage query will support time-based queries and statistics, as well as key-based queries and statistics"; + const mdImg = "![Image](https://github.com/user-attachments/assets/17ea27a8-cec6-4591-aa09-a0ce36f1211f)"; + const body = [ + "### What are you trying to accomplish?", + mdImg, + "### What prevents this today?", + repeated, + "### What should OpenCodex do?", + repeated, + "### Example usage or interface", + repeated, + ].join("\n"); + const result = validateIssue({ title: repeated, body, labels: ["enhancement"] }); + assert.equal(result.kind, "feature"); + assert.equal(result.valid, false); + assert.ok( + result.reasons.some((r) => /missing or empty/i.test(r)), + `Expected missing/empty reason, got: ${result.reasons.join("; ")}`, + ); + }); + + it("rejects a markdown image with bracketed alt text in the goal (#1098)", () => { + const repeated = + "It is hoped that the usage query will support time-based queries and statistics, as well as key-based queries and statistics"; + // GitHub permits balanced brackets inside image alt text, e.g. + // ![Image [screenshot]](url). The stripper must still treat it as + // media-only so it cannot hide repeated prose. + const mdImg = "![Image [screenshot]](https://example.com/x.png)"; + const body = [ + "### What are you trying to accomplish?", + mdImg, + "### What prevents this today?", + repeated, + "### What should OpenCodex do?", + repeated, + "### Example usage or interface", + repeated, + ].join("\n"); + const result = validateIssue({ title: repeated, body, labels: ["enhancement"] }); + assert.equal(result.kind, "feature"); + assert.equal(result.valid, false); + assert.ok( + result.reasons.some((r) => /missing or empty/i.test(r)), + `Expected missing/empty reason, got: ${result.reasons.join("; ")}`, + ); + }); + + it("preserves a goal section that mixes an image with real text", () => { + const goal = [ + "![Screenshot](https://example.com/shot.png)", + "Route voice requests to a configured fallback provider when the primary quota is exhausted.", + ].join("\n"); + const result = validateIssue({ + title: "Voice fallback routing", + body: featureBodyWithGoal(goal), + labels: ["enhancement"], + }); + assert.equal(result.kind, "feature"); + assert.equal(result.valid, true); + }); + + it("treats image/media-only sections as empty via isMediaOnly", () => { + assert.equal(isMediaOnly(''), true); + assert.equal(isMediaOnly("![alt](https://example.com/x.png)"), true); + assert.equal(isMediaOnly("![alt [with bracket]](https://example.com/x.png)"), true); + assert.equal(isMediaOnly(''), true); + assert.equal(isMediaOnly(''), true); + assert.equal(isMediaOnly('\nCaption text'), false); + assert.equal(isMediaOnly("Some real description."), false); + assert.equal(stripMediaTokens('').trim(), ""); + assert.equal(stripMediaTokens('![alt](url "title")').trim(), ""); + assert.equal(stripMediaTokens('before ![alt](url) after').replace(/\s+/g, " ").trim(), "before after"); + }); + it("accepts a concise but actionable feature", () => { const body = [ "### Area", From b6378e1953e44f9ea3b9c7588048572573dd4cf7 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 13:20:25 +0900 Subject: [PATCH 048/317] feat(gui): expose Claude Desktop desired toggle state --- gui/src/i18n/de.ts | 18 ++++++++ gui/src/i18n/en.ts | 18 ++++++++ gui/src/i18n/ja.ts | 18 ++++++++ gui/src/i18n/ko.ts | 18 ++++++++ gui/src/i18n/ru.ts | 18 ++++++++ gui/src/i18n/zh.ts | 18 ++++++++ gui/src/pages/ClaudeDesktop.tsx | 7 +++- .../integrations/IntegrationsOverview.tsx | 21 +++++++--- gui/src/pages/integrations/integration-api.ts | 8 +++- gui/src/pages/integrations/native-api.ts | 14 +++++-- .../pages/integrations/overview-clients.ts | 34 +++++++++++---- gui/src/pages/integrations/refusal-copy.ts | 4 ++ gui/tests/integrations-overview-rows.test.ts | 42 ++++++++++++++++--- gui/tests/integrations-surfaces.test.tsx | 15 +++++-- 14 files changed, 226 insertions(+), 27 deletions(-) diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index a4d633e10a..1e5e0f3cd8 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -764,6 +764,22 @@ export const de: Record = { "integrations.detail.desktopStale": "Die Profildatei hat sich nach dem Anwenden geändert", "integrations.detail.desktopNotServed": "Das Profil ist da, Desktop nutzt aber ein anderes", "integrations.detail.desktopAbsent": "Kein Profil angewendet", + "integrations.detail.desktopDesiredOff": "Claude Desktop integration is off", + "integrations.detail.desktopDesiredOnNotApplied": "Integration is on, but Desktop is not using the gateway profile", + "integrations.detail.desktopSelectedElsewhere": "Desktop is using another profile", + "integrations.detail.desktopProfileDrift": "The selected Desktop profile changed", + "integrations.detail.desktopObservedUnsafe": "The selected Desktop profile cannot be changed safely", + "integrations.detail.desktopNotInstalled": "Claude Desktop configuration library is not installed", + "integrations.dialog.desktop.title": "Disable Claude Desktop integration?", + "integrations.dialog.desktop.changes": "If {path} contains an opencodex-managed gateway profile, Desktop will first select a new credential-free standard profile, then remove the old profile and backup.", + "integrations.dialog.desktop.breakage": "Claude Desktop will return to standard Claude instead of models routed through opencodex.", + "integrations.dialog.desktop.undo": "Turning this back on regenerates the opencodex profile from your saved model assignments.", + "integrations.dialog.desktop.restart": "Claude Desktop reads this configuration only at launch. Fully quit and reopen it for this change to take effect.", + "integrations.dialog.desktop.confirm": "Disable", + "integrations.native.error.desktopUnsafeMetadata": "Claude Desktop metadata at {path} could not be read safely, so its library was not changed.", + "integrations.native.error.desktopCleanupIncomplete": "Claude Desktop is pointed at standard mode, but old opencodex credential files remain at: {paths}.", + "integrations.native.msg.desktopDisabled": "Claude Desktop integration disabled.", + "integrations.native.msg.desktopEnabled": "Claude Desktop integration enabled.", "integrations.detail.grokModels": "{count} Modell(e) verbunden", "integrations.detail.grokAbsent": "Kein opencodex-Block in der Konfiguration", "integrations.dialog.grok.title": "Grok-Build-Integration deaktivieren?", @@ -1818,6 +1834,8 @@ export const de: Record = { "claudeDesktop.status.stale": "Konfiguration veraltet — erneut anwenden", "claudeDesktop.status.notApplied": "Nicht angewendet", "claudeDesktop.status.notActiveProfile": "Desktop nutzt ein anderes Profil — erneut anwenden", + "claudeDesktop.status.disabled": "Claude Desktop integration is off. Fully quit and reopen Desktop after enabling it.", + "claudeDesktop.enableApply": "Enable and apply", "claudeDesktop.health.lastRequest": "Letzte Anfrage", "claudeDesktop.health.stats": "{count} Anf. / {errors} Fehl.", "claudeDesktop.effort.supported": "effort", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index df67c75023..6be86d3e92 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1219,6 +1219,12 @@ export const en = { "integrations.detail.desktopStale": "The profile file changed after it was applied", "integrations.detail.desktopNotServed": "The profile exists, but Desktop serves another one", "integrations.detail.desktopAbsent": "No profile applied", + "integrations.detail.desktopDesiredOff": "Claude Desktop integration is off", + "integrations.detail.desktopDesiredOnNotApplied": "Integration is on, but Desktop is not using the gateway profile", + "integrations.detail.desktopSelectedElsewhere": "Desktop is using another profile", + "integrations.detail.desktopProfileDrift": "The selected Desktop profile changed", + "integrations.detail.desktopObservedUnsafe": "The selected Desktop profile cannot be changed safely", + "integrations.detail.desktopNotInstalled": "Claude Desktop configuration library is not installed", "integrations.detail.grokModels": "{count} model(s) wired", "integrations.detail.grokAbsent": "No opencodex block in the config", "integrations.dialog.grok.title": "Disable the Grok Build integration?", @@ -1226,6 +1232,12 @@ export const en = { "integrations.dialog.grok.breakage": "Disabling removes the opencodex model aliases from Grok Build. Models used with your xAI account remain available.", "integrations.dialog.grok.undo": "If opencodex is running on a loopback address, turning this back on writes a new block from the models currently available.", "integrations.dialog.grok.confirm": "Disable", + "integrations.dialog.desktop.title": "Disable Claude Desktop integration?", + "integrations.dialog.desktop.changes": "If {path} contains an opencodex-managed gateway profile, Desktop will first select a new credential-free standard profile, then remove the old profile and backup.", + "integrations.dialog.desktop.breakage": "Claude Desktop will return to standard Claude instead of models routed through opencodex.", + "integrations.dialog.desktop.undo": "Turning this back on regenerates the opencodex profile from your saved model assignments.", + "integrations.dialog.desktop.restart": "Claude Desktop reads this configuration only at launch. Fully quit and reopen it for this change to take effect.", + "integrations.dialog.desktop.confirm": "Disable", "integrations.native.msg.nonLoopbackRemoved": "Grok Build can be registered automatically only while opencodex runs on a loopback address. The previous block that pointed to loopback was removed.", "integrations.native.msg.nonLoopbackRemovedNoop": "Grok Build can be registered automatically only while opencodex runs on a loopback address. There was no previous block to remove.", "integrations.native.msg.nonLoopbackSuperseded": "Grok Build can be registered automatically only while opencodex runs on a loopback address. Another process wrote a new block in the meantime, so the block now in the file was not created by this request.", @@ -1233,6 +1245,10 @@ export const en = { "integrations.native.error.homeMismatch": "The installed service home does not match the current home, so the file was left unchanged.", "integrations.native.error.notInstalled": "Grok Build is not installed, so there is nothing to change.", "integrations.native.error.configBusy": "The configuration is being saved elsewhere and could not be changed. Try again shortly.", + "integrations.native.error.desktopUnsafeMetadata": "Claude Desktop metadata at {path} could not be read safely, so its library was not changed.", + "integrations.native.error.desktopCleanupIncomplete": "Claude Desktop is pointed at standard mode, but old opencodex credential files remain at: {paths}.", + "integrations.native.msg.desktopDisabled": "Claude Desktop integration disabled.", + "integrations.native.msg.desktopEnabled": "Claude Desktop integration enabled.", "integrations.state.absent": "Not applied", "integrations.state.current": "Applied", "integrations.state.stale": "Update needed", @@ -1851,6 +1867,8 @@ export const en = { "claudeDesktop.status.stale": "Config stale — re-apply", "claudeDesktop.status.notApplied": "Not applied", "claudeDesktop.status.notActiveProfile": "Desktop is serving another profile — re-apply", + "claudeDesktop.status.disabled": "Claude Desktop integration is off. Fully quit and reopen Desktop after enabling it.", + "claudeDesktop.enableApply": "Enable and apply", "claudeDesktop.health.lastRequest": "Last request", "claudeDesktop.health.stats": "{count} req / {errors} err", "claudeDesktop.effort.supported": "effort", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index eb98a71eb6..a3530e8f0c 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1167,6 +1167,22 @@ export const ja: Record = { "integrations.detail.desktopStale": "適用後にプロファイルが変更されました", "integrations.detail.desktopNotServed": "プロファイルはありますが Desktop は別のものを使用中です", "integrations.detail.desktopAbsent": "適用されたプロファイルはありません", + "integrations.detail.desktopDesiredOff": "Claude Desktop integration is off", + "integrations.detail.desktopDesiredOnNotApplied": "Integration is on, but Desktop is not using the gateway profile", + "integrations.detail.desktopSelectedElsewhere": "Desktop is using another profile", + "integrations.detail.desktopProfileDrift": "The selected Desktop profile changed", + "integrations.detail.desktopObservedUnsafe": "The selected Desktop profile cannot be changed safely", + "integrations.detail.desktopNotInstalled": "Claude Desktop configuration library is not installed", + "integrations.dialog.desktop.title": "Disable Claude Desktop integration?", + "integrations.dialog.desktop.changes": "If {path} contains an opencodex-managed gateway profile, Desktop will first select a new credential-free standard profile, then remove the old profile and backup.", + "integrations.dialog.desktop.breakage": "Claude Desktop will return to standard Claude instead of models routed through opencodex.", + "integrations.dialog.desktop.undo": "Turning this back on regenerates the opencodex profile from your saved model assignments.", + "integrations.dialog.desktop.restart": "Claude Desktop reads this configuration only at launch. Fully quit and reopen it for this change to take effect.", + "integrations.dialog.desktop.confirm": "Disable", + "integrations.native.error.desktopUnsafeMetadata": "Claude Desktop metadata at {path} could not be read safely, so its library was not changed.", + "integrations.native.error.desktopCleanupIncomplete": "Claude Desktop is pointed at standard mode, but old opencodex credential files remain at: {paths}.", + "integrations.native.msg.desktopDisabled": "Claude Desktop integration disabled.", + "integrations.native.msg.desktopEnabled": "Claude Desktop integration enabled.", "integrations.detail.grokModels": "モデル {count} 個を接続済み", "integrations.detail.grokAbsent": "設定に opencodex ブロックがありません", "integrations.dialog.grok.title": "Grok Build 連携を解除しますか?", @@ -1699,6 +1715,8 @@ export const ja: Record = { "claudeDesktop.status.stale": "設定が古くなっています — 再適用してください", "claudeDesktop.status.notApplied": "未適用", "claudeDesktop.status.notActiveProfile": "Desktop は別のプロファイルを使用中 — 再適用してください", + "claudeDesktop.status.disabled": "Claude Desktop integration is off. Fully quit and reopen Desktop after enabling it.", + "claudeDesktop.enableApply": "Enable and apply", "claudeDesktop.health.lastRequest": "最終リクエスト", "claudeDesktop.health.stats": "{count} リクエスト / {errors} エラー", "claudeDesktop.effort.supported": "effort", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 339cdbe970..e198a22166 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -788,6 +788,22 @@ export const ko: Record = { "integrations.detail.desktopStale": "적용 후 프로필 파일이 바뀌었습니다", "integrations.detail.desktopNotServed": "프로필은 있지만 Desktop이 다른 것을 씁니다", "integrations.detail.desktopAbsent": "적용된 프로필이 없습니다", + "integrations.detail.desktopDesiredOff": "Claude Desktop integration is off", + "integrations.detail.desktopDesiredOnNotApplied": "Integration is on, but Desktop is not using the gateway profile", + "integrations.detail.desktopSelectedElsewhere": "Desktop is using another profile", + "integrations.detail.desktopProfileDrift": "The selected Desktop profile changed", + "integrations.detail.desktopObservedUnsafe": "The selected Desktop profile cannot be changed safely", + "integrations.detail.desktopNotInstalled": "Claude Desktop configuration library is not installed", + "integrations.dialog.desktop.title": "Disable Claude Desktop integration?", + "integrations.dialog.desktop.changes": "If {path} contains an opencodex-managed gateway profile, Desktop will first select a new credential-free standard profile, then remove the old profile and backup.", + "integrations.dialog.desktop.breakage": "Claude Desktop will return to standard Claude instead of models routed through opencodex.", + "integrations.dialog.desktop.undo": "Turning this back on regenerates the opencodex profile from your saved model assignments.", + "integrations.dialog.desktop.restart": "Claude Desktop reads this configuration only at launch. Fully quit and reopen it for this change to take effect.", + "integrations.dialog.desktop.confirm": "Disable", + "integrations.native.error.desktopUnsafeMetadata": "Claude Desktop metadata at {path} could not be read safely, so its library was not changed.", + "integrations.native.error.desktopCleanupIncomplete": "Claude Desktop is pointed at standard mode, but old opencodex credential files remain at: {paths}.", + "integrations.native.msg.desktopDisabled": "Claude Desktop integration disabled.", + "integrations.native.msg.desktopEnabled": "Claude Desktop integration enabled.", "integrations.detail.grokModels": "모델 {count}개 연결됨", "integrations.detail.grokAbsent": "설정에 opencodex 블록이 없습니다", "integrations.dialog.grok.title": "Grok Build 연동을 해제할까요?", @@ -1845,6 +1861,8 @@ export const ko: Record = { "claudeDesktop.status.stale": "설정 변경됨 — 재적용 필요", "claudeDesktop.status.notApplied": "미적용", "claudeDesktop.status.notActiveProfile": "Desktop이 다른 프로필을 사용 중 — 재적용 필요", + "claudeDesktop.status.disabled": "Claude Desktop integration is off. Fully quit and reopen Desktop after enabling it.", + "claudeDesktop.enableApply": "Enable and apply", "claudeDesktop.health.lastRequest": "마지막 요청", "claudeDesktop.health.stats": "{count} 요청 / {errors} 에러", "claudeDesktop.effort.supported": "effort", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 7904e27c3e..7223cb6518 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1209,6 +1209,22 @@ export const ru: Record = { "integrations.detail.desktopStale": "Файл профиля изменился после применения", "integrations.detail.desktopNotServed": "Профиль есть, но Desktop использует другой", "integrations.detail.desktopAbsent": "Профиль не применён", + "integrations.detail.desktopDesiredOff": "Claude Desktop integration is off", + "integrations.detail.desktopDesiredOnNotApplied": "Integration is on, but Desktop is not using the gateway profile", + "integrations.detail.desktopSelectedElsewhere": "Desktop is using another profile", + "integrations.detail.desktopProfileDrift": "The selected Desktop profile changed", + "integrations.detail.desktopObservedUnsafe": "The selected Desktop profile cannot be changed safely", + "integrations.detail.desktopNotInstalled": "Claude Desktop configuration library is not installed", + "integrations.dialog.desktop.title": "Disable Claude Desktop integration?", + "integrations.dialog.desktop.changes": "If {path} contains an opencodex-managed gateway profile, Desktop will first select a new credential-free standard profile, then remove the old profile and backup.", + "integrations.dialog.desktop.breakage": "Claude Desktop will return to standard Claude instead of models routed through opencodex.", + "integrations.dialog.desktop.undo": "Turning this back on regenerates the opencodex profile from your saved model assignments.", + "integrations.dialog.desktop.restart": "Claude Desktop reads this configuration only at launch. Fully quit and reopen it for this change to take effect.", + "integrations.dialog.desktop.confirm": "Disable", + "integrations.native.error.desktopUnsafeMetadata": "Claude Desktop metadata at {path} could not be read safely, so its library was not changed.", + "integrations.native.error.desktopCleanupIncomplete": "Claude Desktop is pointed at standard mode, but old opencodex credential files remain at: {paths}.", + "integrations.native.msg.desktopDisabled": "Claude Desktop integration disabled.", + "integrations.native.msg.desktopEnabled": "Claude Desktop integration enabled.", "integrations.detail.grokModels": "Подключено моделей: {count}", "integrations.detail.grokAbsent": "В конфигурации нет блока opencodex", "integrations.dialog.grok.title": "Отключить интеграцию Grok Build?", @@ -1741,6 +1757,8 @@ export const ru: Record = { "claudeDesktop.status.stale": "Конфигурация устарела — примените заново", "claudeDesktop.status.notApplied": "Не применено", "claudeDesktop.status.notActiveProfile": "Desktop использует другой профиль — примените заново", + "claudeDesktop.status.disabled": "Claude Desktop integration is off. Fully quit and reopen Desktop after enabling it.", + "claudeDesktop.enableApply": "Enable and apply", "claudeDesktop.health.lastRequest": "Последний запрос", "claudeDesktop.health.stats": "{count} запр. / {errors} ошиб.", "claudeDesktop.effort.supported": "effort", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 8b16dbe6c8..4bc9a459ea 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -781,6 +781,22 @@ export const zh: Record = { "integrations.detail.desktopStale": "应用后配置文件已更改", "integrations.detail.desktopNotServed": "配置存在,但 Desktop 使用的是另一个", "integrations.detail.desktopAbsent": "未应用任何配置", + "integrations.detail.desktopDesiredOff": "Claude Desktop integration is off", + "integrations.detail.desktopDesiredOnNotApplied": "Integration is on, but Desktop is not using the gateway profile", + "integrations.detail.desktopSelectedElsewhere": "Desktop is using another profile", + "integrations.detail.desktopProfileDrift": "The selected Desktop profile changed", + "integrations.detail.desktopObservedUnsafe": "The selected Desktop profile cannot be changed safely", + "integrations.detail.desktopNotInstalled": "Claude Desktop configuration library is not installed", + "integrations.dialog.desktop.title": "Disable Claude Desktop integration?", + "integrations.dialog.desktop.changes": "If {path} contains an opencodex-managed gateway profile, Desktop will first select a new credential-free standard profile, then remove the old profile and backup.", + "integrations.dialog.desktop.breakage": "Claude Desktop will return to standard Claude instead of models routed through opencodex.", + "integrations.dialog.desktop.undo": "Turning this back on regenerates the opencodex profile from your saved model assignments.", + "integrations.dialog.desktop.restart": "Claude Desktop reads this configuration only at launch. Fully quit and reopen it for this change to take effect.", + "integrations.dialog.desktop.confirm": "Disable", + "integrations.native.error.desktopUnsafeMetadata": "Claude Desktop metadata at {path} could not be read safely, so its library was not changed.", + "integrations.native.error.desktopCleanupIncomplete": "Claude Desktop is pointed at standard mode, but old opencodex credential files remain at: {paths}.", + "integrations.native.msg.desktopDisabled": "Claude Desktop integration disabled.", + "integrations.native.msg.desktopEnabled": "Claude Desktop integration enabled.", "integrations.detail.grokModels": "已接入 {count} 个模型", "integrations.detail.grokAbsent": "配置中没有 opencodex 区块", "integrations.dialog.grok.title": "要停用 Grok Build 集成吗?", @@ -1838,6 +1854,8 @@ export const zh: Record = { "claudeDesktop.status.stale": "配置已更改 — 需重新应用", "claudeDesktop.status.notApplied": "未应用", "claudeDesktop.status.notActiveProfile": "Desktop 正在使用其他配置 — 请重新应用", + "claudeDesktop.status.disabled": "Claude Desktop integration is off. Fully quit and reopen Desktop after enabling it.", + "claudeDesktop.enableApply": "Enable and apply", "claudeDesktop.health.lastRequest": "最后请求", "claudeDesktop.health.stats": "{count} 请求 / {errors} 错误", "claudeDesktop.effort.supported": "effort", diff --git a/gui/src/pages/ClaudeDesktop.tsx b/gui/src/pages/ClaudeDesktop.tsx index 32b3a41f57..785aeb7688 100644 --- a/gui/src/pages/ClaudeDesktop.tsx +++ b/gui/src/pages/ClaudeDesktop.tsx @@ -43,6 +43,7 @@ interface DesktopModel { } interface DesktopStatus { + desiredEnabled: boolean; applied: boolean; appliedAt: string | null; stale: boolean; @@ -408,6 +409,8 @@ export default function ClaudeDesktop({ ? "not-applied" : !status ? "pending" + : !status.desiredEnabled + ? "not-applied" : status.activeProfile === false ? "not-applied" : status.stale @@ -426,6 +429,8 @@ export default function ClaudeDesktop({ ? t("claudeDesktop.loadFail") : !status ? t("claudeDesktop.loading") + : !status.desiredEnabled + ? t("claudeDesktop.status.disabled") : status.activeProfile === false ? t("claudeDesktop.status.notActiveProfile") : status.stale @@ -459,7 +464,7 @@ export default function ClaudeDesktop({ {pending === "save" ? t("claudeDesktop.saving") : t("common.save")} diff --git a/gui/src/pages/integrations/IntegrationsOverview.tsx b/gui/src/pages/integrations/IntegrationsOverview.tsx index cc063bd5ab..79788f036b 100644 --- a/gui/src/pages/integrations/IntegrationsOverview.tsx +++ b/gui/src/pages/integrations/IntegrationsOverview.tsx @@ -41,6 +41,15 @@ const GROK_DISABLE_COPY: ConsequenceCopy = { confirmKey: "integrations.dialog.grok.confirm", }; +const DESKTOP_DISABLE_COPY: ConsequenceCopy = { + titleKey: "integrations.dialog.desktop.title", + changesKey: "integrations.dialog.desktop.changes", + breakageKey: "integrations.dialog.desktop.breakage", + undoKey: "integrations.dialog.desktop.undo", + sideEffectKey: "integrations.dialog.desktop.restart", + confirmKey: "integrations.dialog.desktop.confirm", +}; + const KIND_KEY: Record = { apply: "integrations.kind.apply", disable: "integrations.kind.disable", @@ -115,7 +124,7 @@ function OverviewCard({ {row.toggle && onToggle && (
navigateHash(row.hash)} - onToggle={row.toggle ? () => requestToggle(row, !row.applied) : null} + onToggle={row.toggle ? () => requestToggle(row, !(row.toggleOn ?? row.applied)) : null} /> ))} @@ -584,7 +593,7 @@ export default function IntegrationsOverview({ )} {pendingToggle && ( setPendingToggle(null)} onConfirm={async () => { await toggleCard(pendingToggle, false); diff --git a/gui/src/pages/integrations/integration-api.ts b/gui/src/pages/integrations/integration-api.ts index 927f1417ad..54bcc4a545 100644 --- a/gui/src/pages/integrations/integration-api.ts +++ b/gui/src/pages/integrations/integration-api.ts @@ -268,6 +268,9 @@ async function readOptional(request: Promise): Promise { export async function loadCodexRoutingStatus(apiBase: string, signal?: AbortSignal) { const body = await readOptional<{ + desiredEnabled?: unknown; + installed?: unknown; + observedKind?: unknown; routingInjected?: unknown; status?: unknown; recommendedCommand?: unknown; @@ -321,8 +324,11 @@ export async function loadClaudeDesktopStatus(apiBase: string, signal?: AbortSig activeProfile?: unknown; appliedAt?: unknown; }>(fetch(`${apiBase}/api/claude-desktop/status`, { signal })); - if (!body) return null; + if (!body || typeof body.desiredEnabled !== "boolean" || typeof body.installed !== "boolean" || typeof body.observedKind !== "string") return null; return { + desiredEnabled: body.desiredEnabled, + installed: body.installed, + observedKind: body.observedKind, applied: body.applied === true, stale: body.stale === true, // Tri-state on purpose: `null` means undeterminable, which must not be diff --git a/gui/src/pages/integrations/native-api.ts b/gui/src/pages/integrations/native-api.ts index 05fec0e8ff..f73051f633 100644 --- a/gui/src/pages/integrations/native-api.ts +++ b/gui/src/pages/integrations/native-api.ts @@ -8,20 +8,23 @@ import { readJsonIfOk } from "../../fetch-json"; * nothing caught it locally because GUI typecheck runs from its own tsconfig — * `bun x tsc --noEmit` at the repository root does not read this file. CI did. */ -export type NativeIntegrationClientId = "claude" | "grok" | "codex"; +export type NativeIntegrationClientId = "claude" | "grok" | "codex" | "claude-desktop"; export type NativeIntegrationState = "absent" | "current" | "unsafe"; export type NativeRefusalReason = | "not_installed" | "orphaned_marker" | "home_mismatch" | "config_busy" - | "write_failed"; + | "write_failed" + | "metadata_unreadable" + | "cleanup_incomplete"; export interface NativeStatus { clientId: NativeIntegrationClientId; state: NativeIntegrationState; installed: boolean; configPath: string; + desiredEnabled: boolean; disableBlocked: { reason: NativeRefusalReason; message: string } | null; } @@ -35,6 +38,7 @@ export interface NativeToggleEnvelope { changed: boolean; state: NativeIntegrationState; message: string; + desiredEnabled: boolean; reason?: string; } @@ -44,6 +48,8 @@ export interface NativeRefusalEnvelope { clientId: NativeIntegrationClientId; reason: NativeRefusalReason; message: string; + desiredEnabled?: boolean; + residualPaths?: string[]; } export interface NativeErrorEnvelope { @@ -58,7 +64,7 @@ export type NativeErrorBody = NativeErrorEnvelope | NativeRefusalEnvelope; // Widening the type alone would leave this guard rejecting a `codex` response at // runtime, so the set moves with it. -const NATIVE_CLIENTS: ReadonlySet = new Set(["claude", "grok", "codex"]); +const NATIVE_CLIENTS: ReadonlySet = new Set(["claude", "grok", "codex", "claude-desktop"]); const NATIVE_REFUSAL_CODES: ReadonlySet = new Set([ "native_integration_refused", "native_integration_failed", @@ -69,6 +75,8 @@ const NATIVE_REFUSAL_REASONS: ReadonlySet = new Set "home_mismatch", "config_busy", "write_failed", + "metadata_unreadable", + "cleanup_incomplete", ]); function isRecord(value: unknown): value is Record { diff --git a/gui/src/pages/integrations/overview-clients.ts b/gui/src/pages/integrations/overview-clients.ts index 6fe89d5ec0..3e3e85db33 100644 --- a/gui/src/pages/integrations/overview-clients.ts +++ b/gui/src/pages/integrations/overview-clients.ts @@ -66,6 +66,8 @@ export interface OverviewRow { installed: boolean; /** Drives the "applied" summary count. */ applied: boolean; + /** Desired switch position; separate from observed application. */ + toggleOn?: boolean; /** * The one line under the title. File clients show their config path — the * thing a user copies when a refusal tells them to finish by hand. The other @@ -96,6 +98,9 @@ export interface ClaudeCodePayload { authMode?: string; } export interface ClaudeDesktopPayload { + desiredEnabled?: boolean; + installed?: boolean; + observedKind?: string; applied?: boolean; stale?: boolean; activeProfile?: boolean | null; @@ -270,27 +275,35 @@ function claudeRow( * Desktop is not honoring. `null` is undeterminable and must not downgrade a * healthy `current`. */ -function claudeDesktopRow(payload: ClaudeDesktopPayload | null): OverviewRow { +function claudeDesktopRow( + payload: ClaudeDesktopPayload | null, + native: NativeStatus | undefined, + nativeSettled: boolean, +): OverviewRow { const base = { id: "claudeDesktop" as const, hash: "integrations/claude/desktop", // "Desktop" alone is ambiguous next to ten other client names. labelKey: "claudeDesktop.title" as TKey, - toggle: null, - toggleBlocked: null, - togglePath: null, + toggle: "claude-desktop" as const, + toggleBlocked: native?.disableBlocked ?? null, + togglePath: native?.configPath ?? null, status: null, detail: null, detailVars: null, }; - if (!payload) return { ...base, state: "unknown", installed: false, applied: false, detailKey: null }; + if (!payload || !nativeSettled || !native || typeof payload.desiredEnabled !== "boolean") { + return { ...base, toggle: null, state: "unknown", installed: false, applied: false, detailKey: null }; + } + const toggleOn = payload.desiredEnabled; if (payload.applied !== true) { return { ...base, state: "absent", - installed: true, + installed: payload.installed === true, applied: false, - detailKey: "integrations.detail.desktopAbsent", + toggleOn, + detailKey: toggleOn ? "integrations.detail.desktopDesiredOnNotApplied" : "integrations.detail.desktopDesiredOff", }; } const drifted = payload.stale === true || payload.activeProfile === false; @@ -299,6 +312,7 @@ function claudeDesktopRow(payload: ClaudeDesktopPayload | null): OverviewRow { state: drifted ? "stale" : "current", installed: true, applied: true, + toggleOn, // Separate sentences: a drifted file and a profile Desktop is not serving // are different problems with different fixes. detailKey: payload.activeProfile === false @@ -395,7 +409,11 @@ export function buildOverviewRows(sources: OverviewSources): OverviewRows { const rows: OverviewRow[] = [ codexRow(sources.codex), claudeRow(sources.claude, nativeClaude, sources.nativeSettled), - claudeDesktopRow(sources.claudeDesktop), + claudeDesktopRow( + sources.claudeDesktop, + sources.native?.find(client => client.clientId === "claude-desktop"), + sources.nativeSettled, + ), grokRow(sources.grok, nativeGrok, sources.nativeSettled), ]; for (const clientId of FILE_INTEGRATION_CLIENTS) { diff --git a/gui/src/pages/integrations/refusal-copy.ts b/gui/src/pages/integrations/refusal-copy.ts index 824ac60896..6415be022f 100644 --- a/gui/src/pages/integrations/refusal-copy.ts +++ b/gui/src/pages/integrations/refusal-copy.ts @@ -68,6 +68,10 @@ function describeNativeRefusal( } if (refusal.reason === "not_installed") return t("integrations.native.error.notInstalled"); if (refusal.reason === "config_busy") return t("integrations.native.error.configBusy"); + if (refusal.reason === "metadata_unreadable") return t("integrations.native.error.desktopUnsafeMetadata", { path: configPath ?? "" }); + if (refusal.reason === "cleanup_incomplete") { + return t("integrations.native.error.desktopCleanupIncomplete", { paths: (refusal.residualPaths ?? []).join(", ") }); + } return refusal.message || t("integrations.error.generic"); } diff --git a/gui/tests/integrations-overview-rows.test.ts b/gui/tests/integrations-overview-rows.test.ts index 4cd926dc94..5410c9fd8a 100644 --- a/gui/tests/integrations-overview-rows.test.ts +++ b/gui/tests/integrations-overview-rows.test.ts @@ -35,6 +35,8 @@ function sources(overrides: Partial = {}): OverviewSources { claude: null, claudeDesktop: null, grok: null, + native: null, + nativeSettled: true, ...overrides, }; } @@ -79,24 +81,32 @@ test("Codex reads routingInjected, not status", () => { }); test("Claude Desktop: applied but not the served profile reads as stale", () => { + const desktopNative = [{ + clientId: "claude-desktop" as const, + state: "current" as const, + installed: true, + configPath: "/tmp/desktop", + desiredEnabled: true, + disableBlocked: null, + }]; const served = buildOverviewRows( - sources({ claudeDesktop: { applied: true, stale: false, activeProfile: true } }), + sources({ native: desktopNative, claudeDesktop: { desiredEnabled: true, installed: true, applied: true, stale: false, activeProfile: true } }), ); expect(rowById(served, "claudeDesktop").state).toBe("current"); const notServed = buildOverviewRows( - sources({ claudeDesktop: { applied: true, stale: false, activeProfile: false } }), + sources({ native: desktopNative, claudeDesktop: { desiredEnabled: true, installed: true, applied: true, stale: false, activeProfile: false } }), ); expect(rowById(notServed, "claudeDesktop").state).toBe("stale"); const drifted = buildOverviewRows( - sources({ claudeDesktop: { applied: true, stale: true, activeProfile: true } }), + sources({ native: desktopNative, claudeDesktop: { desiredEnabled: true, installed: true, applied: true, stale: true, activeProfile: true } }), ); expect(rowById(drifted, "claudeDesktop").state).toBe("stale"); // Undeterminable must not downgrade a healthy applied profile. const unknownProfile = buildOverviewRows( - sources({ claudeDesktop: { applied: true, stale: false, activeProfile: null } }), + sources({ native: desktopNative, claudeDesktop: { desiredEnabled: true, installed: true, applied: true, stale: false, activeProfile: null } }), ); expect(rowById(unknownProfile, "claudeDesktop").state).toBe("current"); }); @@ -133,7 +143,29 @@ test("every client counts toward the summary, not just the file six", () => { codex: { routingInjected: true, status: "at-risk" }, keyCount: 2, claude: { enabled: true }, - claudeDesktop: { applied: true, stale: true, activeProfile: true }, + claudeDesktop: { desiredEnabled: true, installed: true, applied: true, stale: true, activeProfile: true }, + native: [{ + clientId: "claude-desktop", + state: "current", + installed: true, + configPath: "/tmp/desktop", + desiredEnabled: true, + disableBlocked: null, + }, { + clientId: "claude", + state: "current", + installed: true, + configPath: "/tmp/config", + desiredEnabled: true, + disableBlocked: null, + }, { + clientId: "grok", + state: "current", + installed: true, + configPath: "/tmp/grok", + desiredEnabled: true, + disableBlocked: null, + }], grok: { present: true, models: [{}, {}] }, })); const counts = countOverviewRows(rows.rows); diff --git a/gui/tests/integrations-surfaces.test.tsx b/gui/tests/integrations-surfaces.test.tsx index c4090792d3..a720859cda 100644 --- a/gui/tests/integrations-surfaces.test.tsx +++ b/gui/tests/integrations-surfaces.test.tsx @@ -123,7 +123,17 @@ beforeEach(() => { if (url.includes("/api/claude-desktop/status")) { return failExtraSources ? json({ error: "nope" }, 500) - : json({ applied: false, stale: false, activeProfile: null, appliedAt: null }); + : json({ desiredEnabled: true, installed: true, observedKind: "standard", applied: false, stale: false, activeProfile: null, appliedAt: null }); + } + if (url.includes("/api/native-integrations")) { + return json({ clients: [{ + clientId: "claude-desktop", + state: "absent", + installed: true, + configPath: "/tmp/desktop", + desiredEnabled: true, + disableBlocked: null, + }] }); } if (url.includes("/api/claude-code")) { return failExtraSources ? json({ error: "nope" }, 500) : json({ enabled: false }); @@ -604,8 +614,7 @@ test("every reachable client gets a card, not just the file six", async () => { .map(card => card.getAttribute("data-client")); expect(switchOwners).toContain("hermes"); expect(switchOwners).toContain("codex"); - // Navigation-only cards still have none. - expect(switchOwners).not.toContain("claudeDesktop"); + expect(switchOwners).toContain("claudeDesktop"); // Claude Desktop opens Claude's nested route, not a tab of its own. const desktopLink = container.querySelector( From 0b97b91efe9050b4c3173bfbe0c2c5ae39d09372 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 13:21:22 +0900 Subject: [PATCH 049/317] test(claude): cover Desktop standard-mode removal --- tests/desktop-3p-removal.test.ts | 51 ++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 tests/desktop-3p-removal.test.ts diff --git a/tests/desktop-3p-removal.test.ts b/tests/desktop-3p-removal.test.ts new file mode 100644 index 0000000000..a80319efce --- /dev/null +++ b/tests/desktop-3p-removal.test.ts @@ -0,0 +1,51 @@ +import { expect, test } from "bun:test"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { + inspectDesktop3pConfigLibrary, + removeDesktop3pStandardPivot, +} from "../src/claude/desktop-3p"; + +function envFor(path: string): NodeJS.ProcessEnv { + return { ...process.env, OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR: path }; +} + +test("an absent Desktop library is read-only and OFF is an idempotent no-op", () => { + const library = join(mkdtempSync(join(tmpdir(), "ocx-desktop-remove-")), "missing"); + const options = { env: envFor(library) }; + expect(inspectDesktop3pConfigLibrary(options).kind).toBe("not_installed"); + expect(removeDesktop3pStandardPivot(options)).toMatchObject({ ok: true, changed: false, kind: "noop" }); + expect(existsSync(library)).toBe(false); +}); + +test("OFF selects a credential-free standard profile before deleting the owned profile and backup", () => { + const library = mkdtempSync(join(tmpdir(), "ocx-desktop-remove-")); + const id = "owned-profile"; + mkdirSync(library, { recursive: true }); + writeFileSync(join(library, "_meta.json"), JSON.stringify({ appliedId: id, entries: [{ id, name: "opencodex" }] })); + writeFileSync(join(library, `${id}.json`), JSON.stringify({ + inferenceProvider: "gateway", + inferenceCredentialKind: "static", + inferenceGatewayBaseUrl: "http://127.0.0.1:10100", + inferenceGatewayApiKey: "not-printed", + })); + writeFileSync(join(library, `${id}.json.bak`), "{}"); + + const result = removeDesktop3pStandardPivot({ env: envFor(library) }); + expect(result).toMatchObject({ ok: true, changed: true, kind: "removed" }); + expect(existsSync(join(library, `${id}.json`))).toBe(false); + expect(existsSync(join(library, `${id}.json.bak`))).toBe(false); + const metadata = JSON.parse(readFileSync(join(library, "_meta.json"), "utf8")) as { appliedId: string }; + const standard = JSON.parse(readFileSync(join(library, `${metadata.appliedId}.json`), "utf8")) as Record; + expect(standard).toEqual({}); +}); + +test("a selected path traversal id is refused without following it", () => { + const library = mkdtempSync(join(tmpdir(), "ocx-desktop-remove-")); + writeFileSync(join(library, "_meta.json"), JSON.stringify({ appliedId: "../outside", entries: [] })); + const result = inspectDesktop3pConfigLibrary({ env: envFor(library) }); + expect(result).toMatchObject({ kind: "unsafe", reason: "unsafe_applied_id" }); + expect(removeDesktop3pStandardPivot({ env: envFor(library) }).kind).toBe("unsafe"); +}); From 98de51002be3259b13040f57d9a4c6c1e90f8f84 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Thu, 6 Aug 2026 06:25:47 +0200 Subject: [PATCH 050/317] fix(issue-quality): scan markdown images with balanced delimiters (CodeRabbit) Replace the regex-only markdown-image matcher with a small balanced scanner so destinations containing balanced parentheses (e.g. ![diagram](https://example.com/image_(final).png)) are stripped as media-only instead of leaving a trailing fragment that evades the empty-section check. Also assert the repeated-title reason in the #1098 regression test and cover balanced-paren URLs and malformed destinations. --- .github/scripts/issue-quality.cjs | 100 +++++++++++++++++++++++-- .github/scripts/issue-quality.test.cjs | 34 +++++++++ 2 files changed, 127 insertions(+), 7 deletions(-) diff --git a/.github/scripts/issue-quality.cjs b/.github/scripts/issue-quality.cjs index a0fae2760c..0274022cc5 100644 --- a/.github/scripts/issue-quality.cjs +++ b/.github/scripts/issue-quality.cjs @@ -66,19 +66,105 @@ function isPlaceholderOnlyValue(raw) { */ function stripMediaTokens(text) { if (typeof text !== "string") return ""; - return text + const htmlStripped = text // HTML media tags: (self-closing or not), ..., // , (kept as whole blocks so a lone // media embed does not leave stray tags behind). .replace(//gi, " ") .replace(//gi, " ") .replace(//gi, " ") - .replace(/]*>/gi, " ") - // Markdown images, optionally with a title: ![alt](url "title"). Alt text - // may contain balanced brackets (for example ![Image [screenshot]](url)). - .replace(/!\[(?:[^\[\]]|\[[^\]]*\])*\]\([^)]*\)/g, " ") - // HTML comment tokens that may wrap media. - .replace(//g, " "); + .replace(/]*>/gi, " "); + return stripMarkdownImages(htmlStripped); +} + +/** + * Remove Markdown image tokens `![alt](dest "title")` using a small + * balanced scanner instead of a regex, because destinations may contain + * balanced parentheses (for example `image_(final).png`) and alt text may + * contain balanced brackets (`![Image [screenshot]](url)`). + * + * A token is matched only when: + * - it starts with `![` (not escaped); + * - the alt text is balanced with respect to `[` / `]`; + * - the destination is balanced with respect to `(`, `)` and `"` (an + * optional title may follow); and + * - the token closes with a `)`. + * + * Malformed tokens (unbalanced destination, e.g. `a)b.png)`) are left in + * place — they are not valid Markdown images and must not be silently + * dropped. + */ +function stripMarkdownImages(text) { + if (typeof text !== "string") return ""; + const out = []; + let i = 0; + while (i < text.length) { + // A backslash-escaped or code-fenced `![` is not an image token. We only + // guard the common `\!` escape here; fenced blocks are handled by the + // section extractor upstream, which does not include them in sections. + if (text[i] === "!" && text[i + 1] === "[") { + const end = scanMarkdownImage(text, i); + if (end !== -1) { + out.push(" "); + i = end; + continue; + } + } + out.push(text[i]); + i += 1; + } + return out.join(""); +} + +/** + * Scan a Markdown image token starting at `start` (which points at `!`). + * Returns the index just past the closing `)` on success, or -1 when the + * token is malformed. + */ +function scanMarkdownImage(text, start) { + // Alt text: `![` ... `]` with balanced nested brackets. + let i = start + 2; + let bracketDepth = 0; + for (; i < text.length; i += 1) { + const ch = text[i]; + if (ch === "\\") { + i += 1; // skip escaped character + continue; + } + if (ch === "[") { + bracketDepth += 1; + } else if (ch === "]") { + if (bracketDepth === 0) break; + bracketDepth -= 1; + } + } + if (i >= text.length || text[i] !== "]") return -1; + + // Destination: `(` ... `)` with balanced parentheses. An optional + // whitespace-separated `"title"` may follow the destination. + if (text[i + 1] !== "(") return -1; + i += 2; + let parenDepth = 1; + let inQuotes = false; + for (; i < text.length; i += 1) { + const ch = text[i]; + if (ch === "\\") { + i += 1; // skip escaped character + continue; + } + if (ch === '"') { + inQuotes = !inQuotes; + continue; + } + if (inQuotes) continue; + if (ch === "(") { + parenDepth += 1; + } else if (ch === ")") { + parenDepth -= 1; + if (parenDepth === 0) return i + 1; + } + } + return -1; } /** diff --git a/.github/scripts/issue-quality.test.cjs b/.github/scripts/issue-quality.test.cjs index dcd44ad9aa..7094025087 100644 --- a/.github/scripts/issue-quality.test.cjs +++ b/.github/scripts/issue-quality.test.cjs @@ -268,6 +268,10 @@ describe("validateIssue - feature", () => { result.reasons.some((r) => /same content/i.test(r)), `Expected duplicate-content reason, got: ${result.reasons.join("; ")}`, ); + assert.ok( + result.reasons.some((r) => /repeat the issue title/i.test(r)), + `Expected repeated-title reason, got: ${result.reasons.join("; ")}`, + ); }); it("rejects a markdown-image-only goal section with repeated prose (#1098)", () => { @@ -319,6 +323,32 @@ describe("validateIssue - feature", () => { ); }); + it("rejects a markdown image whose URL contains balanced parentheses (#1098)", () => { + const repeated = + "It is hoped that the usage query will support time-based queries and statistics, as well as key-based queries and statistics"; + // Markdown destinations may contain balanced parentheses, e.g. + // ![diagram](https://example.com/image_(final).png). The stripper must + // still treat it as media-only so it cannot hide repeated prose. + const mdImg = "![diagram](https://example.com/image_(final).png)"; + const body = [ + "### What are you trying to accomplish?", + mdImg, + "### What prevents this today?", + repeated, + "### What should OpenCodex do?", + repeated, + "### Example usage or interface", + repeated, + ].join("\n"); + const result = validateIssue({ title: repeated, body, labels: ["enhancement"] }); + assert.equal(result.kind, "feature"); + assert.equal(result.valid, false); + assert.ok( + result.reasons.some((r) => /missing or empty/i.test(r)), + `Expected missing/empty reason, got: ${result.reasons.join("; ")}`, + ); + }); + it("preserves a goal section that mixes an image with real text", () => { const goal = [ "![Screenshot](https://example.com/shot.png)", @@ -337,6 +367,10 @@ describe("validateIssue - feature", () => { assert.equal(isMediaOnly(''), true); assert.equal(isMediaOnly("![alt](https://example.com/x.png)"), true); assert.equal(isMediaOnly("![alt [with bracket]](https://example.com/x.png)"), true); + assert.equal(isMediaOnly("![diagram](https://example.com/image_(final).png)"), true); + assert.equal(isMediaOnly('![alt](https://example.com/image_(final).png "title")'), true); + assert.equal(isMediaOnly("![bad](https://example.com/a)b.png)"), false); + assert.equal(isMediaOnly("\\![escaped](url)"), false); assert.equal(isMediaOnly(''), true); assert.equal(isMediaOnly(''), true); assert.equal(isMediaOnly('\nCaption text'), false); From 718b57a3f099baec6e6daebd7eb0f7e050726c66 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Thu, 6 Aug 2026 06:28:01 +0200 Subject: [PATCH 051/317] fix(issue-quality): handle reference images, media fallback text, and indented code (Codex) - Strip reference-style markdown images (![alt][ref] plus [ref]: url) when the reference is used by an image, including implicit ![alt][] references. - Preserve fallback/caption prose inside