@bitkyc08/opencodex 2.65.0 → 2.66.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.
- package/README.md +14 -0
- package/gui/dist/assets/App-DyHlFeNB.js +51 -0
- package/gui/dist/assets/App-gkoJIEOV.css +1 -0
- package/gui/dist/assets/{Tray-D4bA1vff.js → Tray-CI8cVKWu.js} +1 -1
- package/gui/dist/assets/index-BiwnVAUm.js +86 -0
- package/gui/dist/assets/index-ComVsdSk.css +1 -0
- package/gui/dist/assets/{tray-data-BZXhB3qz.js → tray-data-_WyYzbMt.js} +1 -1
- package/gui/dist/index.html +2 -2
- package/package.json +1 -2
- package/src/adapters/anthropic/beta-allowlist.ts +80 -0
- package/src/adapters/anthropic/passthrough.ts +221 -0
- package/src/adapters/anthropic-image-codec.ts +56 -19
- package/src/adapters/anthropic-image-normalize.ts +20 -10
- package/src/adapters/anthropic.ts +55 -24
- package/src/adapters/coding-agent/protocol.ts +29 -19
- package/src/adapters/cursor/live-transport.ts +18 -3
- package/src/adapters/cursor/native-exec-shell.ts +18 -63
- package/src/adapters/cursor/native-exec.ts +5 -2
- package/src/adapters/cursor/native-foreground-shell.ts +44 -0
- package/src/adapters/devin/cloud-direct/chat.ts +7 -1
- package/src/adapters/exec-tool-result-normalize.ts +4 -1
- package/src/adapters/google.ts +12 -0
- package/src/adapters/ollama-native.ts +32 -1
- package/src/adapters/openai-chat/serialized-tool-call-content.ts +131 -34
- package/src/adapters/openai-chat.ts +6 -4
- package/src/adapters/openai-responses/passthrough.ts +1 -0
- package/src/adapters/openai-responses/reasoning.ts +45 -1
- package/src/bridge/response-json.ts +10 -2
- package/src/chat/outbound.ts +104 -52
- package/src/claude/claude-code-block.ts +16 -0
- package/src/claude/context-windows.ts +52 -4
- package/src/claude/desktop-first-party.ts +25 -8
- package/src/claude/inbound.ts +22 -0
- package/src/claude/intercept/connect-proxy.ts +37 -4
- package/src/claude/intercept/proxy-auth.ts +166 -0
- package/src/claude/intercept/runtime.ts +21 -0
- package/src/claude/intercept/settings.ts +33 -12
- package/src/claude/outbound.ts +45 -27
- package/src/cli/account-extended.ts +47 -15
- package/src/cli/account-target.ts +115 -0
- package/src/cli/account.ts +20 -28
- package/src/cli/agent.ts +10 -1
- package/src/cli/api-protocols.ts +206 -0
- package/src/cli/capabilities.ts +86 -0
- package/src/cli/combo.ts +2 -2
- package/src/cli/connect.ts +127 -3
- package/src/cli/dispatch.ts +8 -0
- package/src/cli/doctor.ts +33 -0
- package/src/cli/help.ts +2 -0
- package/src/cli/link.ts +288 -0
- package/src/cli/registry.ts +23 -0
- package/src/cli/runtime-api.ts +79 -0
- package/src/cli/system-command.ts +21 -2
- package/src/client/connect.ts +125 -23
- package/src/client/hub-relay.ts +12 -8
- package/src/client/link-join.ts +323 -0
- package/src/client/link-relay.ts +241 -0
- package/src/client/link-state.ts +110 -0
- package/src/client/link-teardown.ts +63 -0
- package/src/client/link-tunnel.ts +465 -0
- package/src/client/machine-listener.ts +14 -5
- package/src/client/runtime.ts +91 -42
- package/src/client/state.ts +4 -0
- package/src/codex/catalog/build-entries.ts +4 -0
- package/src/codex/catalog/derive-entry.ts +5 -0
- package/src/codex/catalog/parsing.ts +4 -0
- package/src/codex/catalog/retained-sync.ts +4 -1
- package/src/codex/desktop-switches.ts +88 -8
- package/src/codex/inject/plan.ts +16 -3
- package/src/codex/inject.ts +3 -0
- package/src/codex/internal/catalog-writer.ts +13 -2
- package/src/combos/failover.ts +3 -2
- package/src/combos/index.ts +12 -0
- package/src/combos/jev.ts +646 -0
- package/src/combos/request.ts +5 -0
- package/src/combos/resolve.ts +17 -15
- package/src/combos/types.ts +47 -3
- package/src/config/atomic-write.ts +3 -0
- package/src/config/live-reconcile.ts +20 -4
- package/src/config/persist-unlocked.ts +20 -7
- package/src/config/schema/config-schema.ts +16 -0
- package/src/config/schema/leaf-validators.ts +38 -1
- package/src/generated/compatibility-version.json +416 -128
- package/src/integrations/omp-yaml-source.ts +109 -4
- package/src/lab/automation/orchestrator.ts +60 -4
- package/src/lab/events/limits.ts +1 -1
- package/src/lib/bounded-body.ts +55 -38
- package/src/lib/config-ownership.ts +11 -22
- package/src/lib/local-desktop-snapshot-capability.ts +56 -0
- package/src/lib/local-management-capability.ts +25 -3
- package/src/lib/optional-shutdown-hooks.ts +17 -0
- package/src/lib/state-store-registrations.ts +2 -2
- package/src/lib/windows-elevation.ts +62 -25
- package/src/lib/windows-secret-acl.ts +114 -23
- package/src/link/admission-wait.ts +73 -0
- package/src/link/compensation.ts +100 -0
- package/src/link/fingerprint.ts +21 -0
- package/src/link/paths.ts +19 -0
- package/src/link/ports.ts +12 -0
- package/src/link/routes.ts +32 -0
- package/src/link/ssh-argv.ts +170 -0
- package/src/link/ssh-config.ts +152 -0
- package/src/link/ssh-runner.ts +161 -0
- package/src/link/status-projection.ts +96 -0
- package/src/link/store.ts +139 -0
- package/src/link/supervisor.ts +404 -0
- package/src/link/tunnel-state.ts +91 -0
- package/src/protocols/baseline.ts +88 -0
- package/src/protocols/codecs/chat.ts +17 -0
- package/src/protocols/codecs/messages.ts +18 -0
- package/src/protocols/codecs/responses.ts +17 -0
- package/src/protocols/contract.ts +185 -0
- package/src/protocols/dto.ts +267 -0
- package/src/protocols/encoders/adapter-events.ts +876 -0
- package/src/protocols/encoders/chat.ts +243 -0
- package/src/protocols/encoders/messages.ts +462 -0
- package/src/protocols/envelope.ts +43 -0
- package/src/protocols/features.ts +295 -0
- package/src/protocols/guard.ts +33 -0
- package/src/protocols/opaque-state.ts +132 -0
- package/src/protocols/path.ts +39 -0
- package/src/protocols/plan-snapshot.ts +226 -0
- package/src/protocols/plan.ts +192 -0
- package/src/protocols/provider-summary.ts +101 -0
- package/src/protocols/settings.ts +118 -0
- package/src/protocols/shadow-plan.ts +36 -0
- package/src/protocols/shadow.ts +62 -0
- package/src/protocols/trace.ts +335 -0
- package/src/providers/model-rename-migration.ts +3 -3
- package/src/providers/registry/entries-core.ts +6 -1
- package/src/providers/registry/entries-extended.ts +15 -0
- package/src/providers/registry/model-ids.ts +1 -0
- package/src/providers/registry/types.ts +6 -0
- package/src/providers/registry.ts +1 -1
- package/src/remote-control/workspace-hub.ts +5 -4
- package/src/responses/apply-patch-envelope.ts +6 -1
- package/src/responses/code-mode-shell-input.ts +4 -2
- package/src/responses/freeform-wrapper-scan.ts +51 -24
- package/src/responses/progressive-freeform-input.ts +14 -20
- package/src/responses/spill-store.ts +187 -33
- package/src/responses/state/snapshot-select.ts +49 -0
- package/src/responses/state/spill-inspect.ts +46 -0
- package/src/responses/state/spill-queue.ts +16 -0
- package/src/responses/state.ts +51 -27
- package/src/server/audio-client.ts +8 -3
- package/src/server/audio-upstream.ts +9 -4
- package/src/server/auth-cors.ts +25 -8
- package/src/server/chat-completions.ts +71 -11
- package/src/server/chat-native-eligibility.ts +74 -0
- package/src/server/chat-native.ts +139 -74
- package/src/server/claude-messages.ts +140 -22
- package/src/server/hub-usage.ts +4 -3
- package/src/server/index/link-listener.ts +195 -0
- package/src/server/index/optional-listeners.ts +115 -0
- package/src/server/index/serve-options.ts +44 -10
- package/src/server/index.ts +10 -9
- package/src/server/inference/attempt.ts +51 -0
- package/src/server/inference/client-encoder-delivery.ts +235 -0
- package/src/server/inference/client-wire-log.ts +49 -0
- package/src/server/inference/client-wire.ts +69 -0
- package/src/server/inference/context.ts +15 -0
- package/src/server/inference/final-log.ts +37 -0
- package/src/server/local-desktop-snapshot-auth.ts +57 -0
- package/src/server/management/agent-settings-routes.ts +15 -13
- package/src/server/management/api-access.ts +11 -1
- package/src/server/management/config-routes.ts +3 -6
- package/src/server/management/context.ts +25 -0
- package/src/server/management/link-routes.ts +535 -0
- package/src/server/management/logs-usage-routes.ts +31 -1
- package/src/server/management/native-integration-routes.ts +6 -11
- package/src/server/management/oauth-account-routes.ts +42 -16
- package/src/server/management/protocol-routes.ts +159 -0
- package/src/server/management/protocol-settings-patch.ts +145 -0
- package/src/server/management/provider-patch-transaction.ts +61 -0
- package/src/server/management/provider-routes.ts +39 -18
- package/src/server/management/route-registry.ts +12 -0
- package/src/server/management/sidebar-routes.ts +8 -3
- package/src/server/management/system-routes.ts +18 -1
- package/src/server/management/usage-aggregate-cache.ts +206 -6
- package/src/server/management-api.ts +26 -1
- package/src/server/management-auth.ts +19 -5
- package/src/server/messages-native-eligibility.ts +161 -0
- package/src/server/messages-native-oauth.ts +149 -0
- package/src/server/messages-native.ts +790 -0
- package/src/server/relay.ts +9 -0
- package/src/server/request-log-filter.ts +92 -0
- package/src/server/request-log.ts +31 -70
- package/src/server/responses/adapter-delivery.ts +43 -16
- package/src/server/responses/agent-task-recovery.ts +90 -22
- package/src/server/responses/codex-ws-exchange.ts +4 -0
- package/src/server/responses/codex-ws-wire.ts +10 -1
- package/src/server/responses/core-combo-native.ts +323 -0
- package/src/server/responses/core-combo.ts +224 -13
- package/src/server/responses/core-options.ts +22 -0
- package/src/server/responses/core.ts +14 -26
- package/src/server/responses/request-sidecar-auth.ts +8 -3
- package/src/server/responses/run-turn-execution.ts +257 -63
- package/src/server/responses/sidecar-execution.ts +7 -4
- package/src/server/system-env.ts +7 -0
- package/src/service/diagnostics.ts +7 -1
- package/src/service/launchd.ts +26 -37
- package/src/service/windows-ops.ts +24 -20
- package/src/types/config.ts +58 -1
- package/src/types.ts +2 -0
- package/src/update/transactional-install.d.mts +0 -1
- package/src/update/transactional-install.mjs +17 -17
- package/src/usage/jev-stats.ts +495 -0
- package/src/usage/log.ts +24 -3
- package/src/web-search/run-turn-loop.ts +568 -0
- package/gui/dist/assets/App-8NMiZxT0.css +0 -1
- package/gui/dist/assets/App-DGLte6IR.js +0 -50
- package/gui/dist/assets/index--EWgGQvZ.css +0 -1
- 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.
|
|
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))
|
|
181
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
477
|
-
|
|
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
|
+
}
|
package/src/lab/events/limits.ts
CHANGED
|
@@ -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])(
|
|
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
|
|
package/src/lib/bounded-body.ts
CHANGED
|
@@ -88,15 +88,46 @@ export function boundedBodyBufferGrowthsForTests(): number {
|
|
|
88
88
|
return bufferGrowthsForTests;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
-
function
|
|
92
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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, () =>
|
|
184
|
+
: idleDeadline(options.inactivityTimeoutMs, () => waiting.interrupt({ error: inactivityReason }));
|
|
157
185
|
inactivity?.reset();
|
|
158
186
|
|
|
159
|
-
|
|
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
|
|
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
|
|
293
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
271
|
-
|
|
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.
|
|
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
|
-
|
|
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) },
|