@bitkyc08/opencodex 2.35.0 → 2.36.0-preview.20260830
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/gui/dist/assets/index-Cy7Z_pl0.css +1 -0
- package/gui/dist/assets/index-DPl4nBMA.js +112 -0
- package/gui/dist/index.html +2 -2
- package/package.json +2 -1
- package/src/AGENTS.md +2 -1
- package/src/adapters/agentrouter.ts +50 -0
- package/src/adapters/anthropic.ts +1 -51
- package/src/adapters/cursor/call-id.ts +76 -8
- package/src/adapters/cursor/checkpoint-store.ts +6 -1
- package/src/adapters/cursor/cursor-errors.ts +44 -0
- package/src/adapters/cursor/native-exec.ts +13 -0
- package/src/adapters/cursor/protobuf-request.ts +651 -29
- package/src/adapters/cursor/tool-result-normalize.ts +3 -3
- package/src/adapters/cursor/transport-retry.ts +5 -1
- package/src/adapters/cursor.ts +15 -1
- package/src/adapters/empty-tool-output-annotation.ts +43 -0
- package/src/adapters/exec-tool-result-normalize.ts +70 -5
- package/src/adapters/google.ts +22 -2
- package/src/adapters/kiro.ts +26 -2
- package/src/adapters/ollama-native-url.ts +111 -0
- package/src/adapters/ollama-native.ts +1131 -0
- package/src/adapters/openai-chat.ts +30 -7
- package/src/adapters/openai-responses.ts +72 -4
- package/src/adapters/registry.ts +7 -0
- package/src/adapters/xai-web-search.ts +58 -0
- package/src/claude/desktop-3p.ts +21 -1
- package/src/claude/desktop-policy.ts +149 -0
- package/src/cli/account.ts +16 -2
- package/src/cli/claude-desktop.ts +13 -3
- package/src/cli/combo.ts +8 -5
- package/src/cli/doctor.ts +77 -11
- package/src/cli/help.ts +1 -1
- package/src/cli/index.ts +16 -0
- package/src/cli/models.ts +20 -3
- package/src/cli/registry.ts +2 -1
- package/src/cli/status.ts +140 -2
- package/src/cli/storage.ts +10 -1
- package/src/codex/account-runtime-state.ts +39 -5
- package/src/codex/account-store.ts +393 -13
- package/src/codex/account-usability.ts +11 -4
- package/src/codex/app-server-processes.ts +46 -5
- package/src/codex/auth-context.ts +160 -32
- package/src/codex/catalog/bundled.ts +7 -5
- package/src/codex/catalog/metadata.ts +1 -1
- package/src/codex/catalog/parsing.ts +57 -1
- package/src/codex/catalog/provider-fetch.ts +61 -4
- package/src/codex/catalog/sync.ts +4 -3
- package/src/codex/convergence.ts +3 -2
- package/src/codex/data/upstream-models.json +40 -8
- package/src/codex/inject-coordination.ts +111 -14
- package/src/codex/integration-record.ts +12 -2
- package/src/codex/main-account.ts +225 -1
- package/src/codex/model-entitlements.ts +339 -27
- package/src/codex/prompt-layers.ts +346 -7
- package/src/codex/prompt-text-probe.ts +272 -21
- package/src/codex/routing.ts +693 -132
- package/src/codex/runtime.ts +12 -0
- package/src/codex/subagent-model-fallback.ts +62 -24
- package/src/codex/user-identity.ts +33 -25
- package/src/combos/index.ts +1 -0
- package/src/combos/reset-window.ts +46 -0
- package/src/combos/resolve.ts +84 -2
- package/src/combos/types.ts +5 -2
- package/src/config/atomic-write.ts +104 -22
- package/src/config/provider-validation.ts +11 -0
- package/src/config.ts +75 -3
- package/src/generated/compatibility-version.json +207 -131
- package/src/generated/model-metadata.ts +1 -1
- package/src/grok/catalog.ts +71 -0
- package/src/grok/effort.ts +83 -0
- package/src/grok/inject.ts +952 -127
- package/src/grok/models.ts +56 -0
- package/src/grok/status.ts +21 -8
- package/src/grok/sync.ts +10 -18
- package/src/images/loop.ts +6 -3
- package/src/integrations/native/ownership-preflight.ts +4 -1
- package/src/lab/fabric/producer-isolate.ts +36 -3
- package/src/lib/destination-policy.ts +93 -7
- package/src/lib/redact.ts +6 -1
- package/src/lib/shadow-call.ts +38 -3
- package/src/lib/test-home-guard.ts +18 -3
- package/src/lib/upstream-retry.ts +43 -6
- package/src/lib/windows-secret-acl.ts +66 -0
- package/src/lib/windows-text.ts +28 -2
- package/src/lib/windows-user-principal.ts +35 -23
- package/src/oauth/account-quota-rank.ts +107 -0
- package/src/oauth/anthropic-routing.ts +125 -30
- package/src/oauth/chatgpt.ts +5 -1
- package/src/oauth/generic-account-failover.ts +114 -7
- package/src/oauth/index.ts +15 -8
- package/src/oauth/store.ts +16 -0
- package/src/providers/account-quota-disk.ts +79 -0
- package/src/providers/command-code-efforts.ts +24 -0
- package/src/providers/derive.ts +6 -0
- package/src/providers/key-failover.ts +33 -1
- package/src/providers/kiro-usage.ts +272 -0
- package/src/providers/ollama-show.ts +311 -0
- package/src/providers/openai-sidecar.ts +5 -0
- package/src/providers/quota-routing-cache.ts +32 -0
- package/src/providers/quota-types.ts +36 -0
- package/src/providers/quota-wire.ts +102 -0
- package/src/providers/quota.ts +208 -147
- package/src/providers/registry.ts +68 -8
- package/src/providers/slug-codec.ts +12 -4
- package/src/providers/vercel-gateway-routing.ts +108 -0
- package/src/router.ts +22 -12
- package/src/server/auth-cors.ts +26 -0
- package/src/server/catalog-download.ts +73 -0
- package/src/server/chat-native.ts +12 -2
- package/src/server/gui-static.ts +4 -1
- package/src/server/index.ts +132 -9
- package/src/server/management/agent-settings-routes.ts +38 -5
- package/src/server/management/codex-prompt-routes.ts +7 -1
- package/src/server/management/combo-routes.ts +10 -1
- package/src/server/management/config-routes.ts +9 -1
- package/src/server/management/context.ts +5 -0
- package/src/server/management/model-routes.ts +16 -6
- package/src/server/management/native-integration-routes.ts +12 -17
- package/src/server/management/oauth-account-routes.ts +13 -0
- package/src/server/management/provider-routes.ts +32 -5
- package/src/server/management/routing-profile-routes.ts +15 -0
- package/src/server/management/shadow-call-validation.ts +29 -0
- package/src/server/management-api.ts +7 -3
- package/src/server/request-log.ts +3 -5
- package/src/server/responses/agent-task-recovery-cache.ts +8 -0
- package/src/server/responses/agent-task-recovery.ts +52 -20
- package/src/server/responses/codex-auth-error.ts +26 -0
- package/src/server/responses/compact.ts +345 -10
- package/src/server/responses/core.ts +736 -108
- package/src/server/responses/empty-completion-guard.ts +16 -0
- package/src/server/responses/fetch-helpers.ts +42 -0
- package/src/server/responses/policy-fallback.ts +11 -6
- package/src/server/responses-undeclared-tool-guard.ts +16 -3
- package/src/server/startup-health-cache.ts +59 -13
- package/src/service-manager-probe.ts +115 -9
- package/src/service.ts +139 -40
- package/src/storage/cleanup.ts +10 -0
- package/src/storage/storage-mutation-coordinator.ts +14 -3
- package/src/tray/windows-tray.ps1 +10 -4
- package/src/tray/windows.ts +30 -2
- package/src/types/config.ts +27 -14
- package/src/types/provider.ts +54 -0
- package/src/types/tools.ts +13 -3
- package/src/types.ts +4 -0
- package/src/usage/summary.ts +421 -177
- package/src/vision/anthropic-describe.ts +3 -3
- package/src/vision/describe.ts +5 -3
- package/src/web-search/anthropic-executor.ts +9 -2
- package/src/web-search/exa-executor.ts +3 -3
- package/src/web-search/executor.ts +8 -3
- package/src/web-search/gemini-executor.ts +3 -3
- package/src/web-search/loop.ts +11 -3
- package/src/web-search/xai-executor.ts +3 -3
- package/gui/dist/assets/index-DNdRKXK9.js +0 -112
- package/gui/dist/assets/index-DQ-Ie18T.css +0 -1
|
@@ -28,9 +28,10 @@
|
|
|
28
28
|
*/
|
|
29
29
|
import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs";
|
|
30
30
|
import { dirname, join, resolve } from "node:path";
|
|
31
|
-
import { createHash, randomBytes } from "node:crypto";
|
|
31
|
+
import { createHash, randomBytes, type Hash } from "node:crypto";
|
|
32
32
|
import { expandUserPath } from "../config";
|
|
33
33
|
import { CODEX_CONFIG_PATH } from "./paths";
|
|
34
|
+
import { resolveCodexHomeDir } from "./home";
|
|
34
35
|
import { OCX_SECTION_MARKER } from "./injected-marker";
|
|
35
36
|
import {
|
|
36
37
|
durableWrite,
|
|
@@ -181,6 +182,211 @@ export function activeBaseVariantDir(opts?: Paths): string {
|
|
|
181
182
|
return opts?.baseVariantDir ?? join(activeCodexHome(), "opencodex-prompt-base");
|
|
182
183
|
}
|
|
183
184
|
|
|
185
|
+
/**
|
|
186
|
+
* Instruction documents the prompt probe renders out of CODEX_HOME, in the
|
|
187
|
+
* precedence order Codex itself applies: an `AGENTS.override.md` shadows
|
|
188
|
+
* `AGENTS.md`. Both are hashed into the probe fingerprint, because either one
|
|
189
|
+
* changes the rendered project document without touching a managed file.
|
|
190
|
+
*/
|
|
191
|
+
const PROBE_INSTRUCTION_FILES = ["AGENTS.override.md", "AGENTS.md"] as const;
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* The project-document filenames Codex would look for in a given home, in its own
|
|
195
|
+
* order: the two built-ins first, then whatever `project_doc_fallback_filenames`
|
|
196
|
+
* adds, de-duplicated (`core/src/agents_md.rs` `candidate_filenames`).
|
|
197
|
+
*
|
|
198
|
+
* Read from config rather than hard-coded, because a user who configures
|
|
199
|
+
* `TEAM.md` renders TEAM.md, and a fingerprint that only knew about AGENTS.md
|
|
200
|
+
* would let an edit to it pass unnoticed.
|
|
201
|
+
*
|
|
202
|
+
*/
|
|
203
|
+
function probeInstructionFilenames(configBytes: string | null): string[] {
|
|
204
|
+
const names: string[] = [...PROBE_INSTRUCTION_FILES];
|
|
205
|
+
for (const entry of rootArrayEntries(configBytes, "project_doc_fallback_filenames")) {
|
|
206
|
+
// Upstream trims each configured name and drops whitespace-only entries
|
|
207
|
+
// (`core/src/config/mod.rs`), so " TEAM.md " and "TEAM.md" are one filename.
|
|
208
|
+
const name = entry.trim();
|
|
209
|
+
if (name === "") continue;
|
|
210
|
+
if (!names.includes(name)) names.push(name);
|
|
211
|
+
}
|
|
212
|
+
return names;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Decoded string entries of a root-scope TOML array.
|
|
217
|
+
*
|
|
218
|
+
* Parsed, not pattern-matched. Three successive review rounds each found another
|
|
219
|
+
* valid spelling a hand-rolled reader missed — multi-line arrays, a comment after the
|
|
220
|
+
* opening bracket, a quoted key — and every miss was a rendered document whose edits
|
|
221
|
+
* moved no admission key. The pattern was the defect: TOML is not a line format, so
|
|
222
|
+
* no regex over lines can enumerate what a parser accepts.
|
|
223
|
+
*
|
|
224
|
+
* The module header's warning about JS TOML parsers does apply here, and a review
|
|
225
|
+
* round proved it against an earlier version of this comment that claimed otherwise.
|
|
226
|
+
* Bun rejects an entire document containing an integer outside JavaScript's safe
|
|
227
|
+
* range, such as `model_context_window = 9223372036854775807`, which Rust accepts as
|
|
228
|
+
* an ordinary `i64`. A whole-document parse turned that into BOTH arrays disappearing
|
|
229
|
+
* — a worse failure than any single missed spelling, and one the old regex did not
|
|
230
|
+
* have.
|
|
231
|
+
*
|
|
232
|
+
* So the parse is the preferred reader, not the only one. When it fails, the scan
|
|
233
|
+
* below runs, and it is deliberately loose: it accepts any spelling it recognises and
|
|
234
|
+
* over-reports rather than under-reports, because an extra hashed filename costs one
|
|
235
|
+
* redundant probe while a missing one costs stale text.
|
|
236
|
+
*/
|
|
237
|
+
function rootArrayEntries(configBytes: string | null, key: string): string[] {
|
|
238
|
+
const value = rootValue(configBytes, key);
|
|
239
|
+
if (value === PARSE_FAILED) return scanRootArrayEntries(configBytes, key);
|
|
240
|
+
if (!Array.isArray(value)) return [];
|
|
241
|
+
return value.filter((entry): entry is string => typeof entry === "string");
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Distinguishes "the parser could not read this file" from "the key is absent".
|
|
246
|
+
* Collapsing the two is what made an unrelated large integer silently empty the
|
|
247
|
+
* project-document set.
|
|
248
|
+
*/
|
|
249
|
+
const PARSE_FAILED = Symbol("toml-parse-failed");
|
|
250
|
+
|
|
251
|
+
/** A root-scope value, `undefined` when the key is absent, `PARSE_FAILED` when the file will not parse. */
|
|
252
|
+
function rootValue(configBytes: string | null, key: string): unknown {
|
|
253
|
+
if (configBytes === null) return undefined;
|
|
254
|
+
let parsed: unknown;
|
|
255
|
+
try {
|
|
256
|
+
parsed = Bun.TOML.parse(configBytes);
|
|
257
|
+
} catch {
|
|
258
|
+
return PARSE_FAILED;
|
|
259
|
+
}
|
|
260
|
+
if (typeof parsed !== "object" || parsed === null) return PARSE_FAILED;
|
|
261
|
+
return (parsed as Record<string, unknown>)[key];
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Fallback reader for a config this parser will not accept but Codex will.
|
|
266
|
+
*
|
|
267
|
+
* Not a second attempt at being a TOML parser — that approach failed three review
|
|
268
|
+
* rounds. It is a deliberately over-eager scan: it takes the first bracketed group for
|
|
269
|
+
* the key under either spelling, spans lines, strips comments, and keeps anything that
|
|
270
|
+
* decodes. Over-reporting is the safe direction here.
|
|
271
|
+
*/
|
|
272
|
+
function scanRootArrayEntries(configBytes: string | null, key: string): string[] {
|
|
273
|
+
const lines = rootLines(configBytes ?? "");
|
|
274
|
+
const opener = new RegExp(`^\\s*"?${key}"?\\s*=\\s*\\[(.*)$`);
|
|
275
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
276
|
+
const m = opener.exec(lines[i]!);
|
|
277
|
+
if (!m) continue;
|
|
278
|
+
let body = m[1]!.replace(/#.*$/, "");
|
|
279
|
+
for (let j = i; !body.includes("]"); ) {
|
|
280
|
+
j += 1;
|
|
281
|
+
if (j >= lines.length) return [];
|
|
282
|
+
body += lines[j]!.replace(/#.*$/, "");
|
|
283
|
+
}
|
|
284
|
+
const out: string[] = [];
|
|
285
|
+
for (const raw of body.slice(0, body.indexOf("]")).split(",")) {
|
|
286
|
+
const trimmed = raw.trim();
|
|
287
|
+
if (trimmed === "") continue;
|
|
288
|
+
const decoded = trimmed.startsWith("'") && trimmed.endsWith("'") && trimmed.length >= 2
|
|
289
|
+
? trimmed.slice(1, -1)
|
|
290
|
+
: decodeBasicString(trimmed);
|
|
291
|
+
if (decoded !== null) out.push(decoded);
|
|
292
|
+
}
|
|
293
|
+
return out;
|
|
294
|
+
}
|
|
295
|
+
return [];
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* The directories Codex would look in for a project document, given the home the
|
|
300
|
+
* probe runs in.
|
|
301
|
+
*
|
|
302
|
+
* Upstream finds the nearest ancestor holding a `project_root_markers` entry
|
|
303
|
+
* (default `.git`) and then searches every directory from that root down to the cwd,
|
|
304
|
+
* inclusive; with no such ancestor it searches the cwd alone
|
|
305
|
+
* (`core/src/agents_md.rs` `agents_md_paths`).
|
|
306
|
+
*
|
|
307
|
+
* This was originally written off as unreachable on the grounds that the probe runs
|
|
308
|
+
* in CODEX_HOME with no checkout around it. That was wrong, and a review round caught
|
|
309
|
+
* it: `~/.codex` inside a dotfiles repository is an ordinary setup, and there the
|
|
310
|
+
* walk finds real documents. The walk is cheap — a bounded number of `existsSync`
|
|
311
|
+
* calls beside a subprocess spawn — so it is performed rather than assumed away.
|
|
312
|
+
*/
|
|
313
|
+
function probeProjectDocDirs(home: string, configBytes: string | null): string[] {
|
|
314
|
+
const markers = projectRootMarkers(configBytes);
|
|
315
|
+
// An explicitly empty array disables root detection upstream, which is not the same
|
|
316
|
+
// as an absent key falling back to the default.
|
|
317
|
+
if (markers.length === 0) return [home];
|
|
318
|
+
let root: string | null = null;
|
|
319
|
+
for (let dir = home; ; ) {
|
|
320
|
+
if (markers.some(marker => existsSync(join(dir, marker)))) { root = dir; break; }
|
|
321
|
+
const parent = dirname(dir);
|
|
322
|
+
if (parent === dir) break;
|
|
323
|
+
dir = parent;
|
|
324
|
+
}
|
|
325
|
+
if (root === null) return [home];
|
|
326
|
+
const dirs: string[] = [];
|
|
327
|
+
for (let dir = home; ; ) {
|
|
328
|
+
dirs.push(dir);
|
|
329
|
+
if (dir === root) break;
|
|
330
|
+
const parent = dirname(dir);
|
|
331
|
+
if (parent === dir) break;
|
|
332
|
+
dir = parent;
|
|
333
|
+
}
|
|
334
|
+
// Root first, matching upstream's reversed search order. Order is load-bearing:
|
|
335
|
+
// the digest must not change merely because the walk was traversed the other way.
|
|
336
|
+
return dirs.reverse();
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** `project_root_markers`, defaulting to `.git` when the key is absent. */
|
|
340
|
+
function projectRootMarkers(configBytes: string | null): string[] {
|
|
341
|
+
if (!hasRootKey(configBytes, "project_root_markers")) return [".git"];
|
|
342
|
+
// Present-but-empty disables root detection upstream, which is why presence is
|
|
343
|
+
// tested separately from the decoded entries rather than inferred from them.
|
|
344
|
+
return rootArrayEntries(configBytes, "project_root_markers").filter(m => m !== "");
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Whether a root-scope key is present at all, regardless of what it holds.
|
|
349
|
+
*
|
|
350
|
+
* A parse failure is not an answer, so it falls through to the scan rather than
|
|
351
|
+
* counting as present: reading `PARSE_FAILED` as "present" would report an empty
|
|
352
|
+
* marker list and disable root detection on a config Codex reads fine.
|
|
353
|
+
*/
|
|
354
|
+
function hasRootKey(configBytes: string | null, key: string): boolean {
|
|
355
|
+
const value = rootValue(configBytes, key);
|
|
356
|
+
if (value === PARSE_FAILED) return scanHasRootKey(configBytes, key);
|
|
357
|
+
return value !== undefined;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Textual presence check, used only when the parser cannot read the file. */
|
|
361
|
+
function scanHasRootKey(configBytes: string | null, key: string): boolean {
|
|
362
|
+
const probe = new RegExp(`^\\s*"?${key}"?\\s*=`);
|
|
363
|
+
return rootLines(configBytes ?? "").some(line => probe.test(line));
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Feed one named field into a fingerprint, framed so that no two distinct states
|
|
368
|
+
* can produce the same digest.
|
|
369
|
+
*
|
|
370
|
+
* Framing is the whole point. Concatenating `name + ":" + contents` is ambiguous:
|
|
371
|
+
* an adversarial review of the first version of this function showed that
|
|
372
|
+
* `{override: "left", agents: "right\nAGENTS.md:tail"}` and
|
|
373
|
+
* `{override: "left\nAGENTS.md:right", agents: "tail"}` hashed identically, because
|
|
374
|
+
* a file's own bytes can imitate the separator that follows it. That is exactly a
|
|
375
|
+
* missed invalidation: the fingerprint is the probe's admission key, so two
|
|
376
|
+
* different prompt states sharing a digest means one caller is served the other's
|
|
377
|
+
* stale text.
|
|
378
|
+
*
|
|
379
|
+
* A byte length cannot be forged by content, so each field carries one. Absence is
|
|
380
|
+
* a length of -1 rather than a sentinel string, because a sentinel is just more
|
|
381
|
+
* content: the same review found that `null` collided with a file whose bytes were
|
|
382
|
+
* literally NUL + "absent".
|
|
383
|
+
*/
|
|
384
|
+
function updateFingerprintField(hash: Hash, name: string, contents: string | null): void {
|
|
385
|
+
const bytes = contents === null ? -1 : Buffer.byteLength(contents, "utf8");
|
|
386
|
+
hash.update(`\n${name}:${bytes}:`);
|
|
387
|
+
if (contents !== null) hash.update(contents);
|
|
388
|
+
}
|
|
389
|
+
|
|
184
390
|
function journalPathFor(storePath: string): string {
|
|
185
391
|
return `${storePath.replace(/\.json$/, "")}.journal`;
|
|
186
392
|
}
|
|
@@ -286,10 +492,13 @@ function readFileOrNull(path: string): string | null {
|
|
|
286
492
|
|
|
287
493
|
export function computeRevision(configBytes: string | null, storeBytes: string | null): string {
|
|
288
494
|
const hash = createHash("sha256");
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
hash.
|
|
292
|
-
|
|
495
|
+
// Length-framed for the reason given on updateFingerprintField: with a bare
|
|
496
|
+
// separator, config bytes ending in "\nstore:" shift the boundary and two
|
|
497
|
+
// different pairs hash alike. That matters twice over — this value is both the
|
|
498
|
+
// probe's admission input and the optimistic-concurrency token compared in
|
|
499
|
+
// commit(), where a collision would let a write built on stale bytes through.
|
|
500
|
+
updateFingerprintField(hash, "cfg", configBytes);
|
|
501
|
+
updateFingerprintField(hash, "store", storeBytes);
|
|
293
502
|
return `sha256:${hash.digest("hex")}`;
|
|
294
503
|
}
|
|
295
504
|
|
|
@@ -506,8 +715,19 @@ function readToggle(configBytes: string | null, id: ToggleId): ToggleState {
|
|
|
506
715
|
function readModelInstructionsFile(configBytes: string | null): string | null {
|
|
507
716
|
if (configBytes === null) return null;
|
|
508
717
|
for (const line of rootLines(configBytes)) {
|
|
509
|
-
|
|
510
|
-
|
|
718
|
+
// Capture the whole literal INCLUDING its quotes and decode it, rather than
|
|
719
|
+
// returning the raw inner text. `setRootString` writes this key through
|
|
720
|
+
// `encodeBasicString`, which escapes backslashes, so on Windows the stored
|
|
721
|
+
// literal is "C:\\Users\\..." while the path is "C:\Users\...". Reading the
|
|
722
|
+
// inner text verbatim returned the doubled form: the round trip did not
|
|
723
|
+
// survive, `baseSelection` compared a doubled path against the real variant
|
|
724
|
+
// path and reported `external` for a variant this code had just selected.
|
|
725
|
+
//
|
|
726
|
+
// `[^"]*` cannot span an escaped quote either. That is not a new limit -- it
|
|
727
|
+
// is the same one the writer's restricted escape set is built around, and
|
|
728
|
+
// `decodeBasicString` refuses anything outside it rather than guessing.
|
|
729
|
+
const m = /^\s*model_instructions_file\s*=\s*("[^"]*")\s*(?:#.*)?$/.exec(line);
|
|
730
|
+
if (m) return decodeBasicString(m[1]!);
|
|
511
731
|
}
|
|
512
732
|
return null;
|
|
513
733
|
}
|
|
@@ -634,6 +854,125 @@ export function readPromptLayers(opts?: Paths): PromptLayerSnapshot {
|
|
|
634
854
|
};
|
|
635
855
|
}
|
|
636
856
|
|
|
857
|
+
/**
|
|
858
|
+
* Identity for prompt-text probe admission, deliberately separate from the
|
|
859
|
+
* optimistic-concurrency revision above. The revision covers only config/store
|
|
860
|
+
* transaction bytes; an edit to the selected base variant changes the prompt
|
|
861
|
+
* without changing that transaction contract.
|
|
862
|
+
*
|
|
863
|
+
* The instruction documents in CODEX_HOME are hashed for the same reason, and they
|
|
864
|
+
* are read from `resolveCodexHomeDir()` rather than from `activeConfigPath`'s
|
|
865
|
+
* directory. Those two are deliberately different under test — the route fixtures
|
|
866
|
+
* inject `codexPromptPaths` at a temp root while CODEX_HOME points at a decoy — and
|
|
867
|
+
* the probe renders whatever lives in the home it actually runs in. Deriving the
|
|
868
|
+
* path from the injected config would name a file the probe never reads, which is
|
|
869
|
+
* a fingerprint that cannot fail rather than evidence.
|
|
870
|
+
*
|
|
871
|
+
* A BOUNDED invalidation key, not prompt identity. It covers opencodex-managed writes,
|
|
872
|
+
* the selected base prompt, the project documents Codex would discover from this home,
|
|
873
|
+
* and each skill's manifest. Plugin manifests, live MCP availability, and the clock
|
|
874
|
+
* also move the rendered prompt and are not files this process can name.
|
|
875
|
+
*
|
|
876
|
+
* The distinction is worth stating exactly, because the obvious phrasing is wrong: for
|
|
877
|
+
* a COVERED input the key moves and a late caller is refused with `busy`. For an
|
|
878
|
+
* UNCOVERED one the key does not move, so a late caller joins and reads the older
|
|
879
|
+
* rendering. That is the residual, bounded to one in-flight window in a read-only view.
|
|
880
|
+
*
|
|
881
|
+
* "Hash every input" is only closable against a pinned Codex — the dependency graph is
|
|
882
|
+
* upstream's and moves on its own. An enumeration-free alternative exists (admit only
|
|
883
|
+
* when the probe started after the request arrived) and is recorded in the plan; it
|
|
884
|
+
* costs the coalescing this work exists to provide unless arrivals are batched first.
|
|
885
|
+
* See devlog/_plan/260829_bugpr_lane_h_residual_issues/130_pr2872_probe_fingerprint.md.
|
|
886
|
+
*/
|
|
887
|
+
export function computePromptProbeStateFingerprint(opts?: Paths): string {
|
|
888
|
+
const configBytes = readFileOrNull(activeConfigPath(opts));
|
|
889
|
+
const storeBytes = readFileOrNull(activeStorePath(opts));
|
|
890
|
+
const variants = readBaseVariants(opts);
|
|
891
|
+
const selection = resolveBaseSelection(configBytes, variants, opts);
|
|
892
|
+
const hash = createHash("sha256");
|
|
893
|
+
updateFingerprintField(hash, "revision", computeRevision(configBytes, storeBytes));
|
|
894
|
+
updateFingerprintField(hash, "selected-base", selection.kind === "variant" ? `variant:${selection.id}` : selection.kind);
|
|
895
|
+
if (selection.kind === "variant") {
|
|
896
|
+
updateFingerprintField(hash, "variant-bytes", readFileOrNull(join(activeBaseVariantDir(opts), `${selection.id}.md`)));
|
|
897
|
+
}
|
|
898
|
+
if (selection.kind === "external") {
|
|
899
|
+
// The selected base file is hashed whether or not we manage it. Hashing the
|
|
900
|
+
// managed variant's bytes while recording an external selection as the bare
|
|
901
|
+
// word "external" would make the guarantee depend on who authored the file,
|
|
902
|
+
// which is not a distinction the probe's caller can see.
|
|
903
|
+
//
|
|
904
|
+
// Its path is part of the identity as well as its contents: pointing the key
|
|
905
|
+
// at a different file changes the prompt even when both files read alike.
|
|
906
|
+
updateFingerprintField(hash, "external-path", selection.path);
|
|
907
|
+
let externalBytes: string | null = null;
|
|
908
|
+
try {
|
|
909
|
+
// Relative to the CONFIG FILE's directory, which is what Codex does with its
|
|
910
|
+
// relative path fields. resolve() alone would use this process's cwd — the
|
|
911
|
+
// proxy's working directory, which has nothing to do with either the config
|
|
912
|
+
// or the probe child's cwd — and would hash an unrelated file.
|
|
913
|
+
externalBytes = readFileOrNull(resolve(dirname(activeConfigPath(opts)), expandUserPath(selection.path)));
|
|
914
|
+
} catch {
|
|
915
|
+
// An unresolvable path is a state, not a failure: it hashes as absent, and
|
|
916
|
+
// resolveBaseSelection has already reported the selection as external.
|
|
917
|
+
externalBytes = null;
|
|
918
|
+
}
|
|
919
|
+
updateFingerprintField(hash, "external-bytes", externalBytes);
|
|
920
|
+
}
|
|
921
|
+
// Codex prefers AGENTS.override.md over AGENTS.md, so both spellings are hashed
|
|
922
|
+
// in that order: an override edit changes the rendered project document exactly
|
|
923
|
+
// as a plain edit does.
|
|
924
|
+
const probeHome = resolveCodexHomeDir();
|
|
925
|
+
const filenames = probeInstructionFilenames(configBytes);
|
|
926
|
+
for (const dir of probeProjectDocDirs(probeHome, configBytes)) {
|
|
927
|
+
for (const name of filenames) {
|
|
928
|
+
// The path goes in the CONTENTS, never in the field name. Only contents are
|
|
929
|
+
// length-framed, so a name built from a path would reintroduce exactly the
|
|
930
|
+
// ambiguity this helper exists to remove. Path and bytes are separate fields
|
|
931
|
+
// because two directories in the walk can both hold an AGENTS.md.
|
|
932
|
+
const path = join(dir, name);
|
|
933
|
+
updateFingerprintField(hash, "doc-path", path);
|
|
934
|
+
updateFingerprintField(hash, "doc-bytes", readFileOrNull(path));
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
for (const path of probeSkillManifests(probeHome)) {
|
|
938
|
+
updateFingerprintField(hash, "skill-path", path);
|
|
939
|
+
updateFingerprintField(hash, "skill-bytes", readFileOrNull(path));
|
|
940
|
+
}
|
|
941
|
+
return `sha256:${hash.digest("hex")}`;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/**
|
|
945
|
+
* `SKILL.md` manifests under the home's skills directory.
|
|
946
|
+
*
|
|
947
|
+
* These were written off as unobservable in an earlier version of this function's
|
|
948
|
+
* comment. They are not: Codex reads each manifest's frontmatter and renders its
|
|
949
|
+
* description into `<skills_instructions>`, and a review round demonstrated a live
|
|
950
|
+
* description edit changing the probe's output while the fingerprint stood still.
|
|
951
|
+
*
|
|
952
|
+
* One directory listing plus one `readFileOrNull` per skill, beside a subprocess that
|
|
953
|
+
* costs orders of magnitude more. Sorted, because `readdirSync` order is not a
|
|
954
|
+
* contract and a digest must not depend on it.
|
|
955
|
+
*
|
|
956
|
+
* Only the top-level manifest per skill is read. A skill's bundled scripts and
|
|
957
|
+
* references do not reach the rendered section, so hashing the whole tree would buy
|
|
958
|
+
* redundant invalidations at a real cost on large skill sets.
|
|
959
|
+
*/
|
|
960
|
+
function probeSkillManifests(home: string): string[] {
|
|
961
|
+
const root = join(home, "skills");
|
|
962
|
+
let entries: string[];
|
|
963
|
+
try {
|
|
964
|
+
entries = readdirSync(root);
|
|
965
|
+
} catch {
|
|
966
|
+
return [];
|
|
967
|
+
}
|
|
968
|
+
const manifests: string[] = [];
|
|
969
|
+
for (const entry of entries.sort()) {
|
|
970
|
+
const manifest = join(root, entry, "SKILL.md");
|
|
971
|
+
if (existsSync(manifest)) manifests.push(manifest);
|
|
972
|
+
}
|
|
973
|
+
return manifests;
|
|
974
|
+
}
|
|
975
|
+
|
|
637
976
|
// ---------------------------------------------------------------------------
|
|
638
977
|
// Writing
|
|
639
978
|
// ---------------------------------------------------------------------------
|