@bitkyc08/opencodex 2.16.0 → 2.17.0

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.
@@ -9,6 +9,7 @@
9
9
  *
10
10
  * Design of record: devlog/_fin/260802_client_toggle_api/030 and 031.
11
11
  */
12
+ import { homedir } from "node:os";
12
13
  import { dirname } from "node:path";
13
14
  import { EXPORT_CLIENTS, type ExportModel, type ManagedContribution } from "../clients/config-export";
14
15
  import { isLoopbackHostname } from "../codex/inject";
@@ -23,7 +24,8 @@ import { serializeDocument, UnserializableValueError } from "./serialize";
23
24
  import { ClientPathError } from "../clients/config-export";
24
25
  import { matchesOperationResult, newOpId, type JournalEntry } from "./journal";
25
26
  import { createIntegrationStateStore, type IntegrationStateStore } from "./store";
26
- import { patchOmpYamlSource } from "./omp-yaml-source";
27
+ import { patchYamlFragmentSource, sourcePrunableYamlContainers } from "./omp-yaml-source";
28
+ import { withIntegrationWriterLock, type IntegrationWriterLockSeams } from "./writer-lock";
27
29
 
28
30
  export type RefusalReason =
29
31
  | "not_installed"
@@ -66,6 +68,8 @@ export interface IntegrationWriteInput {
66
68
  home?: string;
67
69
  store?: IntegrationStateStore;
68
70
  io?: IntegrationIO;
71
+ /** Frozen once by the async coordinator; synchronous callers may omit it. */
72
+ resolvedPaths?: { configPath: string; detectDir: string };
69
73
  }
70
74
 
71
75
  export interface IntegrationRestoreInput extends IntegrationWriteInput {
@@ -169,11 +173,13 @@ function snapshotAbsPath(store: IntegrationStateStore, entry: JournalEntry): str
169
173
  return snapshot.kind === "stored" ? snapshot.path : undefined;
170
174
  }
171
175
 
172
- function ompFragmentValue(contribution: ManagedContribution): unknown | undefined {
176
+ function sourcePreservingFragmentValue(
177
+ contribution: ManagedContribution,
178
+ path: readonly string[],
179
+ ): unknown | undefined {
173
180
  const fragment = contribution.fragments.find(item => (
174
- item.path.length === 2
175
- && item.path[0] === "providers"
176
- && item.path[1] === "opencodex"
181
+ item.path.length === path.length
182
+ && item.path.every((key, index) => key === path[index])
177
183
  ));
178
184
  return fragment?.value;
179
185
  }
@@ -194,7 +200,7 @@ function preflight(input: IntegrationWriteInput) {
194
200
  */
195
201
  let configPath: string;
196
202
  try {
197
- configPath = spec.configPath(input.env, input.home);
203
+ configPath = input.resolvedPaths?.configPath ?? spec.configPath(input.env, input.home);
198
204
  } catch (error) {
199
205
  if (!(error instanceof ClientPathError)) throw error;
200
206
  return { failed: refuse(clientId, "unsafe", "unsafe", error.message) } as const;
@@ -239,7 +245,7 @@ export function applyIntegration(input: IntegrationWriteInput): WriteOutcome {
239
245
  if (pre.failed) return pre.failed;
240
246
  const { store, io, clientId, spec, exportSpec, configPath, before, parsed, contribution, record, classified } = pre;
241
247
 
242
- if (io.statKind(spec.detectDir(input.env, input.home)) !== "dir") {
248
+ if (io.statKind(input.resolvedPaths?.detectDir ?? spec.detectDir(input.env, input.home)) !== "dir") {
243
249
  return refuse(clientId, "not_installed", "absent", `${clientId} is not installed`);
244
250
  }
245
251
  if (isLoopbackOnly(clientId) && !isLoopbackHostname(input.config.hostname)) {
@@ -294,11 +300,16 @@ export function applyIntegration(input: IntegrationWriteInput): WriteOutcome {
294
300
  const nextDocument = mergeContribution(base, contribution);
295
301
  let text: string;
296
302
  try {
297
- if (clientId === "omp" && before !== null) {
298
- const value = ompFragmentValue(contribution);
303
+ if (spec.sourcePreservingYaml && before !== null) {
304
+ const value = sourcePreservingFragmentValue(contribution, spec.sourcePreservingYaml.path);
299
305
  const patched = value === undefined
300
306
  ? null
301
- : patchOmpYamlSource(before, { kind: "upsert", value }, nextDocument);
307
+ : patchYamlFragmentSource(
308
+ before,
309
+ spec.sourcePreservingYaml.path,
310
+ { kind: "upsert", value },
311
+ nextDocument,
312
+ );
302
313
  if (patched === null) {
303
314
  return refuse(clientId, "unsafe", "unsafe",
304
315
  `${configPath} uses YAML source opencodex cannot patch without risking unrelated comments or formatting, so it was left alone`);
@@ -344,7 +355,7 @@ export function applyIntegration(input: IntegrationWriteInput): WriteOutcome {
344
355
  export function disableIntegration(input: IntegrationWriteInput): WriteOutcome {
345
356
  const pre = preflight(input);
346
357
  if (pre.failed) return pre.failed;
347
- const { store, io, clientId, exportSpec, configPath, before, parsed, record, classified } = pre;
358
+ const { store, io, clientId, spec, exportSpec, configPath, before, parsed, record, classified } = pre;
348
359
 
349
360
  if (classified.state === "absent") {
350
361
  return { ok: true, changed: false, state: "absent", clientId, message: "not applied" };
@@ -377,21 +388,32 @@ export function disableIntegration(input: IntegrationWriteInput): WriteOutcome {
377
388
  * json client classifies a sibling edit as stale (#1631) — which is why the
378
389
  * removal runs against the document as parsed NOW, and the re-serialize is
379
390
  * value-safe because non-round-tripping numbers were refused at parse time.
391
+ * Source-preserving YAML clients additionally compute which recorded
392
+ * containers are still source-empty before pruning, so a later sibling or
393
+ * comment makes its ancestor user-owned without protecting our leaf.
380
394
  */
395
+ const recordedCreated = record!.createdContainers ?? [];
396
+ const prunableCreated = spec.sourcePreservingYaml && before !== null
397
+ ? sourcePrunableYamlContainers(before, spec.sourcePreservingYaml.path, recordedCreated)
398
+ : recordedCreated;
399
+ if (prunableCreated === null) {
400
+ return refuse(clientId, "unsafe", "unsafe",
401
+ `${configPath} uses YAML source opencodex cannot patch without risking unrelated comments or formatting, so nothing was removed`);
402
+ }
381
403
  const { doc, removed } = removeFragments(
382
404
  parsed,
383
405
  record!.fragmentPaths,
384
- new Set(record!.createdContainers ?? []),
406
+ new Set(prunableCreated),
385
407
  );
386
408
  if (!removed) {
387
409
  return { ok: true, changed: false, state: "absent", clientId, message: "nothing to remove" };
388
410
  }
389
411
  let text: string;
390
412
  try {
391
- if (clientId === "omp" && before !== null) {
392
- const patched = patchOmpYamlSource(before, {
413
+ if (spec.sourcePreservingYaml && before !== null) {
414
+ const patched = patchYamlFragmentSource(before, spec.sourcePreservingYaml.path, {
393
415
  kind: "remove",
394
- removeEmptyProviders: record!.createdContainers?.includes("providers") === true,
416
+ createdContainers: prunableCreated,
395
417
  }, doc);
396
418
  if (patched === null) {
397
419
  return refuse(clientId, "unsafe", "unsafe",
@@ -435,7 +457,8 @@ export function restoreIntegration(input: IntegrationRestoreInput): WriteOutcome
435
457
  if (entry.clientId !== input.clientId) throw new Error("restore input names a different client than the operation");
436
458
 
437
459
  const clientId = entry.clientId;
438
- const resolvedPath = INTEGRATION_CLIENTS[clientId].configPath(input.env, input.home);
460
+ const resolvedPath = input.resolvedPaths?.configPath
461
+ ?? INTEGRATION_CLIENTS[clientId].configPath(input.env, input.home);
439
462
  // Restore acts on the path the operation was journaled against. Resolving a
440
463
  // different path here would let an operation recorded for one home delete a
441
464
  // file in another.
@@ -533,3 +556,105 @@ export function restoreIntegration(input: IntegrationRestoreInput): WriteOutcome
533
556
  snapshotPath: snapshotAbsPath(store, restoreEntry),
534
557
  });
535
558
  }
559
+
560
+ export interface CoordinatedIntegrationOptions {
561
+ lockSeams?: IntegrationWriterLockSeams;
562
+ }
563
+
564
+ /** Freeze all mutable resolution seams before the first lock await. */
565
+ type FrozenIntegrationInput = IntegrationWriteInput & {
566
+ store: IntegrationStateStore;
567
+ io: IntegrationIO;
568
+ env: NodeJS.ProcessEnv;
569
+ home: string;
570
+ resolvedPaths: { configPath: string; detectDir: string };
571
+ };
572
+
573
+ function freezeIntegrationInput(input: IntegrationWriteInput): FrozenIntegrationInput {
574
+ const env = { ...(input.env ?? process.env) };
575
+ const home = input.home ?? homedir();
576
+ const store = input.store ?? createIntegrationStateStore();
577
+ const io = input.io ?? defaultIntegrationIO(store);
578
+ const spec = INTEGRATION_CLIENTS[input.clientId];
579
+ const resolvedPaths = {
580
+ configPath: spec.configPath(env, home),
581
+ detectDir: spec.detectDir(env, home),
582
+ };
583
+ return { ...input, env, home, store, io, resolvedPaths };
584
+ }
585
+
586
+ function tryFreezeIntegrationInput(input: IntegrationWriteInput):
587
+ | { ok: true; value: FrozenIntegrationInput }
588
+ | { ok: false; refusal: WriteRefused } {
589
+ try {
590
+ return { ok: true, value: freezeIntegrationInput(input) };
591
+ } catch (error) {
592
+ if (!(error instanceof ClientPathError)) throw error;
593
+ return {
594
+ ok: false,
595
+ refusal: refuse(input.clientId, "unsafe", "unsafe", error.message),
596
+ };
597
+ }
598
+ }
599
+
600
+ async function coordinatedWrite(
601
+ input: IntegrationWriteInput,
602
+ operation: (frozen: IntegrationWriteInput) => WriteOutcome,
603
+ options?: CoordinatedIntegrationOptions,
604
+ ): Promise<WriteOutcome> {
605
+ const prepared = tryFreezeIntegrationInput(input);
606
+ if (!prepared.ok) return prepared.refusal;
607
+ const frozen = prepared.value;
608
+ const spec = INTEGRATION_CLIENTS[frozen.clientId];
609
+ if (!spec.writerLock) return operation(frozen);
610
+
611
+ // An absent client home is not created merely to acquire a sibling lock.
612
+ if (frozen.io.statKind(frozen.resolvedPaths.detectDir) !== "dir") {
613
+ return operation(frozen);
614
+ }
615
+ return withIntegrationWriterLock(
616
+ frozen.resolvedPaths.configPath,
617
+ async () => operation(frozen),
618
+ options?.lockSeams,
619
+ spec.writerLock.suffix,
620
+ );
621
+ }
622
+
623
+ export function applyIntegrationCoordinated(
624
+ input: IntegrationWriteInput,
625
+ options?: CoordinatedIntegrationOptions,
626
+ ): Promise<WriteOutcome> {
627
+ return coordinatedWrite(input, applyIntegration, options);
628
+ }
629
+
630
+ export function disableIntegrationCoordinated(
631
+ input: IntegrationWriteInput,
632
+ options?: CoordinatedIntegrationOptions,
633
+ ): Promise<WriteOutcome> {
634
+ return coordinatedWrite(input, disableIntegration, options);
635
+ }
636
+
637
+ export async function restoreIntegrationCoordinated(
638
+ input: IntegrationRestoreInput,
639
+ options?: CoordinatedIntegrationOptions,
640
+ ): Promise<WriteOutcome> {
641
+ const prepared = tryFreezeIntegrationInput(input);
642
+ if (!prepared.ok) return prepared.refusal;
643
+ const frozen = prepared.value;
644
+ const spec = INTEGRATION_CLIENTS[frozen.clientId];
645
+ if (!spec.writerLock) return restoreIntegration({ ...frozen, opId: input.opId, confirmDrift: input.confirmDrift });
646
+ if (frozen.io.statKind(frozen.resolvedPaths.detectDir) !== "dir") {
647
+ return refuse(
648
+ frozen.clientId,
649
+ "unsafe",
650
+ "unsafe",
651
+ `${frozen.resolvedPaths.detectDir} is missing; restore will not create the client home`,
652
+ );
653
+ }
654
+ return withIntegrationWriterLock(
655
+ frozen.resolvedPaths.configPath,
656
+ async () => restoreIntegration({ ...frozen, opId: input.opId, confirmDrift: input.confirmDrift }),
657
+ options?.lockSeams,
658
+ spec.writerLock.suffix,
659
+ );
660
+ }
@@ -32,23 +32,15 @@ export function isShadowSourceModel(modelId: string, configured?: unknown): bool
32
32
  /**
33
33
  * Decide whether a matching source model should use the opt-in intercept.
34
34
  *
35
- * Codex 0.145.0+ identifies normal user turns and maintenance requests in
36
- * x-codex-turn-metadata. Only an explicit normal turn bypasses interception;
37
- * missing or unrecognized metadata retains the legacy opt-in prefix behavior.
35
+ * Before Codex 0.147.0 this checked x-codex-turn-metadata and exempted
36
+ * request_kind "turn". Codex 0.147.0 can label background helper calls as
37
+ * "turn", causing them to bypass the intercept (#1684). The fix is to
38
+ * intercept every configured shadow source model unconditionally — the model
39
+ * slug alone is a sufficient signal.
38
40
  */
39
41
  export function shouldInterceptShadowCall(
40
42
  modelId: string,
41
43
  configured: unknown,
42
- headers: Headers,
43
44
  ): boolean {
44
- if (!isShadowSourceModel(modelId, configured)) return false;
45
- const rawMetadata = headers.get("x-codex-turn-metadata");
46
- if (rawMetadata === null) return true;
47
-
48
- try {
49
- const parsed = JSON.parse(rawMetadata) as { request_kind?: unknown };
50
- return parsed?.request_kind !== "turn";
51
- } catch {
52
- return true;
53
- }
45
+ return isShadowSourceModel(modelId, configured);
54
46
  }
@@ -19,13 +19,14 @@ import {
19
19
  import { readIntegrationState } from "../../integrations/state";
20
20
  import { createIntegrationStateStore, type IntegrationStateStore } from "../../integrations/store";
21
21
  import {
22
- applyIntegration,
23
- disableIntegration,
24
- restoreIntegration,
22
+ applyIntegrationCoordinated,
23
+ disableIntegrationCoordinated,
24
+ restoreIntegrationCoordinated,
25
25
  type IntegrationRestoreInput,
26
26
  type IntegrationWriteInput,
27
27
  type WriteRefused,
28
28
  } from "../../integrations/writer";
29
+ import { IntegrationWriterLockBusyError, type IntegrationWriterLockSeams } from "../../integrations/writer-lock";
29
30
  import { jsonResponse } from "../auth-cors";
30
31
  import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body";
31
32
  import type { ManagementContext } from "./context";
@@ -37,9 +38,9 @@ const INTEGRATION_MUTATION_JOIN_MS = 120_000;
37
38
  export const INTEGRATION_MUTATION_TERMINAL_MS = 10 * 60_000;
38
39
 
39
40
  type IntegrationStateRecord = Awaited<ReturnType<typeof readIntegrationState>>;
40
- type ApplyResult = Awaited<ReturnType<typeof applyIntegration>>;
41
- type DisableResult = Awaited<ReturnType<typeof disableIntegration>>;
42
- type RestoreResult = Awaited<ReturnType<typeof restoreIntegration>>;
41
+ type ApplyResult = Awaited<ReturnType<typeof applyIntegrationCoordinated>>;
42
+ type DisableResult = Awaited<ReturnType<typeof disableIntegrationCoordinated>>;
43
+ type RestoreResult = Awaited<ReturnType<typeof restoreIntegrationCoordinated>>;
43
44
 
44
45
  export type IntegrationStateEnvelope = {
45
46
  clientId: IntegrationClientId;
@@ -95,6 +96,7 @@ class IntegrationMutationBusyError extends Error {
95
96
  const integrationMutationFlights = new Map<IntegrationClientId, IntegrationMutationFlight>();
96
97
  let integrationMutationTestHooks: {
97
98
  io?: IntegrationIO;
99
+ lockSeams?: IntegrationWriterLockSeams;
98
100
  /**
99
101
  * Bind every read and write in the request to one store. Without this a
100
102
  * route test could isolate the writer but not the journal listing or the
@@ -185,6 +187,7 @@ function runIntegrationMutationFlight<T>(
185
187
  export function setIntegrationMutationFlightTestHooks(
186
188
  hooks: {
187
189
  io?: IntegrationIO;
190
+ lockSeams?: IntegrationWriterLockSeams;
188
191
  /** Binds the WHOLE request — reads, writes and journal — to one store. */
189
192
  store?: IntegrationStateStore;
190
193
  run?: (operation: () => Promise<unknown>) => Promise<unknown>;
@@ -433,6 +436,7 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise<R
433
436
 
434
437
  const opId = parsed.opId.trim();
435
438
  const confirmDrift = parsed.confirmDrift ?? false;
439
+ let restoreClientId: IntegrationClientId | undefined;
436
440
  try {
437
441
  const store = integrationStore();
438
442
  const operation = store.findOperation(opId);
@@ -443,6 +447,7 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise<R
443
447
  opId,
444
448
  }, 404, req, ctx.config);
445
449
  }
450
+ restoreClientId = operation.clientId;
446
451
  const snapshot = store.readSnapshot(operation);
447
452
  if (snapshot.kind === "expired") {
448
453
  return jsonResponse({
@@ -462,7 +467,9 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise<R
462
467
  operation.clientId,
463
468
  `restore:${opId}:${confirmDrift}`,
464
469
  writeInput.io?.now ?? Date.now,
465
- () => Promise.resolve(restoreIntegration(restoreInput)),
470
+ () => restoreIntegrationCoordinated(restoreInput, {
471
+ lockSeams: integrationMutationTestHooks?.lockSeams,
472
+ }),
466
473
  );
467
474
  if (!result.ok) {
468
475
  /*
@@ -478,11 +485,11 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise<R
478
485
  }
479
486
  return jsonResponse(result satisfies IntegrationRestoreEnvelope, 200, req, ctx.config);
480
487
  } catch (error) {
481
- if (error instanceof IntegrationMutationBusyError) {
488
+ if (error instanceof IntegrationMutationBusyError || error instanceof IntegrationWriterLockBusyError) {
482
489
  return jsonResponse({
483
490
  error: "integration mutation busy",
484
491
  code: "integration_mutation_busy",
485
- clientId: error.clientId,
492
+ clientId: restoreClientId ?? (error instanceof IntegrationMutationBusyError ? error.clientId : undefined),
486
493
  }, 409, req, ctx.config);
487
494
  }
488
495
  return internalErrorResponse(error, ctx);
@@ -519,18 +526,18 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise<R
519
526
  requestedClient,
520
527
  parsed.enabled ? "apply" : "disable",
521
528
  input.io?.now ?? Date.now,
522
- () => Promise.resolve(parsed.enabled
523
- ? applyIntegration(input)
524
- : disableIntegration(input)),
529
+ () => parsed.enabled
530
+ ? applyIntegrationCoordinated(input, { lockSeams: integrationMutationTestHooks?.lockSeams })
531
+ : disableIntegrationCoordinated(input, { lockSeams: integrationMutationTestHooks?.lockSeams }),
525
532
  );
526
533
  if (!result.ok) return writerFailureResponse(requestedClient, result, ctx);
527
534
  return jsonResponse(result satisfies IntegrationToggleEnvelope, 200, req, ctx.config);
528
535
  } catch (error) {
529
- if (error instanceof IntegrationMutationBusyError) {
536
+ if (error instanceof IntegrationMutationBusyError || error instanceof IntegrationWriterLockBusyError) {
530
537
  return jsonResponse({
531
538
  error: "integration mutation busy",
532
539
  code: "integration_mutation_busy",
533
- clientId: error.clientId,
540
+ clientId: requestedClient,
534
541
  }, 409, req, ctx.config);
535
542
  }
536
543
  return internalErrorResponse(error, ctx);
@@ -1582,7 +1582,6 @@ async function handleResponsesInner(
1582
1582
  if (_sci?.enabled && _sci.model && shouldInterceptShadowCall(
1583
1583
  parsed.modelId,
1584
1584
  _sci.sourceModels,
1585
- req.headers,
1586
1585
  )) {
1587
1586
  const _sciOriginal = parsed.modelId;
1588
1587
  parsed.modelId = _sci.model;
package/src/types.ts CHANGED
@@ -756,11 +756,11 @@ export interface OcxConfig {
756
756
  */
757
757
  customModelCatalogMigration?: unknown;
758
758
  /**
759
- * Shadow call intercept: redirect Codex's hard-coded helper calls (title generation,
760
- * commit messages, skill orchestration) to a user-chosen model. Default intercepted
761
- * source models: gpt-5.4-mini (older clients) and gpt-5.6-luna (Codex 0.145.0+).
762
- * Opt-in; disabled by default. Matching maintenance/helper requests are forced to low.
763
- * Normal Codex turns identified by request_kind=turn are never rewritten.
759
+ * Shadow call intercept: redirect Codex's hard-coded helper calls (title generation,
760
+ * commit messages, skill orchestration) to a user-chosen model. Default intercepted
761
+ * source models: gpt-5.4-mini (older clients) and gpt-5.6-luna (Codex 0.145.0+).
762
+ * Opt-in; disabled by default. Matching maintenance/helper requests are forced to low.
763
+ * All requests for configured shadow source models are intercepted unconditionally.
764
764
  */
765
765
  shadowCallIntercept?: {
766
766
  /** When true, requests for known shadow/helper source models are rewritten to the configured model. */