@bitkyc08/opencodex 2.37.0 → 2.39.0-preview.20260901

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.
Files changed (131) hide show
  1. package/bin/ocx.mjs +127 -16
  2. package/gui/dist/assets/index-6HZDkIpX.js +112 -0
  3. package/gui/dist/assets/index-uvENYLin.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/gui/dist/provider-icons/aside.svg +3 -0
  6. package/gui/dist/provider-icons/baseten.svg +13 -0
  7. package/gui/dist/provider-icons/bizrouter.svg +41 -0
  8. package/gui/dist/provider-icons/cerebras.svg +26 -0
  9. package/gui/dist/provider-icons/deepinfra.svg +75 -0
  10. package/gui/dist/provider-icons/deepseek-harness.svg +3 -0
  11. package/gui/dist/provider-icons/digitalocean.svg +10 -0
  12. package/gui/dist/provider-icons/featherless.svg +4 -0
  13. package/gui/dist/provider-icons/gajae-code.svg +410 -0
  14. package/gui/dist/provider-icons/hermes-agent.svg +207 -0
  15. package/gui/dist/provider-icons/hyperbolic.svg +18 -0
  16. package/gui/dist/provider-icons/kilo.svg +13 -0
  17. package/gui/dist/provider-icons/litellm.svg +1 -0
  18. package/gui/dist/provider-icons/minimax.svg +1 -0
  19. package/gui/dist/provider-icons/nanogpt.svg +74 -0
  20. package/gui/dist/provider-icons/nebius.svg +1 -0
  21. package/gui/dist/provider-icons/neuralwatt.svg +27 -0
  22. package/gui/dist/provider-icons/nous.svg +149 -0
  23. package/gui/dist/provider-icons/novita.svg +32 -0
  24. package/gui/dist/provider-icons/oh-my-pi.svg +11 -0
  25. package/gui/dist/provider-icons/openclaw.svg +54 -0
  26. package/gui/dist/provider-icons/orcarouter.svg +175 -0
  27. package/gui/dist/provider-icons/parallel.svg +13 -0
  28. package/gui/dist/provider-icons/prime-agent.svg +21 -0
  29. package/gui/dist/provider-icons/sambanova.svg +276 -0
  30. package/gui/dist/provider-icons/scaleway.svg +11 -0
  31. package/gui/dist/provider-icons/siliconflow.svg +18 -0
  32. package/gui/dist/provider-icons/synthetic.svg +12 -0
  33. package/gui/dist/provider-icons/together.svg +18 -0
  34. package/gui/dist/provider-icons/umans.svg +30 -0
  35. package/gui/dist/provider-icons/venice.svg +165 -0
  36. package/gui/dist/provider-icons/vultr.svg +15 -0
  37. package/gui/dist/provider-icons/zai.svg +218 -0
  38. package/gui/dist/provider-icons/zcode.svg +219 -0
  39. package/gui/dist/provider-icons/zenmux.svg +1 -0
  40. package/package.json +1 -1
  41. package/src/adapters/cursor/live-models.ts +1 -0
  42. package/src/adapters/cursor/protobuf-request.ts +4 -1
  43. package/src/adapters/cursor/tool-definitions.ts +36 -4
  44. package/src/adapters/openai-responses.ts +72 -10
  45. package/src/bridge.ts +15 -15
  46. package/src/cli/capabilities.ts +14 -0
  47. package/src/cli/codex-cli-update.ts +96 -0
  48. package/src/cli/codex-shim-autorestore.ts +3 -0
  49. package/src/cli/dispatch.ts +54 -18
  50. package/src/cli/export-command.ts +18 -17
  51. package/src/cli/help.ts +2 -2
  52. package/src/cli/index.ts +423 -32
  53. package/src/cli/integrations.ts +24 -2
  54. package/src/cli/launcher-context.ts +53 -2
  55. package/src/cli/opencode.ts +126 -33
  56. package/src/cli/registry.ts +16 -10
  57. package/src/cli/system-command.ts +6 -1
  58. package/src/cli/uninstall-plan.ts +86 -0
  59. package/src/clients/config-export.ts +293 -28
  60. package/src/codex/account-store.ts +131 -12
  61. package/src/codex/auth-api.ts +202 -33
  62. package/src/codex/autostart-health.ts +3 -3
  63. package/src/codex/catalog/provider-fetch.ts +96 -10
  64. package/src/codex/catalog/sync.ts +4 -3
  65. package/src/codex/cli-install-provenance.ts +795 -0
  66. package/src/codex/convergence.ts +4 -3
  67. package/src/codex/credential-mutation-epoch.ts +11 -0
  68. package/src/codex/history-job.ts +10 -0
  69. package/src/codex/history-manifest.ts +35 -2
  70. package/src/codex/history-provider.ts +196 -19
  71. package/src/codex/history-worker.ts +3 -0
  72. package/src/codex/main-account.ts +2 -0
  73. package/src/codex/model-entitlements.ts +430 -27
  74. package/src/codex/native-profile-manager.ts +4 -0
  75. package/src/codex/quota-401-recovery.ts +190 -0
  76. package/src/codex/quota-recovery-timing.ts +28 -0
  77. package/src/codex/quota.ts +6 -0
  78. package/src/codex/reset-credit-operation-ledger.ts +1411 -0
  79. package/src/codex/reset-credit-recovery.ts +20 -2
  80. package/src/codex/routing.ts +68 -16
  81. package/src/codex/shim.ts +204 -18
  82. package/src/codex/subagent-model-fallback.ts +4 -1
  83. package/src/codex/user-identity.ts +2 -1
  84. package/src/config/paths.ts +18 -3
  85. package/src/config/pending-teardown-names.d.mts +8 -0
  86. package/src/config/pending-teardown-names.mjs +69 -0
  87. package/src/config/pending-teardown.ts +286 -0
  88. package/src/config.ts +23 -0
  89. package/src/generated/compatibility-version.json +155 -67
  90. package/src/integrations/journal.ts +12 -1
  91. package/src/integrations/registry.ts +112 -0
  92. package/src/integrations/state.ts +67 -5
  93. package/src/integrations/writer.ts +107 -16
  94. package/src/lib/bounded-subprocess.ts +36 -0
  95. package/src/lib/process-control.ts +30 -5
  96. package/src/lib/state-store-registrations.ts +8 -0
  97. package/src/lib/strict-semver.ts +47 -0
  98. package/src/lib/windows-elevation.ts +32 -1
  99. package/src/lib/windows-secret-acl.ts +47 -25
  100. package/src/lib/windows-service-mutation-lock.ts +133 -0
  101. package/src/lib/windows-user-principal.ts +15 -17
  102. package/src/oauth/index.ts +220 -13
  103. package/src/oauth/store.ts +220 -18
  104. package/src/responses/spill-store.ts +354 -29
  105. package/src/responses/state.ts +775 -8
  106. package/src/server/index.ts +4 -3
  107. package/src/server/lifecycle.ts +5 -1
  108. package/src/server/management/integration-routes.ts +36 -5
  109. package/src/server/management/model-rows.ts +11 -2
  110. package/src/server/management/provider-routes.ts +4 -0
  111. package/src/server/management/system-restart.ts +5 -5
  112. package/src/server/management-api.ts +72 -13
  113. package/src/server/responses/encrypted-payload.ts +18 -1
  114. package/src/server/startup-action-control.ts +3 -2
  115. package/src/server/stop-teardown.ts +84 -0
  116. package/src/service.ts +749 -53
  117. package/src/sidecar/candidates.ts +1 -1
  118. package/src/update/codex-cli-update-launch-policy.d.mts +18 -0
  119. package/src/update/codex-cli-update-launch-policy.mjs +30 -0
  120. package/src/update/index.ts +39 -6
  121. package/src/update/job.ts +10 -11
  122. package/src/update/proxy-liveness-probe.d.mts +6 -0
  123. package/src/update/proxy-liveness-probe.mjs +84 -0
  124. package/src/update/stop-contract.d.mts +2 -0
  125. package/src/update/stop-contract.mjs +15 -0
  126. package/src/update/stop-decision.d.mts +10 -0
  127. package/src/update/stop-decision.mjs +34 -0
  128. package/src/vision/eligibility.ts +19 -1
  129. package/src/vision/index.ts +4 -18
  130. package/gui/dist/assets/index-CowztZdo.js +0 -112
  131. package/gui/dist/assets/index-jqE_VOKI.css +0 -1
@@ -19,7 +19,18 @@ import { atomicWriteFile } from "../config";
19
19
  import { ensureDir, fingerprint, integrationsDir, type OwnershipRecord } from "./ownership";
20
20
  import { isIntegrationClientId, type IntegrationClientId } from "./registry";
21
21
 
22
- export type OperationKind = "apply" | "disable" | "refresh" | "restore";
22
+ /**
23
+ * `overwrite` is deliberately distinct from `apply`. Both write our block, but
24
+ * only one of them replaced something the user or another tool had put there,
25
+ * and the rollback list is exactly where that distinction matters.
26
+ *
27
+ * This union is re-declared, not imported, in two other places -- the management
28
+ * route envelope and the GUI adapter -- because neither imports across that
29
+ * boundary. `tests/integrations-journal.test.ts` asserts the three agree, since
30
+ * nothing else can: a kind persisted here and missing there renders as a raw
31
+ * key with no type error anywhere.
32
+ */
33
+ export type OperationKind = "apply" | "disable" | "refresh" | "restore" | "overwrite";
23
34
 
24
35
  /**
25
36
  * Tagged so "the file did not exist" and "the snapshot was collected" stay
@@ -11,7 +11,11 @@
11
11
  import { homedir } from "node:os";
12
12
  import { join } from "node:path";
13
13
  import {
14
+ ClientPathError,
14
15
  EXPORT_CLIENTS,
16
+ asideAccountDir,
17
+ asideConfigPath,
18
+ asideHomeDir,
15
19
  dshConfigPath,
16
20
  dshHomeDir,
17
21
  gajaeConfigPath,
@@ -52,6 +56,86 @@ export interface IntegrationClientSpec {
52
56
  sourcePreservingYaml?: { path: readonly string[] };
53
57
  /** Coordinate the complete mutation through a sibling config lock. */
54
58
  writerLock?: { suffix: ".lock" };
59
+ /**
60
+ * Derive the config path AND the detect directory from one resolution, for a
61
+ * client whose paths depend on mutable state rather than only env and home.
62
+ *
63
+ * Only Aside needs this. Its two paths both come from the account id in
64
+ * `accounts.json`, so calling `configPath` and `detectDir` in sequence can
65
+ * straddle an account switch and check one account's install while writing
66
+ * another's catalog. Reading the id once and deriving both paths from it
67
+ * removes the window instead of narrowing it.
68
+ */
69
+ resolvePaths?: (env?: NodeJS.ProcessEnv, home?: string) => { configPath: string; detectDir: string };
70
+ /**
71
+ * Where the client's config WOULD live, for a client whose real path cannot
72
+ * be resolved yet.
73
+ *
74
+ * Only a client with `resolvePaths` needs this, and only because that
75
+ * resolution can legitimately fail on a machine where the client has never
76
+ * run. Aside's account id comes from a manifest the app writes at first
77
+ * launch, so a never-signed-in install has no account directory and no id --
78
+ * which is "not installed", not "we cannot verify this file".
79
+ *
80
+ * The value is a location to SHOW, never a location to write: it names the
81
+ * account root without an account, so it cannot be mistaken for a real
82
+ * catalog. `resolveIntegrationPaths` still throws for callers that mutate.
83
+ */
84
+ unresolvedPathHint?: (env?: NodeJS.ProcessEnv, home?: string) => string;
85
+ }
86
+
87
+ /**
88
+ * The one place that turns a client id into the pair of paths an operation uses.
89
+ *
90
+ * A caller that resolves `configPath` and `detectDir` separately is correct for
91
+ * every client whose paths are a pure function of env and home, and wrong for
92
+ * one that reads mutable state. Routing both through here lets such a client fix
93
+ * that for itself without every call site learning why.
94
+ */
95
+ export function resolveIntegrationPaths(
96
+ clientId: IntegrationClientId,
97
+ env: NodeJS.ProcessEnv = process.env,
98
+ home: string = homedir(),
99
+ ): { configPath: string; detectDir: string } {
100
+ const spec = INTEGRATION_CLIENTS[clientId];
101
+ if (spec.resolvePaths) return spec.resolvePaths(env, home);
102
+ return { configPath: spec.configPath(env, home), detectDir: spec.detectDir(env, home) };
103
+ }
104
+
105
+ /**
106
+ * The location to name when resolution refused, or `""` when there is none.
107
+ *
108
+ * A read-only surface reporting "unresolvable" with an empty path told the user
109
+ * nothing they could act on, and for Aside it also reported the wrong thing: an
110
+ * absent account manifest is the ordinary state of an installed-but-never-run
111
+ * Aside, and the honest answer there is that it is not signed in.
112
+ *
113
+ * `""` is a sentinel, not a path: it is what `readIntegrationState` reads to
114
+ * decide between not-installed and cannot-verify. A config path is never
115
+ * legitimately empty, and a hint is always an absolute `join` result, so the two
116
+ * cannot be confused.
117
+ */
118
+ export function unresolvedPathHintFor(
119
+ clientId: IntegrationClientId,
120
+ env: NodeJS.ProcessEnv = process.env,
121
+ home: string = homedir(),
122
+ ): string {
123
+ const spec = INTEGRATION_CLIENTS[clientId];
124
+ if (!spec.unresolvedPathHint) return "";
125
+ try {
126
+ return spec.unresolvedPathHint(env, home);
127
+ } catch (error) {
128
+ /*
129
+ * Only a path refusal is absorbed. An unqualified catch here would also
130
+ * swallow a TypeError from a future implementor's typo, an
131
+ * ERR_INVALID_ARG_TYPE out of `join`, or an EACCES from a resolver that
132
+ * touches the filesystem -- turning a programming error into a silently
133
+ * degraded badge. `readIntegrationState` narrows the same way at its own
134
+ * catch, and this is the matching half.
135
+ */
136
+ if (!(error instanceof ClientPathError)) throw error;
137
+ return "";
138
+ }
55
139
  }
56
140
 
57
141
  /**
@@ -149,6 +233,34 @@ export const INTEGRATION_CLIENTS: Record<IntegrationClientId, IntegrationClientS
149
233
  // is set. Same choice as OMP, whose detect signal is `ompAgentDir`.
150
234
  detectDir: (env = process.env, home = homedir()) => primeAgentDir(env, home),
151
235
  },
236
+ aside: {
237
+ id: "aside",
238
+ configPath: (env = process.env, home = homedir()) => asideConfigPath(env, home),
239
+ /*
240
+ * The ACCOUNT directory, not `~/.aside`. Aside's CLI creates `~/.aside/cli`
241
+ * for its own update check before any account exists, so the outer directory
242
+ * is present on a machine that never signed in, and writing a catalog for an
243
+ * account that does not exist is worse than reporting absent.
244
+ */
245
+ detectDir: (env = process.env, home = homedir()) => asideAccountDir(env, home),
246
+ /*
247
+ * Both paths from ONE account read. The two resolvers above each consult
248
+ * the account manifest, so a switch landing between them would let an
249
+ * operation verify one account's install and then write another's catalog.
250
+ */
251
+ resolvePaths: (env = process.env, home = homedir()) => {
252
+ const detectDir = asideAccountDir(env, home);
253
+ return { configPath: join(detectDir, "models.json"), detectDir };
254
+ },
255
+ /*
256
+ * The account ROOT, with no account under it. Aside writes `accounts.json`
257
+ * at first launch, so its absence is the ordinary state of an Aside that has
258
+ * been installed and never signed into -- and a page that answered "cannot
259
+ * verify" with an empty path for that case named nothing the user could go
260
+ * look at.
261
+ */
262
+ unresolvedPathHint: (env = process.env, home = homedir()) => join(asideHomeDir(env, home), "u"),
263
+ },
152
264
  };
153
265
 
154
266
  export const INTEGRATION_CLIENT_IDS: readonly IntegrationClientId[] =
@@ -19,7 +19,12 @@ import {
19
19
  semanticProtectedContributionFingerprint,
20
20
  validRefreshablePaths,
21
21
  } from "./ownership-policy";
22
- import { INTEGRATION_CLIENTS, type IntegrationClientId } from "./registry";
22
+ import {
23
+ INTEGRATION_CLIENTS,
24
+ resolveIntegrationPaths,
25
+ unresolvedPathHintFor,
26
+ type IntegrationClientId,
27
+ } from "./registry";
23
28
  import { createIntegrationStateStore, type IntegrationStateStore } from "./store";
24
29
 
25
30
  export type IntegrationState = "absent" | "current" | "stale" | "conflict" | "unsafe";
@@ -238,6 +243,42 @@ export function classifyIntegration(input: {
238
243
  return { state: "unsafe", reason: "blocked-container" };
239
244
  }
240
245
  if (!hasOurFragments(input.parsed, input.contribution)) return { state: "absent" };
246
+
247
+ /*
248
+ * Fragments the desired contribution carries beyond the paths this record names. Both
249
+ * states appear whenever a client gains a second owned block:
250
+ *
251
+ * - occupied by a value we did not write -> refuse. A refresh merges the WHOLE
252
+ * contribution, so without this check applying would replace a block the user wrote
253
+ * themselves and report success.
254
+ * - empty -> our own block is missing, because the record predates it. Report drift so
255
+ * a refresh adds it. Without this the file reads `current` forever and the second
256
+ * block never arrives, which is exactly what an older installation hits on upgrade.
257
+ *
258
+ * A byte-identical value is ours in substance: adopt it instead of dead-ending a
259
+ * hand-merged config on a conflict the user can only resolve by deleting our own block.
260
+ */
261
+ const recordedPaths = new Set((input.record?.fragmentPaths ?? []).map(path => path.join("\u0000")));
262
+ let addedPathMissing = false;
263
+ for (const fragment of input.contribution.fragments) {
264
+ if (recordedPaths.has(fragment.path.join("\u0000"))) continue;
265
+ const observed = readPath(input.parsed, fragment.path);
266
+ if (observed === undefined) {
267
+ addedPathMissing = true;
268
+ continue;
269
+ }
270
+ const one = (value: unknown): string => fingerprint(canonicalContribution({
271
+ clientId: (input.clientId ?? input.record?.clientId) as IntegrationClientId,
272
+ fragments: [{ path: fragment.path, value }],
273
+ }));
274
+ if (one(observed) !== one(fragment.value)) return { state: "conflict", reason: "unowned-key" };
275
+ }
276
+ /*
277
+ * No record: whatever occupies our paths is not ours to touch. A byte-identical value
278
+ * would be ours in substance, but `stale` without a record is not actionable — the writer
279
+ * reads `createdContainers` off the record to decide what it may prune, so adopting a
280
+ * hand-merged block needs an apply path that creates one first. Refuse, exactly as before.
281
+ */
241
282
  if (!input.record) return { state: "conflict", reason: "unowned-key" };
242
283
  /*
243
284
  * A record proves ownership of ONE file. Change HOME, XDG_CONFIG_HOME,
@@ -286,6 +327,12 @@ export function classifyIntegration(input: {
286
327
  }
287
328
  return { state: "stale" };
288
329
  }
330
+ /*
331
+ * Checked after everything else that could refuse: an owned fragment that no longer
332
+ * matches, or a sibling edit in a format that cannot be rewritten safely, still wins.
333
+ * What is left is a block we own on paper and are merely missing on disk.
334
+ */
335
+ if (addedPathMissing) return { state: "stale" };
289
336
  const desiredFingerprint = typeof input.record.semanticBlockFingerprint === "string"
290
337
  ? fingerprint(semanticContribution(input.contribution))
291
338
  : fingerprint(canonicalContribution(input.contribution));
@@ -382,15 +429,30 @@ export function readIntegrationState(input: IntegrationStateInput): IntegrationS
382
429
  let configPath: string;
383
430
  let installed: boolean;
384
431
  try {
385
- configPath = spec.configPath(input.env, input.home);
386
- installed = io.statKind(spec.detectDir(input.env, input.home)) === "dir";
432
+ // One resolution for both, so a client whose paths come from mutable state
433
+ // cannot report one account's install beside another account's config path.
434
+ const paths = resolveIntegrationPaths(input.clientId, input.env, input.home);
435
+ configPath = paths.configPath;
436
+ installed = io.statKind(paths.detectDir) === "dir";
387
437
  } catch (error) {
388
438
  if (!(error instanceof ClientPathError)) throw error;
439
+ /*
440
+ * Two different situations reach here and they are not the same answer.
441
+ *
442
+ * A relative `OPENCLAW_CONFIG_PATH` is a misconfiguration: there is nothing
443
+ * to name, and "cannot verify" is correct. Aside's absent account manifest
444
+ * is the ORDINARY state of an Aside that has been installed and never
445
+ * signed into, and answering that with a red danger badge and an empty path
446
+ * told the user their config was suspect when in fact there is no account
447
+ * yet. A client that can name where its config would go gets `installed:
448
+ * false` and that location, which reads as "not installed" in the UI.
449
+ */
450
+ const hint = unresolvedPathHintFor(input.clientId, input.env, input.home);
389
451
  return {
390
452
  clientId: input.clientId,
391
- state: "unsafe",
453
+ state: hint ? "absent" : "unsafe",
392
454
  installed: false,
393
- configPath: "",
455
+ configPath: hint,
394
456
  reason: "unresolvable-path",
395
457
  ...retention,
396
458
  };
@@ -28,7 +28,7 @@ import {
28
28
  semanticProtectedContributionFingerprint,
29
29
  } from "./ownership-policy";
30
30
  import { createdContainerPaths, mergeContribution, removeFragments } from "./merge";
31
- import { INTEGRATION_CLIENTS, isLoopbackOnly, type IntegrationClientId } from "./registry";
31
+ import { INTEGRATION_CLIENTS, isLoopbackOnly, resolveIntegrationPaths, type IntegrationClientId } from "./registry";
32
32
  import { classifyIntegration, exportContextOf } from "./state";
33
33
  import type { IntegrationState } from "./state";
34
34
  import { serializeDocument, UnserializableValueError } from "./serialize";
@@ -210,8 +210,20 @@ function preflight(input: IntegrationWriteInput) {
210
210
  * whole Integrations page because one client is misconfigured.
211
211
  */
212
212
  let configPath: string;
213
+ let detectDir: string;
213
214
  try {
214
- configPath = input.resolvedPaths?.configPath ?? spec.configPath(input.env, input.home);
215
+ /*
216
+ * Resolve the PAIR, never one half.
217
+ *
218
+ * The coordinated path hands us a frozen pair, but applyIntegration,
219
+ * refreshIntegration and disableIntegration are public and may be called
220
+ * without one. Resolving configPath here and detectDir separately later let
221
+ * an Aside account switch land between the two, so a direct apply could
222
+ * verify account 1 was installed and then write account 0's catalog.
223
+ */
224
+ const resolved = input.resolvedPaths ?? resolveIntegrationPaths(clientId, input.env, input.home);
225
+ configPath = resolved.configPath;
226
+ detectDir = resolved.detectDir;
215
227
  } catch (error) {
216
228
  if (!(error instanceof ClientPathError)) throw error;
217
229
  return { failed: refuse(clientId, "unsafe", "unsafe", error.message) } as const;
@@ -248,15 +260,34 @@ function preflight(input: IntegrationWriteInput) {
248
260
  const classified = classifyIntegration({
249
261
  fileText: before, fileIsRegular: true, parsed, record, contribution, configPath, clientId,
250
262
  });
251
- return { failed: undefined, store, io, clientId, spec, exportSpec, configPath, before, parsed, contribution, record, classified } as const;
263
+ return { failed: undefined, store, io, clientId, spec, exportSpec, configPath, detectDir, before, parsed, contribution, record, classified } as const;
252
264
  }
253
265
 
254
- function applyOrRefreshIntegration(input: IntegrationWriteInput, allowAbsent: boolean): WriteOutcome {
266
+ /**
267
+ * How a conflicted document is treated.
268
+ *
269
+ * `refuse` is the default and the only behavior that existed: a conflict means
270
+ * something we did not write occupies our paths, or our own block was edited,
271
+ * and guessing which one the user meant to keep is how a toggle deletes work.
272
+ *
273
+ * `overwrite` is the explicit escape hatch. It is never reached by a plain
274
+ * apply -- the caller has to ask for it by name -- because the whole value of
275
+ * the refusal is that it cannot be triggered by accident.
276
+ */
277
+ type ConflictPolicy = "refuse" | "overwrite";
278
+
279
+ function applyOrRefreshIntegration(
280
+ input: IntegrationWriteInput,
281
+ allowAbsent: boolean,
282
+ conflictPolicy: ConflictPolicy = "refuse",
283
+ ): WriteOutcome {
255
284
  const pre = preflight(input);
256
285
  if (pre.failed) return pre.failed;
257
- const { store, io, clientId, spec, exportSpec, configPath, before, parsed, contribution, record, classified } = pre;
286
+ const { store, io, clientId, spec, exportSpec, configPath, detectDir, before, parsed, contribution, record, classified } = pre;
258
287
 
259
- if (io.statKind(input.resolvedPaths?.detectDir ?? spec.detectDir(input.env, input.home)) !== "dir") {
288
+ // The detect directory preflight already resolved, so it cannot name a
289
+ // different account than the config path this operation is about to write.
290
+ if (io.statKind(detectDir) !== "dir") {
260
291
  return refuse(clientId, "not_installed", "absent", `${clientId} is not installed`);
261
292
  }
262
293
  if (isLoopbackOnly(clientId) && !isLoopbackHostname(input.config.hostname)) {
@@ -264,10 +295,20 @@ function applyOrRefreshIntegration(input: IntegrationWriteInput, allowAbsent: bo
264
295
  `The generated ${clientId} integration is loopback-only and does not emit the admission header a non-loopback bind requires. Give it loopback access instead, through a tunnel or a local forwarder.`);
265
296
  }
266
297
  if (classified.state === "conflict") {
267
- return refuse(clientId, "conflict", "conflict",
268
- classified.reason === "foreign-edit"
269
- ? `${configPath} changed after opencodex wrote it`
270
- : `${configPath} already contains an opencodex block we did not write`);
298
+ if (conflictPolicy === "refuse") {
299
+ return refuse(clientId, "conflict", "conflict",
300
+ classified.reason === "foreign-edit"
301
+ ? `${configPath} changed after opencodex wrote it`
302
+ : `${configPath} already contains an opencodex block we did not write`);
303
+ }
304
+ /*
305
+ * The caller asked for the overwrite explicitly, so the merge below runs
306
+ * against the document as it stands and our block replaces whatever holds
307
+ * our paths. Everything that makes it recoverable is shared with apply --
308
+ * the snapshot, the atomic write, the compare-before-commit recheck and the
309
+ * journal row all come from the same commit() call -- which is why this is a
310
+ * policy flag on one code path rather than a second implementation.
311
+ */
271
312
  }
272
313
  /*
273
314
  * `unsafe` from the classifier means the document is not one we may write
@@ -313,7 +354,22 @@ function applyOrRefreshIntegration(input: IntegrationWriteInput, allowAbsent: bo
313
354
  */
314
355
  const base = classified.state === "stale" && record
315
356
  ? removeFragments(parsed, record.fragmentPaths, new Set(record.createdContainers ?? [])).doc
316
- : parsed;
357
+ : classified.state === "conflict" && record
358
+ /*
359
+ * A forced overwrite of a `foreign-edit` conflict drops what the previous
360
+ * record owned for the same reason a stale refresh does: the replacement
361
+ * record covers the paths we are about to write, so a path the old record
362
+ * owned and the new one does not would be stranded forever, unremovable by
363
+ * any later disable.
364
+ *
365
+ * With NO record -- an `unowned-key` conflict -- there is nothing to drop and
366
+ * the merge runs against the user's document directly. That is correct:
367
+ * createdContainerPaths then attributes every container they already had to
368
+ * them, so a later disable removes our leaves and leaves their structure
369
+ * standing.
370
+ */
371
+ ? removeFragments(parsed, record.fragmentPaths, new Set(record.createdContainers ?? [])).doc
372
+ : parsed;
317
373
  // Computed against the document as it stands BEFORE the merge: afterwards
318
374
  // every container exists and "did we create this?" is unanswerable.
319
375
  const created = createdContainerPaths(base, contribution);
@@ -361,7 +417,16 @@ function applyOrRefreshIntegration(input: IntegrationWriteInput, allowAbsent: bo
361
417
  const snapshot = store.captureSnapshot(clientId, opId, before);
362
418
  const at = new Date(io.now()).toISOString();
363
419
  const entry: JournalEntry = {
364
- opId, clientId, kind: classified.state === "stale" ? "refresh" : "apply", at, configPath,
420
+ /*
421
+ * `overwrite` is its own kind rather than reusing `apply`. The rollback list
422
+ * is the one place a user goes after a mistake, and "applied" is a lie about
423
+ * an operation that replaced a block somebody else wrote.
424
+ */
425
+ opId, clientId,
426
+ kind: classified.state === "conflict"
427
+ ? "overwrite"
428
+ : classified.state === "stale" ? "refresh" : "apply",
429
+ at, configPath,
365
430
  snapshot, resultFingerprint: fingerprint(text), resultAbsent: false, priorRecord: record,
366
431
  };
367
432
  const refreshablePaths = refreshablePathsOf(contribution);
@@ -392,6 +457,23 @@ export function applyIntegration(input: IntegrationWriteInput): WriteOutcome {
392
457
  return applyOrRefreshIntegration(input, true);
393
458
  }
394
459
 
460
+ /**
461
+ * Apply over a conflicted config, replacing whatever holds our paths.
462
+ *
463
+ * Separate from `applyIntegration` and never a flag on it: a caller has to name
464
+ * this function to get the behavior, so no existing call site can acquire it by
465
+ * passing a default through.
466
+ *
467
+ * What it does NOT relax. `unsafe` still refuses -- a blocked container means the
468
+ * merge would replace a value it cannot reason about, and a snapshot is not a
469
+ * licence for that. `not_installed` and `non_loopback` still refuse; neither is a
470
+ * conflict. And a non-conflict state behaves exactly as apply does, so calling
471
+ * this on a clean file is not a way to skip any other check.
472
+ */
473
+ export function overwriteIntegration(input: IntegrationWriteInput): WriteOutcome {
474
+ return applyOrRefreshIntegration(input, true, "overwrite");
475
+ }
476
+
395
477
  /** Refresh an owned stale block, but never create or reconnect an absent one. */
396
478
  export function refreshIntegration(input: IntegrationWriteInput): WriteOutcome {
397
479
  return applyOrRefreshIntegration(input, false);
@@ -624,10 +706,12 @@ function freezeIntegrationInput(input: IntegrationWriteInput): FrozenIntegration
624
706
  const store = input.store ?? createIntegrationStateStore();
625
707
  const io = input.io ?? defaultIntegrationIO(store);
626
708
  const spec = INTEGRATION_CLIENTS[input.clientId];
627
- const resolvedPaths = {
628
- configPath: spec.configPath(env, home),
629
- detectDir: spec.detectDir(env, home),
630
- };
709
+ /*
710
+ * One resolution for both paths. Aside derives them from the account id in
711
+ * its manifest, so two independent calls could verify one account's install
712
+ * and then write another account's catalog if a switch landed between them.
713
+ */
714
+ const resolvedPaths = resolveIntegrationPaths(input.clientId, env, home);
631
715
  return { ...input, env, home, store, io, resolvedPaths };
632
716
  }
633
717
 
@@ -682,6 +766,13 @@ export function refreshIntegrationCoordinated(
682
766
  return coordinatedWrite(input, refreshIntegration, options);
683
767
  }
684
768
 
769
+ export function overwriteIntegrationCoordinated(
770
+ input: IntegrationWriteInput,
771
+ options?: CoordinatedIntegrationOptions,
772
+ ): Promise<WriteOutcome> {
773
+ return coordinatedWrite(input, overwriteIntegration, options);
774
+ }
775
+
685
776
  export function disableIntegrationCoordinated(
686
777
  input: IntegrationWriteInput,
687
778
  options?: CoordinatedIntegrationOptions,
@@ -0,0 +1,36 @@
1
+ export interface KillableSubprocess {
2
+ exited: Promise<number>;
3
+ kill(): unknown;
4
+ unref?(): unknown;
5
+ }
6
+
7
+ export interface BoundedSubprocessExit {
8
+ exitCode: number | null;
9
+ timedOut: boolean;
10
+ }
11
+
12
+ /** Kill at the deadline and abandon immediately; late exit/rejection remains observed. */
13
+ export function waitForSubprocessExit(
14
+ proc: KillableSubprocess,
15
+ timeoutMs: number,
16
+ ): Promise<BoundedSubprocessExit> {
17
+ return new Promise(resolve => {
18
+ let settled = false;
19
+ let timer: ReturnType<typeof setTimeout> | undefined;
20
+ const finish = (result: BoundedSubprocessExit): void => {
21
+ if (settled) return;
22
+ settled = true;
23
+ if (timer !== undefined) clearTimeout(timer);
24
+ resolve(result);
25
+ };
26
+ timer = setTimeout(() => {
27
+ try { proc.kill(); } catch { /* already exited */ }
28
+ try { proc.unref?.(); } catch { /* abandonment is still authoritative */ }
29
+ finish({ exitCode: null, timedOut: true });
30
+ }, Math.max(1, timeoutMs));
31
+ void proc.exited.then(
32
+ exitCode => finish({ exitCode, timedOut: false }),
33
+ () => finish({ exitCode: null, timedOut: false }),
34
+ );
35
+ });
36
+ }
@@ -29,6 +29,24 @@ export interface GracefulStopIo {
29
29
  waitExit?: (pid: number, timeoutMs: number) => boolean;
30
30
  env?: Record<string, string | undefined>;
31
31
  exitTimeoutMs?: number;
32
+ /**
33
+ * Nonce of the pending-teardown receipt this caller claimed.
34
+ *
35
+ * `ocx stop` sets it because it restores shared client config itself, only after
36
+ * proving a stopped Task Scheduler did not respawn the proxy (#3008). The nonce is what
37
+ * makes the deferral an owned obligation rather than a flag anyone can set: the proxy
38
+ * honours it only when it names the receipt actually on disk. Direct callers omit it
39
+ * and keep the self-contained behaviour.
40
+ */
41
+ deferSharedTeardownNonce?: string;
42
+ /**
43
+ * Endpoint the caller already resolved for this pid.
44
+ *
45
+ * `ocx stop` records this same snapshot in its pending-teardown receipt. Re-reading the
46
+ * runtime file here could pick up a different one, which would make the receipt name an
47
+ * endpoint the stop never contacted — and recovery probes exactly that endpoint.
48
+ */
49
+ runtimeEndpoint?: { hostname: string; port: number };
32
50
  }
33
51
 
34
52
  /**
@@ -67,7 +85,7 @@ export class ProxyOwnershipRefusedError extends Error {}
67
85
  */
68
86
  export async function stopProxyGracefully(pid: number, io: GracefulStopIo = {}): Promise<GracefulStopResult> {
69
87
  const readRuntime = io.readRuntime ?? readRuntimePort;
70
- const runtime = readRuntime(pid);
88
+ const runtime = io.runtimeEndpoint ?? readRuntime(pid);
71
89
  if (!runtime?.port) return false;
72
90
  const env = io.env ?? process.env;
73
91
  const headers: Record<string, string> = {};
@@ -75,7 +93,14 @@ export async function stopProxyGracefully(pid: number, io: GracefulStopIo = {}):
75
93
  if (token) headers["x-opencodex-api-key"] = token;
76
94
  const fetchFn = io.fetchFn ?? fetch;
77
95
  try {
78
- const res = await fetchFn(`http://${gracefulStopHost(runtime.hostname)}:${runtime.port}/api/stop`, {
96
+ // `ocx stop` asks the proxy NOT to restore shared client config: it does that itself,
97
+ // after verifying a stopped Task Scheduler did not respawn the proxy (#3008). Letting
98
+ // the child do it means a survivor found seconds later has already lost its config.
99
+ const stopUrl = `http://${gracefulStopHost(runtime.hostname)}:${runtime.port}/api/stop`
100
+ + (io.deferSharedTeardownNonce
101
+ ? `?deferSharedTeardown=1&teardownNonce=${encodeURIComponent(io.deferSharedTeardownNonce)}`
102
+ : "");
103
+ const res = await fetchFn(stopUrl, {
79
104
  method: "POST",
80
105
  headers,
81
106
  // Hung proxies with many CLOSE_WAIT clients can be slow to accept; give them
@@ -107,10 +132,10 @@ function drainDeadlineMs(): number {
107
132
  }
108
133
 
109
134
  /** Graceful-first stop: management-API drain, then the platform kill ladder. */
110
- export async function stopProxy(pid: number): Promise<void> {
135
+ export async function stopProxy(pid: number, io: GracefulStopIo = {}): Promise<void> {
111
136
  if (!isProcessAlive(pid)) return;
112
- const runtime = readRuntimePort(pid);
113
- const graceful = await stopProxyGracefully(pid);
137
+ const runtime = io.runtimeEndpoint ?? readRuntimePort(pid);
138
+ const graceful = await stopProxyGracefully(pid, io);
114
139
  if (graceful === "refused") {
115
140
  // The proxy refused on purpose (foreign service owns it). Forcing would strip shared
116
141
  // config while that service keeps the proxy alive.
@@ -9,6 +9,7 @@ import { reconcileProviderFetchWarnings } from "../codex/catalog/provider-fetch"
9
9
  import { reconcileModelCacheGeneration } from "../codex/model-cache";
10
10
  import { reconcilePoolRotationState } from "../codex/pool-rotation";
11
11
  import { reconcileCodexQuotaAccounts } from "../codex/quota";
12
+ import { reconcileQuotaRecovery, sweepExpiredQuotaRecovery } from "../codex/quota-401-recovery";
12
13
  import {
13
14
  listLiveCodexAccountIds,
14
15
  reconcileCodexRoutingHealth,
@@ -84,6 +85,13 @@ export const STATE_STORE_REGISTRATIONS = [
84
85
  },
85
86
  { name: "anthropic-routing-health", sweepExpired: sweepExpiredAnthropicRoutingHealth },
86
87
  { name: "xai-refresh-verdicts", sweepExpired: sweepExpiredXaiPermanentFailureVerdicts },
88
+ {
89
+ name: "codex-quota-401-recovery",
90
+ // Only backoff windows and abandoned leases expire. A spent fence is durable: expiring
91
+ // it would grant the same credential lineage a second refresh (#3019).
92
+ sweepExpired: sweepExpiredQuotaRecovery,
93
+ reconcileGeneration: context => reconcileQuotaRecovery(context.codexAccountIds),
94
+ },
87
95
  {
88
96
  name: "responses-continuation",
89
97
  sweepExpired: sweepExpiredResponseStates,
@@ -0,0 +1,47 @@
1
+ // Core and build metadata are unambiguous and stay inline. The prerelease section does not:
2
+ // the semver.org pattern for one identifier is
3
+ // 0 | [1-9]\d* | [0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*
4
+ // whose three alternatives overlap, and wrapping that in `(?:\.…)*` gives a regex engine an
5
+ // exponential number of ways to split the same string. CodeQL flagged it (`js/redos`) and the
6
+ // cost is real, not theoretical: `0.0.0-0.` followed by repetitions of `--.` took **522ms for a
7
+ // single 125-character input** — inside the 128-char ceiling this module already enforced, and
8
+ // inside the 96-char one its only caller uses. A length cap does not fix superlinear blowup; it
9
+ // only decides where the curve is sampled.
10
+ //
11
+ // So the prerelease section is matched with one non-backtracking pass and its identifiers are
12
+ // validated individually. Each identifier is checked by an anchored regex with no repetition of
13
+ // an alternation, which is linear in the identifier's length.
14
+ const STRICT_SEMVER_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
15
+
16
+ const NUMERIC_IDENTIFIER_RE = /^(?:0|[1-9]\d*)$/;
17
+ const ALPHANUMERIC_IDENTIFIER_RE = /^[0-9A-Za-z-]+$/;
18
+
19
+ /**
20
+ * A prerelease identifier is either a numeric identifier with no leading zero, or an
21
+ * alphanumeric one that contains at least one non-digit. Empty identifiers are invalid,
22
+ * which is what rejects a trailing or doubled dot.
23
+ */
24
+ function isPrereleaseIdentifier(part: string): boolean {
25
+ if (part.length === 0) return false;
26
+ if (NUMERIC_IDENTIFIER_RE.test(part)) return true;
27
+ return ALPHANUMERIC_IDENTIFIER_RE.test(part) && !/^\d+$/.test(part);
28
+ }
29
+
30
+ export interface StrictSemver {
31
+ readonly raw: string;
32
+ readonly core: readonly [bigint, bigint, bigint];
33
+ readonly prerelease: readonly (bigint | string)[];
34
+ }
35
+
36
+ export function parseStrictSemver(value: unknown, maxLength = 128): StrictSemver | null {
37
+ if (typeof value !== "string" || value.length === 0 || value.length > maxLength) return null;
38
+ const match = STRICT_SEMVER_RE.exec(value);
39
+ if (!match) return null;
40
+ const prereleaseParts = match[4] === undefined ? [] : match[4].split(".");
41
+ if (!prereleaseParts.every(isPrereleaseIdentifier)) return null;
42
+ return Object.freeze({
43
+ raw: value,
44
+ core: Object.freeze([BigInt(match[1]!), BigInt(match[2]!), BigInt(match[3]!)]) as readonly [bigint, bigint, bigint],
45
+ prerelease: Object.freeze(prereleaseParts.map(part => /^\d+$/.test(part) ? BigInt(part) : part)),
46
+ });
47
+ }