@bitkyc08/opencodex 2.65.0 → 2.66.0-preview.20260925

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 (213) hide show
  1. package/README.md +14 -0
  2. package/gui/dist/assets/App-BVjA0T6O.js +51 -0
  3. package/gui/dist/assets/App-gkoJIEOV.css +1 -0
  4. package/gui/dist/assets/{Tray-D4bA1vff.js → Tray-BCFtiWwD.js} +1 -1
  5. package/gui/dist/assets/index-ComVsdSk.css +1 -0
  6. package/gui/dist/assets/index-vO4dgfiU.js +86 -0
  7. package/gui/dist/assets/{tray-data-BZXhB3qz.js → tray-data-C4FiNaxk.js} +1 -1
  8. package/gui/dist/index.html +2 -2
  9. package/package.json +1 -2
  10. package/src/adapters/anthropic/beta-allowlist.ts +80 -0
  11. package/src/adapters/anthropic/passthrough.ts +221 -0
  12. package/src/adapters/anthropic-image-codec.ts +56 -19
  13. package/src/adapters/anthropic-image-normalize.ts +20 -10
  14. package/src/adapters/anthropic.ts +55 -24
  15. package/src/adapters/coding-agent/protocol.ts +29 -19
  16. package/src/adapters/cursor/live-transport.ts +18 -3
  17. package/src/adapters/cursor/native-exec-shell.ts +18 -63
  18. package/src/adapters/cursor/native-exec.ts +5 -2
  19. package/src/adapters/cursor/native-foreground-shell.ts +44 -0
  20. package/src/adapters/devin/cloud-direct/chat.ts +7 -1
  21. package/src/adapters/exec-tool-result-normalize.ts +4 -1
  22. package/src/adapters/google.ts +12 -0
  23. package/src/adapters/ollama-native.ts +32 -1
  24. package/src/adapters/openai-chat/serialized-tool-call-content.ts +131 -34
  25. package/src/adapters/openai-chat.ts +6 -4
  26. package/src/adapters/openai-responses/passthrough.ts +1 -0
  27. package/src/adapters/openai-responses/reasoning.ts +45 -1
  28. package/src/bridge/response-json.ts +10 -2
  29. package/src/chat/outbound.ts +104 -52
  30. package/src/claude/claude-code-block.ts +16 -0
  31. package/src/claude/context-windows.ts +52 -4
  32. package/src/claude/desktop-first-party.ts +25 -8
  33. package/src/claude/inbound.ts +22 -0
  34. package/src/claude/intercept/connect-proxy.ts +37 -4
  35. package/src/claude/intercept/proxy-auth.ts +166 -0
  36. package/src/claude/intercept/runtime.ts +21 -0
  37. package/src/claude/intercept/settings.ts +33 -12
  38. package/src/claude/outbound.ts +45 -27
  39. package/src/cli/account-extended.ts +47 -15
  40. package/src/cli/account-target.ts +115 -0
  41. package/src/cli/account.ts +20 -28
  42. package/src/cli/agent.ts +10 -1
  43. package/src/cli/api-protocols.ts +206 -0
  44. package/src/cli/capabilities.ts +86 -0
  45. package/src/cli/combo.ts +2 -2
  46. package/src/cli/connect.ts +127 -3
  47. package/src/cli/dispatch.ts +8 -0
  48. package/src/cli/doctor.ts +33 -0
  49. package/src/cli/help.ts +2 -0
  50. package/src/cli/link.ts +288 -0
  51. package/src/cli/registry.ts +23 -0
  52. package/src/cli/runtime-api.ts +79 -0
  53. package/src/cli/system-command.ts +21 -2
  54. package/src/client/connect.ts +125 -23
  55. package/src/client/hub-relay.ts +12 -8
  56. package/src/client/link-join.ts +323 -0
  57. package/src/client/link-relay.ts +241 -0
  58. package/src/client/link-state.ts +110 -0
  59. package/src/client/link-teardown.ts +63 -0
  60. package/src/client/link-tunnel.ts +465 -0
  61. package/src/client/machine-listener.ts +14 -5
  62. package/src/client/runtime.ts +91 -42
  63. package/src/client/state.ts +4 -0
  64. package/src/codex/catalog/build-entries.ts +4 -0
  65. package/src/codex/catalog/derive-entry.ts +5 -0
  66. package/src/codex/catalog/parsing.ts +4 -0
  67. package/src/codex/catalog/retained-sync.ts +4 -1
  68. package/src/codex/desktop-switches.ts +88 -8
  69. package/src/codex/inject/plan.ts +16 -3
  70. package/src/codex/inject.ts +3 -0
  71. package/src/codex/internal/catalog-writer.ts +13 -2
  72. package/src/combos/failover.ts +3 -2
  73. package/src/combos/index.ts +12 -0
  74. package/src/combos/jev.ts +646 -0
  75. package/src/combos/request.ts +5 -0
  76. package/src/combos/resolve.ts +17 -15
  77. package/src/combos/types.ts +47 -3
  78. package/src/config/atomic-write.ts +3 -0
  79. package/src/config/live-reconcile.ts +20 -4
  80. package/src/config/persist-unlocked.ts +20 -7
  81. package/src/config/schema/config-schema.ts +16 -0
  82. package/src/config/schema/leaf-validators.ts +38 -1
  83. package/src/generated/compatibility-version.json +416 -128
  84. package/src/integrations/omp-yaml-source.ts +109 -4
  85. package/src/lab/automation/orchestrator.ts +60 -4
  86. package/src/lab/events/limits.ts +1 -1
  87. package/src/lib/bounded-body.ts +55 -38
  88. package/src/lib/config-ownership.ts +11 -22
  89. package/src/lib/local-desktop-snapshot-capability.ts +56 -0
  90. package/src/lib/local-management-capability.ts +25 -3
  91. package/src/lib/optional-shutdown-hooks.ts +17 -0
  92. package/src/lib/state-store-registrations.ts +2 -2
  93. package/src/lib/windows-elevation.ts +62 -25
  94. package/src/lib/windows-secret-acl.ts +114 -23
  95. package/src/link/admission-wait.ts +73 -0
  96. package/src/link/compensation.ts +100 -0
  97. package/src/link/fingerprint.ts +21 -0
  98. package/src/link/paths.ts +19 -0
  99. package/src/link/ports.ts +12 -0
  100. package/src/link/routes.ts +32 -0
  101. package/src/link/ssh-argv.ts +170 -0
  102. package/src/link/ssh-config.ts +152 -0
  103. package/src/link/ssh-runner.ts +161 -0
  104. package/src/link/status-projection.ts +96 -0
  105. package/src/link/store.ts +139 -0
  106. package/src/link/supervisor.ts +404 -0
  107. package/src/link/tunnel-state.ts +91 -0
  108. package/src/protocols/baseline.ts +88 -0
  109. package/src/protocols/codecs/chat.ts +17 -0
  110. package/src/protocols/codecs/messages.ts +18 -0
  111. package/src/protocols/codecs/responses.ts +17 -0
  112. package/src/protocols/contract.ts +185 -0
  113. package/src/protocols/dto.ts +267 -0
  114. package/src/protocols/encoders/adapter-events.ts +876 -0
  115. package/src/protocols/encoders/chat.ts +243 -0
  116. package/src/protocols/encoders/messages.ts +462 -0
  117. package/src/protocols/envelope.ts +43 -0
  118. package/src/protocols/features.ts +295 -0
  119. package/src/protocols/guard.ts +33 -0
  120. package/src/protocols/opaque-state.ts +132 -0
  121. package/src/protocols/path.ts +39 -0
  122. package/src/protocols/plan-snapshot.ts +226 -0
  123. package/src/protocols/plan.ts +192 -0
  124. package/src/protocols/provider-summary.ts +101 -0
  125. package/src/protocols/settings.ts +118 -0
  126. package/src/protocols/shadow-plan.ts +36 -0
  127. package/src/protocols/shadow.ts +62 -0
  128. package/src/protocols/trace.ts +335 -0
  129. package/src/providers/model-rename-migration.ts +3 -3
  130. package/src/providers/registry/entries-core.ts +6 -1
  131. package/src/providers/registry/entries-extended.ts +15 -0
  132. package/src/providers/registry/model-ids.ts +1 -0
  133. package/src/providers/registry/types.ts +6 -0
  134. package/src/providers/registry.ts +1 -1
  135. package/src/remote-control/workspace-hub.ts +5 -4
  136. package/src/responses/apply-patch-envelope.ts +6 -1
  137. package/src/responses/code-mode-shell-input.ts +4 -2
  138. package/src/responses/freeform-wrapper-scan.ts +51 -24
  139. package/src/responses/progressive-freeform-input.ts +14 -20
  140. package/src/responses/spill-store.ts +187 -33
  141. package/src/responses/state/snapshot-select.ts +49 -0
  142. package/src/responses/state/spill-inspect.ts +46 -0
  143. package/src/responses/state/spill-queue.ts +16 -0
  144. package/src/responses/state.ts +51 -27
  145. package/src/server/audio-client.ts +8 -3
  146. package/src/server/audio-upstream.ts +9 -4
  147. package/src/server/auth-cors.ts +25 -8
  148. package/src/server/chat-completions.ts +71 -11
  149. package/src/server/chat-native-eligibility.ts +74 -0
  150. package/src/server/chat-native.ts +139 -74
  151. package/src/server/claude-messages.ts +140 -22
  152. package/src/server/hub-usage.ts +4 -3
  153. package/src/server/index/link-listener.ts +195 -0
  154. package/src/server/index/optional-listeners.ts +115 -0
  155. package/src/server/index/serve-options.ts +44 -10
  156. package/src/server/index.ts +10 -9
  157. package/src/server/inference/attempt.ts +51 -0
  158. package/src/server/inference/client-encoder-delivery.ts +235 -0
  159. package/src/server/inference/client-wire-log.ts +49 -0
  160. package/src/server/inference/client-wire.ts +69 -0
  161. package/src/server/inference/context.ts +15 -0
  162. package/src/server/inference/final-log.ts +37 -0
  163. package/src/server/local-desktop-snapshot-auth.ts +57 -0
  164. package/src/server/management/agent-settings-routes.ts +15 -13
  165. package/src/server/management/api-access.ts +11 -1
  166. package/src/server/management/config-routes.ts +3 -6
  167. package/src/server/management/context.ts +25 -0
  168. package/src/server/management/link-routes.ts +535 -0
  169. package/src/server/management/logs-usage-routes.ts +31 -1
  170. package/src/server/management/native-integration-routes.ts +6 -11
  171. package/src/server/management/oauth-account-routes.ts +42 -16
  172. package/src/server/management/protocol-routes.ts +159 -0
  173. package/src/server/management/protocol-settings-patch.ts +145 -0
  174. package/src/server/management/provider-patch-transaction.ts +61 -0
  175. package/src/server/management/provider-routes.ts +39 -18
  176. package/src/server/management/route-registry.ts +12 -0
  177. package/src/server/management/sidebar-routes.ts +8 -3
  178. package/src/server/management/system-routes.ts +18 -1
  179. package/src/server/management/usage-aggregate-cache.ts +206 -6
  180. package/src/server/management-api.ts +26 -1
  181. package/src/server/management-auth.ts +19 -5
  182. package/src/server/messages-native-eligibility.ts +161 -0
  183. package/src/server/messages-native-oauth.ts +149 -0
  184. package/src/server/messages-native.ts +790 -0
  185. package/src/server/relay.ts +9 -0
  186. package/src/server/request-log-filter.ts +92 -0
  187. package/src/server/request-log.ts +31 -70
  188. package/src/server/responses/adapter-delivery.ts +43 -16
  189. package/src/server/responses/agent-task-recovery.ts +90 -22
  190. package/src/server/responses/codex-ws-exchange.ts +4 -0
  191. package/src/server/responses/codex-ws-wire.ts +10 -1
  192. package/src/server/responses/core-combo-native.ts +323 -0
  193. package/src/server/responses/core-combo.ts +224 -13
  194. package/src/server/responses/core-options.ts +22 -0
  195. package/src/server/responses/core.ts +14 -26
  196. package/src/server/responses/request-sidecar-auth.ts +8 -3
  197. package/src/server/responses/run-turn-execution.ts +257 -63
  198. package/src/server/responses/sidecar-execution.ts +7 -4
  199. package/src/server/system-env.ts +7 -0
  200. package/src/service/diagnostics.ts +7 -1
  201. package/src/service/launchd.ts +26 -37
  202. package/src/service/windows-ops.ts +24 -20
  203. package/src/types/config.ts +58 -1
  204. package/src/types.ts +2 -0
  205. package/src/update/transactional-install.d.mts +0 -1
  206. package/src/update/transactional-install.mjs +17 -17
  207. package/src/usage/jev-stats.ts +495 -0
  208. package/src/usage/log.ts +24 -3
  209. package/src/web-search/run-turn-loop.ts +568 -0
  210. package/gui/dist/assets/App-8NMiZxT0.css +0 -1
  211. package/gui/dist/assets/App-DGLte6IR.js +0 -50
  212. package/gui/dist/assets/index--EWgGQvZ.css +0 -1
  213. package/gui/dist/assets/index-Bi1K37Wl.js +0 -86
@@ -80,8 +80,87 @@ function isComment(line: string): boolean {
80
80
  return line.trimStart().startsWith("#");
81
81
  }
82
82
 
83
+ /**
84
+ * The lexical state a scalar scan carries out of a line. Quoted and plain
85
+ * scalars may both continue onto later physical lines, so the state persists
86
+ * across the owned range: a continuation line opening with the scalar's
87
+ * closing quote is not a new opener — a ` #` behind it is a real comment —
88
+ * and a `#` on a line still inside an open quote is scalar content, not a
89
+ * comment.
90
+ */
91
+ interface ScalarScan {
92
+ comment: boolean;
93
+ quote: "'" | "\"" | null;
94
+ scalarStarted: boolean;
95
+ }
96
+
97
+ /**
98
+ * Quote tracking has to work in both directions: a `#` inside a quoted scalar
99
+ * is content (`"model#variant"`), but a quote inside a plain scalar is also
100
+ * content — `user's model` is one plain scalar, and treating its `'` as an
101
+ * opener would hide a real ` #` comment behind quote mode.
102
+ */
103
+ function scanScalarLine(line: string, quote: "'" | "\"" | null, scalarStarted: boolean): ScalarScan {
104
+ // A `'` or `"` opens a quoted scalar only where a scalar may begin — after
105
+ // `key:`, `- `, or a flow indicator — never inside a plain scalar already
106
+ // in progress.
107
+ let flowDepth = 0;
108
+ for (let index = 0; index < line.length; index += 1) {
109
+ const character = line[index]!;
110
+ if (quote === "\"") {
111
+ if (character === "\\") index += 1;
112
+ else if (character === quote) quote = null;
113
+ continue;
114
+ }
115
+ if (quote === "'") {
116
+ if (character !== quote) continue;
117
+ if (line[index + 1] === quote) index += 1;
118
+ else quote = null;
119
+ continue;
120
+ }
121
+ if (character === "#" && /\s/u.test(line[index - 1] ?? "")) {
122
+ return { comment: true, quote, scalarStarted };
123
+ }
124
+ const next = line[index + 1] ?? "";
125
+ if (scalarStarted) {
126
+ // `:` ends a plain scalar where a value boundary follows; inside flow
127
+ // collections `,`, `]`, and `}` do too.
128
+ if (character === ":" && (next === "" || /\s/u.test(next))) scalarStarted = false;
129
+ else if (flowDepth > 0 && (character === "," || character === "]" || character === "}")) {
130
+ scalarStarted = false;
131
+ if (character !== ",") flowDepth -= 1;
132
+ }
133
+ continue;
134
+ }
135
+ if (/\s/u.test(character)) continue;
136
+ if (character === "'" || character === "\"") {
137
+ quote = character;
138
+ continue;
139
+ }
140
+ if (character === "[" || character === "{") {
141
+ flowDepth += 1;
142
+ continue;
143
+ }
144
+ if (character === "]" || character === "}") {
145
+ flowDepth -= 1;
146
+ continue;
147
+ }
148
+ if (character === ",") continue;
149
+ // `key:`, `- `, `? `: indicators only where a value boundary follows.
150
+ if ((character === ":" || character === "-" || character === "?")
151
+ && (next === "" || /\s/u.test(next))) continue;
152
+ // Anchors, aliases, and tags decorate the scalar that follows them.
153
+ if (character === "&" || character === "*" || character === "!") {
154
+ while (index + 1 < line.length && !/\s/u.test(line[index + 1]!)) index += 1;
155
+ continue;
156
+ }
157
+ scalarStarted = true;
158
+ }
159
+ return { comment: false, quote, scalarStarted };
160
+ }
161
+
83
162
  function hasInlineComment(line: string): boolean {
84
- return line.includes("#");
163
+ return scanScalarLine(line, null, false).comment;
85
164
  }
86
165
 
87
166
  function regexpEscape(value: string): string {
@@ -171,16 +250,42 @@ function childEnd(
171
250
  parentEnd: number,
172
251
  indent: number,
173
252
  ): number | null {
253
+ // Scalar state carries across physical lines: a scalar continuation is
254
+ // always deeper than the leaf's indent, so a `spaces <= indent` line can
255
+ // never be one. Inside an open quote a blank or `#`-leading line is scalar
256
+ // content; inside a plain scalar a blank line folds but a `#` line is still
257
+ // a real comment.
258
+ let quote: "'" | "\"" | null = null;
259
+ let scalarStarted = false;
174
260
  for (let index = start + 1; index < parentEnd; index += 1) {
175
261
  const body = lines[index]!.body;
176
262
  const spaces = leadingSpaces(body);
177
263
  if (spaces === null) return null;
178
264
  // Blank lines and same-level comments remain outside our replacement.
179
265
  // Deeper comments belong to the leaf and would be destroyed, so refuse.
180
- if (isBlank(body)) return index;
181
- if (isComment(body)) return spaces <= indent ? index : null;
266
+ if (isBlank(body)) {
267
+ if (quote !== null) continue;
268
+ if (!scalarStarted) return index;
269
+ // A plain scalar's blank line folds only when deeper content follows;
270
+ // otherwise it is the separator before a sibling and stays outside.
271
+ let ahead = index + 1;
272
+ while (ahead < parentEnd && isBlank(lines[ahead]!.body)) ahead += 1;
273
+ if (ahead >= parentEnd) return index;
274
+ const nextSpaces = leadingSpaces(lines[ahead]!.body);
275
+ if (nextSpaces === null) return null;
276
+ if (nextSpaces <= indent) return index;
277
+ continue;
278
+ }
279
+ if (quote === null && isComment(body)) return spaces <= indent ? index : null;
182
280
  if (spaces <= indent) return index;
183
- if (hasInlineComment(body)) return null;
281
+ // A block sequence indicator opens a new node: scalar state never carries
282
+ // across item boundaries. Inside an open quote a leading `- ` is content.
283
+ const trimmed = body.trimStart();
284
+ if (quote === null && (trimmed === "-" || trimmed.startsWith("- "))) scalarStarted = false;
285
+ const scan = scanScalarLine(body, quote, scalarStarted);
286
+ if (scan.comment) return null;
287
+ quote = scan.quote;
288
+ scalarStarted = scan.scalarStarted;
184
289
  }
185
290
  return parentEnd;
186
291
  }
@@ -1,6 +1,9 @@
1
1
  import { readConfigDiagnostics } from "../../config";
2
2
  import { registerCurrentServerResourceCleanup } from "../../lib/server-resource-ownership";
3
- import { registerOptionalShutdownHook } from "../../lib/optional-shutdown-hooks";
3
+ import {
4
+ didRunOptionalShutdownHooks,
5
+ registerOptionalShutdownHook,
6
+ } from "../../lib/optional-shutdown-hooks";
4
7
  import { queryLabStatus } from "../query";
5
8
  import { rebuildLabProjection } from "../projection/rebuild";
6
9
  import { planLabAutomationRuns } from "./planner";
@@ -58,6 +61,9 @@ let shutdownRequested = false;
58
61
  const dispatchDepsByConfigDir = new Map<string, DispatchOwner>();
59
62
  const inFlightControllers = new Map<string, AbortController>();
60
63
  const cancellingRunIds = new Set<string>();
64
+ // Test seam: invoked after a manual run's queue row and per-run shutdown hook exist but
65
+ // before the post-registration sweep check, so a test can land a sweep in that gap.
66
+ let manualEnqueuePostWriteHookForTests: (() => void) | null = null;
61
67
 
62
68
  function configKey(configDir?: string): string {
63
69
  return configDir ?? "";
@@ -403,6 +409,13 @@ export async function runLabAutomationTick(configDir?: string): Promise<void> {
403
409
 
404
410
  export function startLabAutomationScheduler(configDir?: string): void {
405
411
  const key = configKey(configDir);
412
+ // Same late-request race as enqueueManualLabRun: a policy PUT or CLI enable can resume
413
+ // after the shutdown sweep already ran. Starting here would reset the latch and leave a
414
+ // live interval dispatching Lab work outside the completed sweep.
415
+ if (didRunOptionalShutdownHooks()) {
416
+ requestLabAutomationShutdown();
417
+ return;
418
+ }
406
419
  const currentOwner = dispatchDepsByConfigDir.get(key)?.token;
407
420
  const existing = schedulerTimers.get(key);
408
421
  if (existing) {
@@ -418,6 +431,14 @@ export function startLabAutomationScheduler(configDir?: string): void {
418
431
  requestLabAutomationShutdown();
419
432
  stopLabAutomationScheduler(configDir);
420
433
  });
434
+ // The sweep may have run in the gap between the entry check and this registration; it
435
+ // snapshots the registry once, so a hook that landed afterwards is orphaned. Keep the
436
+ // latch set and never start the timer — the registration stays live so a repeat sweep
437
+ // still tears this scheduler down.
438
+ if (didRunOptionalShutdownHooks()) {
439
+ requestLabAutomationShutdown();
440
+ return;
441
+ }
421
442
  shutdownRequested = false;
422
443
  const { policy, routes } = loadLabAutomationConfig(configDir);
423
444
  const now = Date.now();
@@ -457,6 +478,12 @@ export function resetLabAutomationSchedulerStateForTests(): void {
457
478
  dispatchDepsByConfigDir.clear();
458
479
  inFlightControllers.clear();
459
480
  cancellingRunIds.clear();
481
+ manualEnqueuePostWriteHookForTests = null;
482
+ }
483
+
484
+ /** Test-only seam for the gap between a manual run's queue write and its post-write sweep check. */
485
+ export function setLabAutomationManualEnqueuePostWriteHookForTests(hook: (() => void) | null): void {
486
+ manualEnqueuePostWriteHookForTests = hook;
460
487
  }
461
488
 
462
489
  export async function enqueueManualLabRun(
@@ -464,6 +491,14 @@ export async function enqueueManualLabRun(
464
491
  configDir?: string,
465
492
  abortSignal?: AbortSignal,
466
493
  ): Promise<LabAutomationRunRecordV1 | null> {
494
+ // The management route is not covered by the data-plane drain gate, so a request accepted
495
+ // before listener teardown can reach this point after the shutdown sweep already ran. A
496
+ // hook registered then is never invoked; run its teardown inline instead of letting the
497
+ // dispatch escape shutdown.
498
+ if (didRunOptionalShutdownHooks()) {
499
+ requestLabAutomationShutdown();
500
+ return null;
501
+ }
467
502
  const now = Date.now();
468
503
  const created = mutateLabAutomationState(configDir, (state) => {
469
504
  const next = enqueuePlannedRuns(state, [planned], "manual", now);
@@ -473,8 +508,29 @@ export async function enqueueManualLabRun(
473
508
  return { state: next, value: run };
474
509
  });
475
510
  if (!created) return null;
476
- // Manual execution is independent of automation enablement/layer toggles.
477
- await runDispatchBatch(configDir, { manualRunId: created.runId, abortSignal });
511
+ // Manual execution can run without activation or a scheduler, so it must own a shutdown
512
+ // hook for the lifetime of its dispatch instead of relying on either of those paths.
513
+ const detachShutdownHook = registerOptionalShutdownHook(
514
+ `lab-automation-manual:${created.runId}`,
515
+ requestLabAutomationShutdown,
516
+ );
517
+ // The sweep may have run in the gap between the entry check and this registration; it
518
+ // snapshots the registry once, so a hook that landed afterwards is orphaned.
519
+ manualEnqueuePostWriteHookForTests?.();
520
+ if (didRunOptionalShutdownHooks()) {
521
+ requestLabAutomationShutdown();
522
+ detachShutdownHook();
523
+ // The queue row was already written; leaving it `queued` would persist a manual run no
524
+ // scheduler tick ever picks up while the route reports success. Cancel it and fail.
525
+ cancelLabAutomationRun(created.runId, configDir);
526
+ return null;
527
+ }
528
+ try {
529
+ // Manual execution is independent of automation enablement/layer toggles.
530
+ await runDispatchBatch(configDir, { manualRunId: created.runId, abortSignal });
531
+ } finally {
532
+ detachShutdownHook();
533
+ }
478
534
  return loadLabAutomationState(configDir).runs.find((row) => row.runId === created.runId) ?? created;
479
535
  }
480
536
 
@@ -496,4 +552,4 @@ export function cancelLabAutomationRun(runId: string, configDir?: string): boole
496
552
  }
497
553
  return { state: next, value: true };
498
554
  });
499
- }
555
+ }
@@ -32,7 +32,7 @@ const FORBIDDEN_EXACT_KEYS = new Set([
32
32
  ]);
33
33
 
34
34
  const RAW_POSIX_PATH_RE =
35
- /(?:^|[^A-Za-z0-9._~/])\/(?:(?=$|[^A-Za-z0-9._~/])|(?!\/)(?![ \t\r\n])(?:\/|[^/\0\r\n]+)+\/?(?=$|[^A-Za-z0-9._~/]))/u;
35
+ /(?:^|[^A-Za-z0-9._~/])\/(?:(?=$|[^A-Za-z0-9._~/])|(?!\/)(?![ \t\r\n])[^/\0\r\n]+(?:\/+[^/\0\r\n]+)*\/*(?=$|[^A-Za-z0-9._~/]))/u;
36
36
  const ASCII_URL_WHITESPACE_RE = /[\t\r\n]/g;
37
37
  const FILE_URI_RE = /(?:^|[^A-Za-z0-9+.-])file:/i;
38
38
 
@@ -88,15 +88,46 @@ export function boundedBodyBufferGrowthsForTests(): number {
88
88
  return bufferGrowthsForTests;
89
89
  }
90
90
 
91
- function timeoutPromise(ms: number, value: symbol): { promise: Promise<symbol>; clear: () => void } {
92
- let timer: ReturnType<typeof setTimeout> | undefined;
93
- const promise = new Promise<symbol>((resolve) => {
94
- timer = setTimeout(() => resolve(value), Math.max(0, ms));
95
- });
91
+ function bodyDeadline(ms: number, onTimeout: () => void): { clear: () => void } {
92
+ const timer = setTimeout(onTimeout, Math.max(0, ms));
96
93
  return {
97
- promise,
98
- clear: () => {
99
- if (timer !== undefined) clearTimeout(timer);
94
+ clear: () => clearTimeout(timer),
95
+ };
96
+ }
97
+
98
+ /**
99
+ * A reader is consumed serially. Deadlines and abort own only its current wait,
100
+ * never a promise raced against every read: those reactions retain completed
101
+ * read results (including empty chunks) until the long-lived promise settles.
102
+ */
103
+ function interruptibleRead<T = never>(reader: ReadableStreamDefaultReader<Uint8Array>) {
104
+ type Outcome = Awaited<ReturnType<typeof reader.read>> | T;
105
+ type Interruption = { value: T } | { error: unknown };
106
+ let interruption: Interruption | undefined;
107
+ let pending: { resolve: (value: Outcome) => void; reject: (error: unknown) => void } | undefined;
108
+ const interrupt = (outcome: Interruption) => {
109
+ // Latch an interruption even between reads or before the first wait.
110
+ interruption ??= outcome;
111
+ if ("error" in interruption) pending?.reject(interruption.error);
112
+ else pending?.resolve(interruption.value);
113
+ };
114
+ return {
115
+ interrupt,
116
+ async read(): Promise<Outcome> {
117
+ try {
118
+ return await new Promise<Outcome>((resolve, reject) => {
119
+ pending = { resolve, reject };
120
+ if (interruption) {
121
+ interrupt(interruption);
122
+ return;
123
+ }
124
+ // Both reactions belong to this read only. A late rejection stays
125
+ // observed after interruption, even if cancel throws or never settles.
126
+ void reader.read().then(resolve, reject);
127
+ });
128
+ } finally {
129
+ pending = undefined;
130
+ }
100
131
  },
101
132
  };
102
133
  }
@@ -147,30 +178,20 @@ export async function readBoundedResponseBytes(
147
178
  let mustCancel = false;
148
179
  let cancelReason: unknown;
149
180
  const inactivityReason = new DOMException("Response body stalled", "TimeoutError");
150
- let rejectForInactivity: ((reason: unknown) => void) | undefined;
151
- const inactive = new Promise<never>((_resolve, reject) => {
152
- rejectForInactivity = reject;
153
- });
181
+ const waiting = interruptibleRead(reader);
154
182
  const inactivity = options.inactivityTimeoutMs === undefined
155
183
  ? null
156
- : idleDeadline(options.inactivityTimeoutMs, () => rejectForInactivity?.(inactivityReason));
184
+ : idleDeadline(options.inactivityTimeoutMs, () => waiting.interrupt({ error: inactivityReason }));
157
185
  inactivity?.reset();
158
186
 
159
- let rejectForAbort: ((reason: unknown) => void) | undefined;
160
- const aborted = new Promise<never>((_resolve, reject) => {
161
- rejectForAbort = reject;
162
- });
163
- const onAbort = () => rejectForAbort?.(signal?.reason);
187
+ const onAbort = () => waiting.interrupt({ error: signal?.reason });
164
188
  signal?.addEventListener("abort", onAbort, { once: true });
165
189
  // Close the narrow race between the preflight check and listener install.
166
190
  if (signal?.aborted) onAbort();
167
191
 
168
192
  try {
169
193
  while (true) {
170
- const read = reader.read();
171
- // Observe a late read rejection when abort/cancellation wins the race.
172
- void read.catch(() => undefined);
173
- const outcome = await Promise.race([read, aborted, inactive]);
194
+ const outcome = await waiting.read();
174
195
  if (signal?.aborted) {
175
196
  mustCancel = true;
176
197
  cancelReason = signal.reason;
@@ -289,30 +310,26 @@ export async function readBoundedResponseBody(
289
310
  bufferGrowthsForTests = 0;
290
311
  let mustCancel = false;
291
312
  let cancelReason: unknown;
292
- const total = timeoutPromise(options.totalTimeoutMs ?? BOUNDED_BODY_TIMEOUT_MS, TOTAL_TIMEOUT);
293
- let inactivity = timeoutPromise(
313
+ const waiting = interruptibleRead<symbol>(reader);
314
+ const total = bodyDeadline(
315
+ options.totalTimeoutMs ?? BOUNDED_BODY_TIMEOUT_MS,
316
+ () => waiting.interrupt({ value: TOTAL_TIMEOUT }),
317
+ );
318
+ let inactivity = bodyDeadline(
294
319
  options.firstByteTimeoutMs ?? options.inactivityTimeoutMs ?? BOUNDED_BODY_TIMEOUT_MS,
295
- INACTIVITY_TIMEOUT,
320
+ () => waiting.interrupt({ value: INACTIVITY_TIMEOUT }),
296
321
  );
297
322
 
298
- let rejectForAbort: ((reason: unknown) => void) | undefined;
299
- const aborted = new Promise<never>((_resolve, reject) => {
300
- rejectForAbort = reject;
301
- });
302
- const onAbort = () => rejectForAbort?.(signal?.reason);
323
+ const onAbort = () => waiting.interrupt({ error: signal?.reason });
303
324
  signal?.addEventListener("abort", onAbort, { once: true });
304
325
  // Close the narrow race between the preflight check and listener install.
305
326
  if (signal?.aborted) onAbort();
306
327
 
307
328
  try {
308
329
  while (true) {
309
- // Attach a rejection handler before racing. If a deadline wins and
310
- // cancellation later rejects this read, it remains observed.
311
- const read = reader.read();
312
- void read.catch(() => undefined);
313
- const outcome = await Promise.race([read, total.promise, inactivity.promise, aborted]);
330
+ const outcome = await waiting.read();
314
331
  // Cancellation owns the body lifetime even when EOF/readability settles in
315
- // the same turn. Promise.race otherwise lets array order hide the abort.
332
+ // the same turn, even if the read settled before the abort callback.
316
333
  if (signal?.aborted) {
317
334
  mustCancel = true;
318
335
  cancelReason = signal.reason;
@@ -370,9 +387,9 @@ export async function readBoundedResponseBody(
370
387
  if (!value || value.byteLength === 0) continue;
371
388
 
372
389
  inactivity.clear();
373
- inactivity = timeoutPromise(
390
+ inactivity = bodyDeadline(
374
391
  options.inactivityTimeoutMs ?? BOUNDED_BODY_TIMEOUT_MS,
375
- INACTIVITY_TIMEOUT,
392
+ () => waiting.interrupt({ value: INACTIVITY_TIMEOUT }),
376
393
  );
377
394
 
378
395
  if (value.byteLength > maxBytes - retainedBytes) {
@@ -267,9 +267,8 @@ function removeOwnedEntry(root: string, path: string): void {
267
267
  rmdirSync(path);
268
268
  }
269
269
 
270
- export function recordOwnedConfigPath(configDir: string, candidatePath: string): boolean {
271
- const rel = manifestRelativePath(configDir, candidatePath);
272
- if (!rel) return false;
270
+ /** Initialize only an empty or already-owned root; do not claim a candidate path. */
271
+ export function initializeConfigOwnership(configDir: string): boolean {
273
272
  const cacheKey = ownershipCacheKey(configDir);
274
273
  if (!existsSync(configDir)) {
275
274
  ownershipCache.delete(cacheKey);
@@ -280,6 +279,15 @@ export function recordOwnedConfigPath(configDir: string, candidatePath: string):
280
279
  ownership = loadOwnership(configDir) ?? createOwnership(configDir);
281
280
  ownershipCache.set(cacheKey, ownership);
282
281
  }
282
+ return ownership !== null;
283
+ }
284
+
285
+ export function recordOwnedConfigPath(configDir: string, candidatePath: string): boolean {
286
+ const rel = manifestRelativePath(configDir, candidatePath);
287
+ if (!rel) return false;
288
+ if (!initializeConfigOwnership(configDir)) return false;
289
+ const cacheKey = ownershipCacheKey(configDir);
290
+ const ownership = ownershipCache.get(cacheKey);
283
291
  if (!ownership) return false;
284
292
  if (ownership.manifest.paths.includes(rel)) return true;
285
293
  const manifest = {
@@ -337,25 +345,6 @@ export function removeOwnedConfigState(configDir: string): ConfigRemovalResult {
337
345
  }
338
346
  }
339
347
 
340
- // Per-catalog backups are named `catalog-backup-<16 hex>.json` (catalogBackupPathFor), one per
341
- // CODEX_HOME, so they cannot be enumerated as literal manifest entries the way every other
342
- // owned file can. Without this, `ocx uninstall` always reported "unowned files remain" and
343
- // refused to remove a home OpenCodex created itself — the file is unambiguously ours, produced
344
- // by our own writer, and the strict hex shape keeps the match from widening.
345
- for (const name of readdirSync(configDir)) {
346
- if (!/^catalog-backup-[0-9a-f]{16}\.json$/.test(name)) continue;
347
- const path = join(configDir, name);
348
- try {
349
- removeOwnedEntry(rootPath, path);
350
- } catch (error) {
351
- return {
352
- status: "partial",
353
- reason: `could not remove owned path ${name}: ${error instanceof Error ? error.message : String(error)}`,
354
- residualPaths: [path],
355
- };
356
- }
357
- }
358
-
359
348
  try {
360
349
  unlinkSync(join(configDir, CONFIG_UNINSTALL_MANIFEST));
361
350
  unlinkSync(join(configDir, CONFIG_OWNER_FILE));
@@ -0,0 +1,56 @@
1
+ import { createHash, createHmac, timingSafeEqual } from "node:crypto";
2
+ import { isLocalAttestationSecret } from "./local-management-attestation";
3
+ import { LOCAL_MANAGEMENT_CAPABILITY_TTL_MS } from "./local-management-capability";
4
+
5
+ export const LOCAL_DESKTOP_SNAPSHOT_PATH = "/api/update/desktop-snapshot";
6
+ export const LOCAL_DESKTOP_SNAPSHOT_BODY_HEADER = "x-opencodex-desktop-snapshot-sha256";
7
+ const BASE64URL_256 = /^[A-Za-z0-9_-]{43}$/;
8
+
9
+ /** Digest the exact bytes sent on the wire, before JSON parsing or reserialization. */
10
+ export function desktopSnapshotBodyDigest(body: Uint8Array): string {
11
+ return createHash("sha256").update(body).digest("base64url");
12
+ }
13
+
14
+ /** A separate, body-bound grant for the one bounded desktop display-state POST. */
15
+ export function createLocalDesktopSnapshotCapability(
16
+ secret: string,
17
+ nonce: string,
18
+ method: string,
19
+ path: string,
20
+ pid: number,
21
+ port: number,
22
+ expiresAt: number,
23
+ bodyDigest: string,
24
+ ): string | null {
25
+ if (!isLocalAttestationSecret(secret) || !BASE64URL_256.test(nonce)) return null;
26
+ if (method !== "POST" || path !== LOCAL_DESKTOP_SNAPSHOT_PATH) return null;
27
+ if (!Number.isSafeInteger(pid) || pid <= 0) return null;
28
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) return null;
29
+ if (!Number.isSafeInteger(expiresAt) || expiresAt <= 0) return null;
30
+ if (!BASE64URL_256.test(bodyDigest)) return null;
31
+ const payload = `opencodex-local-desktop-snapshot-v1\n${nonce}\n${method}\n${path}\n${pid}\n${port}\n${expiresAt}\n${bodyDigest}`;
32
+ return createHmac("sha256", secret).update(payload).digest("base64url");
33
+ }
34
+
35
+ /** Verify scope, lifetime and MAC; admission separately consumes the grant once. */
36
+ export function verifyLocalDesktopSnapshotCapability(
37
+ secret: string,
38
+ nonce: string | null,
39
+ method: string,
40
+ path: string,
41
+ pid: number,
42
+ port: number,
43
+ expiresAt: number,
44
+ bodyDigest: string | null,
45
+ capability: string | null,
46
+ now = Date.now(),
47
+ ): boolean {
48
+ if (!nonce || !bodyDigest || !capability || !BASE64URL_256.test(capability)) return false;
49
+ if (!Number.isSafeInteger(now) || expiresAt <= now
50
+ || expiresAt > now + LOCAL_MANAGEMENT_CAPABILITY_TTL_MS) return false;
51
+ const expected = createLocalDesktopSnapshotCapability(
52
+ secret, nonce, method, path, pid, port, expiresAt, bodyDigest,
53
+ );
54
+ if (!expected) return false;
55
+ return timingSafeEqual(Buffer.from(expected), Buffer.from(capability));
56
+ }
@@ -9,7 +9,16 @@ export const LOCAL_MANAGEMENT_CAPABILITY_TTL_MS = 10_000;
9
9
 
10
10
  export const LOCAL_MANAGEMENT_READ_PATHS = {
11
11
  codexAccounts: "/api/codex-auth/accounts",
12
+ codexAuthActive: "/api/codex-auth/active",
13
+ oauthAccounts: "/api/oauth/accounts",
14
+ providerKeys: "/api/providers/keys",
15
+ config: "/api/config",
12
16
  systemMemory: "/api/system/memory",
17
+ companionSettings: "/api/companion/settings",
18
+ usage: "/api/usage",
19
+ startupHealth: "/api/startup-health",
20
+ providerQuotas: "/api/provider-quotas",
21
+ usageTimeline: "/api/usage/timeline",
13
22
  } as const;
14
23
 
15
24
  export type LocalManagementReadPath =
@@ -32,7 +41,16 @@ export function parseExpectedLocalManagementPid(value: string | null): ExpectedL
32
41
 
33
42
  function isLocalManagementReadPath(path: string): path is LocalManagementReadPath {
34
43
  return path === LOCAL_MANAGEMENT_READ_PATHS.codexAccounts
35
- || path === LOCAL_MANAGEMENT_READ_PATHS.systemMemory;
44
+ || path === LOCAL_MANAGEMENT_READ_PATHS.codexAuthActive
45
+ || path === LOCAL_MANAGEMENT_READ_PATHS.oauthAccounts
46
+ || path === LOCAL_MANAGEMENT_READ_PATHS.providerKeys
47
+ || path === LOCAL_MANAGEMENT_READ_PATHS.config
48
+ || path === LOCAL_MANAGEMENT_READ_PATHS.systemMemory
49
+ || path === LOCAL_MANAGEMENT_READ_PATHS.companionSettings
50
+ || path === LOCAL_MANAGEMENT_READ_PATHS.usage
51
+ || path === LOCAL_MANAGEMENT_READ_PATHS.startupHealth
52
+ || path === LOCAL_MANAGEMENT_READ_PATHS.providerQuotas
53
+ || path === LOCAL_MANAGEMENT_READ_PATHS.usageTimeline;
36
54
  }
37
55
 
38
56
  function localReadCapabilityPayload(
@@ -44,7 +62,11 @@ function localReadCapabilityPayload(
44
62
  expiresAt: number,
45
63
  ): string | null {
46
64
  if (!BASE64URL_256.test(nonce)) return null;
47
- if (method !== LOCAL_READ_METHOD || !isLocalManagementReadPath(path)) return null;
65
+ // `path` arrives as pathname+search. The allowlist judges the pathname; the query is
66
+ // still signed into the payload, so a capability minted for one range cannot be replayed
67
+ // against another. Query-less paths keep the exact v1 payload shape.
68
+ const pathname = path.split("?", 1)[0]!;
69
+ if (method !== LOCAL_READ_METHOD || !isLocalManagementReadPath(pathname)) return null;
48
70
  if (!Number.isSafeInteger(pid) || pid <= 0) return null;
49
71
  if (!Number.isInteger(port) || port <= 0 || port > 65535) return null;
50
72
  if (!Number.isSafeInteger(expiresAt) || expiresAt <= 0) return null;
@@ -56,7 +78,7 @@ export function createLocalManagementReadCapability(
56
78
  secret: string,
57
79
  nonce: string,
58
80
  method: string,
59
- path: string,
81
+ path: string, // pathname plus any query; the query is bound into the signature
60
82
  pid: number,
61
83
  port: number,
62
84
  expiresAt: number,
@@ -22,12 +22,22 @@
22
22
  type ShutdownHook = () => void;
23
23
 
24
24
  const hooks = new Map<string, ShutdownHook>();
25
+ let hooksRan = false;
25
26
 
26
27
  /**
27
28
  * Register (or replace) the teardown for one optional subsystem.
28
29
  *
29
30
  * Keyed so repeated activation of the same subsystem cannot accumulate duplicate hooks.
30
31
  * Returns a detach function so an owner-scoped lease can release its registration.
32
+ *
33
+ * Registration after a sweep is NOT retro-applied: the hook waits for the next
34
+ * `runOptionalShutdownHooks`, which a draining process never reaches. Callers whose work
35
+ * must not outlive the sweep should gate on `didRunOptionalShutdownHooks`.
36
+ *
37
+ * The `hooksRan` latch is process-lifetime: every production caller runs the sweep inside
38
+ * `drainAndShutdown`, whose callers then exit or hand off to a newly spawned process —
39
+ * there is no in-process restart after a sweep. `resetOptionalShutdownHooksForTests`
40
+ * models that fresh process; it is the only way a post-sweep subsystem may start again.
31
41
  */
32
42
  export function registerOptionalShutdownHook(key: string, hook: ShutdownHook): () => void {
33
43
  hooks.set(key, hook);
@@ -37,8 +47,14 @@ export function registerOptionalShutdownHook(key: string, hook: ShutdownHook): (
37
47
  };
38
48
  }
39
49
 
50
+ /** Whether `runOptionalShutdownHooks` has run at least once since the last test reset. */
51
+ export function didRunOptionalShutdownHooks(): boolean {
52
+ return hooksRan;
53
+ }
54
+
40
55
  /** Run every registered teardown. Never throws. */
41
56
  export function runOptionalShutdownHooks(): void {
57
+ hooksRan = true;
42
58
  for (const [key, hook] of [...hooks]) {
43
59
  try {
44
60
  hook();
@@ -54,4 +70,5 @@ export function runOptionalShutdownHooks(): void {
54
70
  /** Test-only reset so an isolated lifecycle test does not inherit registrations. */
55
71
  export function resetOptionalShutdownHooksForTests(): void {
56
72
  hooks.clear();
73
+ hooksRan = false;
57
74
  }
@@ -37,7 +37,7 @@ import { listLiveOAuthAccountKeys, reconcileOAuthReauthState } from "../oauth/st
37
37
  import { reconcileGuardianBackoff } from "../oauth/token-guardian";
38
38
  import { sweepExpiredApiKeyCooldowns } from "../providers/key-failover";
39
39
  import { reconcileProviderRequestPacing } from "../providers/request-pacing";
40
- import { sweepAbandonedResponseStateTemps, sweepExpiredResponseStates } from "../responses/state";
40
+ import { sweepAbandonedResponseStateTemps, sweepExpiredResponseStates, sweepOrphanedResponseSpills } from "../responses/state";
41
41
  import { sweepExpiredAntigravityReplay } from "../adapters/google-antigravity-replay";
42
42
  import { reconcileProviderAccountQuotaRows } from "../providers/quota";
43
43
  import { reconcileRouterWarningMemos } from "../router";
@@ -98,7 +98,7 @@ export const STATE_STORE_REGISTRATIONS = [
98
98
  sweepExpired: sweepExpiredResponseStates,
99
99
  // Disk reclaim rides the liveness tick, not the TTL tick: sweepExpiredOnWrite puts
100
100
  // sweepExpired on hot write paths, where a directory scan does not belong.
101
- sweepLiveness: sweepAbandonedResponseStateTemps,
101
+ sweepLiveness: () => sweepAbandonedResponseStateTemps() + sweepOrphanedResponseSpills(),
102
102
  },
103
103
  { name: "antigravity-replay", sweepExpired: sweepExpiredAntigravityReplay },
104
104
  { name: "config-warning-memos", reconcileGeneration: (context: GenerationContext) => reconcileConfigWarningMemos(context.generation) },