@bitkyc08/opencodex 2.64.0 → 2.65.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.
- package/README.md +28 -25
- package/bin/ocx.mjs +16 -2
- package/gui/dist/assets/App-8NMiZxT0.css +1 -0
- package/gui/dist/assets/App-CsYvvpr3.js +50 -0
- package/gui/dist/assets/Tray-DUvc_Wul.js +1 -0
- package/gui/dist/assets/{index-DiBRuK-d.css → index--EWgGQvZ.css} +1 -1
- package/gui/dist/assets/{index-SggB6t3z.js → index-BB0iHG8-.js} +12 -12
- package/gui/dist/assets/tray-data-f0lTZ4sF.js +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -4
- package/src/adapters/anthropic.ts +30 -1
- package/src/adapters/claude-cli/adapter.ts +218 -0
- package/src/adapters/claude-cli/profiles.ts +38 -0
- package/src/adapters/codebuddy/profiles.ts +2 -0
- package/src/adapters/coding-agent/profile.ts +12 -4
- package/src/adapters/coding-agent/turn.ts +16 -6
- package/src/adapters/command-code-tool-text.ts +133 -23
- package/src/adapters/cursor/catalog.ts +4 -8
- package/src/adapters/cursor/discovery.ts +10 -5
- package/src/adapters/cursor/effort-map.ts +2 -6
- package/src/adapters/cursor/envelope-echo.ts +10 -5
- package/src/adapters/cursor/request-builder.ts +9 -4
- package/src/adapters/cursor/thread-continuity.ts +53 -0
- package/src/adapters/cursor.ts +29 -13
- package/src/adapters/devin/cloud-direct/chat.ts +3 -1
- package/src/adapters/google-tool-schema.ts +26 -3
- package/src/adapters/identity.ts +214 -2
- package/src/adapters/openai-chat/passthrough.ts +1 -0
- package/src/adapters/openai-chat/serialized-tool-call-content.ts +569 -0
- package/src/adapters/openai-chat.ts +43 -15
- package/src/adapters/openai-responses/canonical-forward.ts +17 -10
- package/src/adapters/openai-responses/passthrough.ts +19 -4
- package/src/adapters/openai-responses/request-strips.ts +25 -0
- package/src/adapters/openai-responses/web-search.ts +22 -4
- package/src/adapters/qoder/profiles.ts +2 -0
- package/src/adapters/registry.ts +8 -0
- package/src/adapters/run-turn-queue.ts +7 -0
- package/src/bridge/response-json.ts +6 -4
- package/src/bridge/sse.ts +7 -5
- package/src/claude/alias.ts +73 -26
- package/src/claude/context-windows.ts +15 -6
- package/src/claude/desktop-3p-library.ts +3 -2
- package/src/claude/desktop-3p.ts +1 -1
- package/src/claude/desktop-first-party.ts +63 -22
- package/src/claude/desktop-picker-profile.ts +302 -0
- package/src/claude/desktop-picker.ts +365 -0
- package/src/claude/desktop-risk.ts +20 -0
- package/src/claude/gateway-cache.ts +13 -4
- package/src/claude/inbound.ts +5 -2
- package/src/claude/intercept/connect-proxy.ts +106 -29
- package/src/claude/intercept/local-ca.ts +76 -18
- package/src/claude/intercept/model-bindings.ts +145 -0
- package/src/claude/intercept/picker-bootstrap.ts +141 -0
- package/src/claude/intercept/picker-ca.ts +126 -0
- package/src/claude/intercept/picker-listener.ts +213 -0
- package/src/claude/intercept/picker-models.ts +101 -0
- package/src/claude/intercept/picker-runtime.ts +330 -0
- package/src/claude/intercept/picker-trust.ts +125 -0
- package/src/claude/intercept/runtime.ts +149 -2
- package/src/claude/model-info.ts +18 -4
- package/src/cli/agent.ts +16 -2
- package/src/cli/capabilities.ts +71 -2
- package/src/cli/claude-desktop.ts +319 -33
- package/src/cli/claude.ts +15 -7
- package/src/cli/codex-shim-autorestore.ts +1 -0
- package/src/cli/dispatch.ts +29 -23
- package/src/cli/effort.ts +16 -6
- package/src/cli/ensure-desired-integrations.ts +29 -4
- package/src/cli/ready.ts +1 -1
- package/src/cli/registry.ts +8 -0
- package/src/cli/restart-scope.ts +9 -1
- package/src/cli/runtime-api.ts +17 -0
- package/src/cli/system-command.ts +11 -12
- package/src/client/connect.ts +12 -2
- package/src/client/state.ts +39 -1
- package/src/clients/config-export.ts +57 -9
- package/src/codex/auth-context.ts +51 -11
- package/src/codex/catalog/derive-entry.ts +5 -5
- package/src/codex/catalog/gather-capture.ts +25 -6
- package/src/codex/catalog/metadata.ts +3 -3
- package/src/codex/catalog/parsing.ts +39 -1
- package/src/codex/catalog/provider-models.ts +8 -6
- package/src/codex/catalog/retained-sync.ts +17 -7
- package/src/codex/catalog/sync.ts +2 -2
- package/src/codex/desktop-app-restart.ts +8 -0
- package/src/codex/desktop-switches.ts +9 -1
- package/src/codex/home.ts +43 -4
- package/src/codex/inject/config-toml.ts +137 -0
- package/src/codex/inject/plan.ts +23 -0
- package/src/codex/inject/remove.ts +21 -1
- package/src/codex/inject.ts +63 -8
- package/src/codex/journal.ts +36 -0
- package/src/codex/main-account-hard-lock.ts +14 -1
- package/src/codex/main-account-policy-wait.ts +109 -0
- package/src/codex/native-profile-startup.ts +24 -6
- package/src/codex/native-residue.ts +28 -10
- package/src/codex/quota-types.ts +8 -1
- package/src/codex/quota.ts +49 -7
- package/src/codex/runtime.ts +31 -10
- package/src/combos/failover.ts +7 -6
- package/src/combos/resolve.ts +91 -14
- package/src/combos/types.ts +18 -1
- package/src/config/load-degrade.ts +2 -0
- package/src/config/schema/config-schema.ts +21 -2
- package/src/config/schema/leaf-validators.ts +6 -3
- package/src/config/subagent-models.ts +3 -1
- package/src/generated/compatibility-version.json +275 -163
- package/src/images/artifacts.ts +14 -0
- package/src/integrations/owned-refresh.ts +1 -1
- package/src/integrations/ownership-policy.ts +32 -1
- package/src/integrations/state.ts +3 -1
- package/src/integrations/writer.ts +9 -0
- package/src/lib/config-ownership.ts +3 -0
- package/src/lib/errors.ts +87 -0
- package/src/lib/package-tree-integrity.ts +1 -1
- package/src/lib/request-execution-budget.ts +42 -14
- package/src/lib/request-resend-gate.ts +4 -3
- package/src/lib/retry-delay.ts +7 -1
- package/src/lib/tool-envelope-echo-filter.ts +236 -0
- package/src/lib/upstream-retry.ts +20 -12
- package/src/oauth/index.ts +1 -0
- package/src/oauth/kiro-credentials.ts +14 -1
- package/src/oauth/login-cli.ts +1 -0
- package/src/providers/derive.ts +4 -0
- package/src/providers/fast-opt-in.ts +31 -0
- package/src/providers/model-rename-fields.ts +2 -0
- package/src/providers/provider-id-rewrite.ts +2 -0
- package/src/providers/quota/vendor-probes-key.ts +8 -2
- package/src/providers/registry/entries-core.ts +45 -2
- package/src/providers/registry/entries-extended.ts +99 -12
- package/src/providers/registry/model-ids.ts +2 -0
- package/src/providers/registry/types.ts +7 -1
- package/src/providers/resolved-model-policy.ts +11 -4
- package/src/providers/service-tier.ts +10 -3
- package/src/responses/code-mode-helper-compat.ts +3 -0
- package/src/responses/parser.ts +45 -3
- package/src/router.ts +6 -0
- package/src/server/auth-cors.ts +8 -0
- package/src/server/chat-completions.ts +3 -1
- package/src/server/claude-messages.ts +69 -14
- package/src/server/grok-upstream-envelope-echo.ts +120 -0
- package/src/server/index/claude-intercept-lifecycle.ts +24 -0
- package/src/server/index/serve-options.ts +2 -2
- package/src/server/index.ts +7 -0
- package/src/server/management/agent-settings-routes.ts +204 -112
- package/src/server/management/claude-desktop-picker-routes.ts +128 -0
- package/src/server/management/combo-routes.ts +59 -2
- package/src/server/management/config-routes.ts +81 -39
- package/src/server/management/context.ts +3 -0
- package/src/server/management/native-integration-routes.ts +169 -93
- package/src/server/management/provider-routes.ts +46 -4
- package/src/server/management/route-registry.ts +10 -0
- package/src/server/management/routing-profile-routes.ts +9 -0
- package/src/server/management/sidebar-routes.ts +52 -0
- package/src/server/management-api.ts +2 -0
- package/src/server/proxy-liveness.ts +38 -3
- package/src/server/request-log-account-rotation.ts +53 -0
- package/src/server/request-log.ts +3 -16
- package/src/server/responses/adapter-dispatch.ts +17 -1
- package/src/server/responses/agent-task-recovery.ts +49 -26
- package/src/server/responses/codex-ws-exchange.ts +18 -7
- package/src/server/responses/codex-ws-wire.ts +27 -4
- package/src/server/responses/core-combo.ts +21 -3
- package/src/server/responses/core-normalize.ts +7 -0
- package/src/server/responses/core-opaque-recovery.ts +3 -2
- package/src/server/responses/encrypted-payload.ts +15 -14
- package/src/server/responses/fetch-helpers.ts +1 -1
- package/src/server/responses/input-admission.ts +10 -6
- package/src/server/responses/native-response-control.ts +2 -2
- package/src/server/responses/passthrough-delivery.ts +182 -12
- package/src/server/responses/passthrough-dispatch.ts +102 -44
- package/src/server/responses/policy-fallback.ts +3 -0
- package/src/server/responses/policy-refusal.ts +67 -0
- package/src/server/responses/request-send-budget.ts +4 -0
- package/src/server/responses/run-turn-execution.ts +42 -11
- package/src/server/responses/ws-upstream.ts +2 -1
- package/src/server/system-env-shell.ts +0 -1
- package/src/server/system-env.ts +25 -4
- package/src/service/cli.ts +34 -3
- package/src/tray/assets/opencodex-tray-offline-update.ico +0 -0
- package/src/tray/assets/opencodex-tray-online-update.ico +0 -0
- package/src/tray/assets/opencodex-tray-warning-update.ico +0 -0
- package/src/tray/windows-tray.ps1 +188 -7
- package/src/tray/windows.ts +23 -2
- package/src/types/config.ts +52 -12
- package/src/types/provider.ts +22 -5
- package/src/types/tools.ts +11 -5
- package/src/types.ts +1 -0
- package/src/update/async-check.ts +109 -0
- package/src/update/badge.ts +11 -17
- package/src/update/check-types.ts +9 -0
- package/src/update/desktop-badge.ts +102 -0
- package/src/update/index.ts +48 -8
- package/src/update/install-detection.d.mts +29 -2
- package/src/update/install-detection.mjs +213 -7
- package/src/update/job.ts +17 -12
- package/src/update/notify.ts +16 -11
- package/src/update/pnpm-owner-worker.ts +12 -0
- package/src/update/refresh-scheduler.ts +141 -0
- package/src/usage/cost.ts +31 -4
- package/src/usage/expected-prices.ts +57 -0
- package/assets/download-linux.svg +0 -10
- package/assets/download-macos.svg +0 -10
- package/assets/download-windows.svg +0 -10
- package/gui/dist/assets/App-CpuDF3ci.js +0 -50
- package/gui/dist/assets/App-I5AnaSLh.css +0 -1
- package/gui/dist/assets/Tray-B4uEIa1O.js +0 -1
- package/gui/dist/assets/usage-companion-chart-DLbKOJml.js +0 -1
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
REALTIME_WS_BASE_URL_KEY,
|
|
9
9
|
isRootOpenaiBaseUrlLine,
|
|
10
10
|
isRootRealtimeWsBaseUrlLine,
|
|
11
|
+
rootTomlString,
|
|
11
12
|
tomlStringPattern,
|
|
12
13
|
} from "../injected-marker";
|
|
13
14
|
import {
|
|
@@ -303,6 +304,142 @@ export function setRootRealtimeWsBaseUrl(
|
|
|
303
304
|
return { content, keptUserRealtimeWsBaseUrl: false };
|
|
304
305
|
}
|
|
305
306
|
|
|
307
|
+
/**
|
|
308
|
+
* Root key codex-rs reads for its web-search mode. The value opencodex writes is the only one that
|
|
309
|
+
* takes the native hosted tool away; the other modes keep it, so they are never written here.
|
|
310
|
+
*/
|
|
311
|
+
export const ROOT_WEB_SEARCH_KEY = "web_search";
|
|
312
|
+
|
|
313
|
+
/** The one value opencodex writes for {@link ROOT_WEB_SEARCH_KEY}. */
|
|
314
|
+
export const ROOT_WEB_SEARCH_DISABLED_LINE = 'web_search = "disabled"';
|
|
315
|
+
|
|
316
|
+
/** The value {@link ROOT_WEB_SEARCH_DISABLED_LINE} carries, as the journal records it. */
|
|
317
|
+
export const ROOT_WEB_SEARCH_DISABLED_VALUE = "disabled";
|
|
318
|
+
|
|
319
|
+
export function isRootWebSearchLine(line: string): boolean {
|
|
320
|
+
// The quoted spellings are the same key to TOML, and `tomlStringPattern` -- which the value
|
|
321
|
+
// evidence below goes through -- already reads them. A predicate that matched only the bare
|
|
322
|
+
// spelling would leave `"web_search" = "live"` in place while inserting our own line, and two
|
|
323
|
+
// root keys of the same name stop Codex from loading the file at all.
|
|
324
|
+
return /^\s*(?:"web_search"|'web_search'|web_search)\s*=/.test(line);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** What an earlier injection recorded about this key, read back from the journal. */
|
|
328
|
+
export interface RootWebSearchJournal {
|
|
329
|
+
/** The value that injection wrote, or null when it wrote none. */
|
|
330
|
+
injectedValue?: string | null;
|
|
331
|
+
/** The user-owned line that injection had to remove, or null when there was none. */
|
|
332
|
+
replacedUserLine?: string | null;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** What one pass of {@link ensureRootWebSearchDisabled} did, for the journal to record. */
|
|
336
|
+
export interface RootWebSearchOutcome {
|
|
337
|
+
content: string;
|
|
338
|
+
/** The user-owned root line this pass removed, or null when there was none. */
|
|
339
|
+
replacedUserLine: string | null;
|
|
340
|
+
/** The value this pass wrote, or null when it wrote none. */
|
|
341
|
+
wroteValue: string | null;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Insert root-level lines ahead of the first table; TOML root keys may not follow one. */
|
|
345
|
+
function insertRootLines(lines: string[], text: string): string {
|
|
346
|
+
const firstTable = lines.findIndex((line) => /^\s*\[/.test(line));
|
|
347
|
+
if (firstTable === -1) return `${lines.join("\n").replace(/\n+$/, "")}\n${text}\n`;
|
|
348
|
+
let at = firstTable;
|
|
349
|
+
while (at > 0 && lines[at - 1].trim() === "") at -= 1;
|
|
350
|
+
lines.splice(at, 0, ...text.split("\n"));
|
|
351
|
+
return lines.join("\n");
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Ensure the root `web_search` key follows the web-search sidecar's master switch.
|
|
356
|
+
*
|
|
357
|
+
* Codex reads this key (modes `disabled`/`cached`/`indexed`/`live`) to decide whether its native
|
|
358
|
+
* Responses `web_search` tool is offered at all; `disabled` is the only mode that removes the tool
|
|
359
|
+
* from the model's tool list. An operator who runs an MCP search server instead needs exactly that,
|
|
360
|
+
* because a native tool the client still advertises wins the model's attention away from the MCP
|
|
361
|
+
* one. So the switch is not advisory: while `webSearchSidecar.enabled` is false we own the value.
|
|
362
|
+
*
|
|
363
|
+
* Ownership, both directions:
|
|
364
|
+
* - `disabled` removes EVERY root `web_search` line (ours or the user's) and writes the marker-owned
|
|
365
|
+
* pair. Two root keys of the same name are invalid TOML, so keeping a user line alongside ours
|
|
366
|
+
* would stop Codex from loading the file at all — and the operator has just asked for this exact
|
|
367
|
+
* value. A value the user owned is reported back as `replacedUserLine` so the journal can carry
|
|
368
|
+
* it, and `ocx restore` additionally replays the snapshot, like every other line this injection
|
|
369
|
+
* rewrites.
|
|
370
|
+
* - enabled (or unset) removes only the marker-owned pair, so re-enabling the sidecar cannot leave
|
|
371
|
+
* the native tool switched off — which would silently leave the sidecar with nothing to intercept.
|
|
372
|
+
* Two further sources of ownership come from the journal: the exact value a recorded injection
|
|
373
|
+
* wrote, so a line whose marker comment a Codex app reserialize dropped is still ours (#1798), and
|
|
374
|
+
* the user line that injection removed, which goes back now that nothing owns the key.
|
|
375
|
+
*/
|
|
376
|
+
export function ensureRootWebSearchDisabled(
|
|
377
|
+
content: string,
|
|
378
|
+
disabled: boolean,
|
|
379
|
+
journal: RootWebSearchJournal = {},
|
|
380
|
+
): RootWebSearchOutcome {
|
|
381
|
+
const lines = stripInjectedRootWebSearch(content, journal.injectedValue).split("\n");
|
|
382
|
+
const firstTable = lines.findIndex((line) => /^\s*\[/.test(line));
|
|
383
|
+
const rootEnd = firstTable === -1 ? lines.length : firstTable;
|
|
384
|
+
if (!disabled) {
|
|
385
|
+
const restore = journal.replacedUserLine?.trim();
|
|
386
|
+
const owned = lines.slice(0, rootEnd).some(isRootWebSearchLine);
|
|
387
|
+
return {
|
|
388
|
+
content: restore && !owned ? insertRootLines(lines, restore) : lines.join("\n"),
|
|
389
|
+
replacedUserLine: null,
|
|
390
|
+
wroteValue: null,
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
// Whatever root line is left here is not marker-owned and not the value we recorded writing, so
|
|
394
|
+
// it is the operator's own mode: keep its exact text for the pass that switches the sidecar back
|
|
395
|
+
// on. Only one can be valid TOML, and the first is the one Codex reads. A pass that finds no such
|
|
396
|
+
// line keeps the one an earlier off pass recorded — the ordinary way to reach that state is a
|
|
397
|
+
// second injection while the switch is still off (a model change), and the operator's mode must
|
|
398
|
+
// not evaporate because the line it came from is already gone.
|
|
399
|
+
const replacedUserLine = lines
|
|
400
|
+
.slice(0, rootEnd)
|
|
401
|
+
.find((line) => isRootWebSearchLine(line))
|
|
402
|
+
?.replace(/\r$/, "") ?? journal.replacedUserLine?.trim() ?? null;
|
|
403
|
+
return {
|
|
404
|
+
content: insertRootLines(
|
|
405
|
+
lines.filter((line, index) => index >= rootEnd || !isRootWebSearchLine(line)),
|
|
406
|
+
`${OCX_ROUTING_MARKER_LINE}\n${ROOT_WEB_SEARCH_DISABLED_LINE}`,
|
|
407
|
+
),
|
|
408
|
+
replacedUserLine,
|
|
409
|
+
wroteValue: ROOT_WEB_SEARCH_DISABLED_VALUE,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Remove the marker-owned root `web_search` pair (marker line + the key line right after it).
|
|
415
|
+
* Same ownership rule as `stripInjectedOpenaiBaseUrl`: a user's own line has no marker above it and
|
|
416
|
+
* survives, so a hand-set mode is never reinterpreted as ours after an injection cycle. `injectedValue`
|
|
417
|
+
* adds the #1798 value evidence — the exact value a recorded injection wrote — so a line whose marker
|
|
418
|
+
* comment a Codex app reserialize dropped is still recognized as ours.
|
|
419
|
+
*/
|
|
420
|
+
export function stripInjectedRootWebSearch(content: string, injectedValue?: string | null): string {
|
|
421
|
+
const lines = content.split("\n");
|
|
422
|
+
const firstTable = lines.findIndex((line) => /^\s*\[/.test(line));
|
|
423
|
+
const rootEnd = firstTable === -1 ? lines.length : firstTable;
|
|
424
|
+
const drop = new Set<number>();
|
|
425
|
+
for (let i = 0; i + 1 < rootEnd; i += 1) {
|
|
426
|
+
if (lines[i].includes(OCX_SECTION_MARKER) && isRootWebSearchLine(lines[i + 1])) {
|
|
427
|
+
drop.add(i);
|
|
428
|
+
drop.add(i + 1);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
if (injectedValue) {
|
|
432
|
+
for (let i = 0; i < rootEnd; i += 1) {
|
|
433
|
+
if (!isRootWebSearchLine(lines[i])) continue;
|
|
434
|
+
if (rootTomlString(lines[i], ROOT_WEB_SEARCH_KEY) !== injectedValue) continue;
|
|
435
|
+
drop.add(i);
|
|
436
|
+
if (i > 0 && lines[i - 1].includes(OCX_SECTION_MARKER)) drop.add(i - 1);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
if (drop.size === 0) return content;
|
|
440
|
+
return lines.filter((_, index) => !drop.has(index)).join("\n");
|
|
441
|
+
}
|
|
442
|
+
|
|
306
443
|
/**
|
|
307
444
|
* Remove the marker-owned root `openai_base_url` (marker line + the key line right after it).
|
|
308
445
|
* A user's own root override (no marker) survives; an orphaned marker with no key line after
|
package/src/codex/inject/plan.ts
CHANGED
|
@@ -17,6 +17,8 @@ import {
|
|
|
17
17
|
import {
|
|
18
18
|
journaledInjectedOpenaiBaseUrl,
|
|
19
19
|
journaledInjectedRealtimeWsBaseUrl,
|
|
20
|
+
journaledInjectedRootWebSearch,
|
|
21
|
+
journaledReplacedRootWebSearch,
|
|
20
22
|
} from "../journal";
|
|
21
23
|
import { stripJournaledOpenaiBaseUrl } from "../injected-marker";
|
|
22
24
|
import { CODEX_CONFIG_PATH, resolveCodexStateDbPath } from "../paths";
|
|
@@ -30,6 +32,7 @@ import {
|
|
|
30
32
|
chooseCatalogPathForInjection,
|
|
31
33
|
dominantEol,
|
|
32
34
|
ensureFastModeFeature,
|
|
35
|
+
ensureRootWebSearchDisabled,
|
|
33
36
|
normalizeServiceTier,
|
|
34
37
|
removeProfileSection,
|
|
35
38
|
setRootModelCatalogPath,
|
|
@@ -74,6 +77,10 @@ export interface CodexInjectionPlanOk {
|
|
|
74
77
|
keepRootOverrideAlongsideTable: boolean;
|
|
75
78
|
keptUserBaseUrl: boolean;
|
|
76
79
|
keptUserRealtimeWsBaseUrl: boolean;
|
|
80
|
+
/** The root `web_search` value this plan writes, or null when it writes none. */
|
|
81
|
+
injectedRootWebSearch: string | null;
|
|
82
|
+
/** The user-owned root `web_search` line this plan removed, for the journal to carry. */
|
|
83
|
+
replacedRootWebSearch: string | null;
|
|
77
84
|
nativeSubagentDefaultsWarning: string | undefined;
|
|
78
85
|
managedDefaultsMessage: string;
|
|
79
86
|
/**
|
|
@@ -169,6 +176,20 @@ export function deriveCodexInjectionPlan(
|
|
|
169
176
|
content = stripRootContextWindowOverrides(content);
|
|
170
177
|
content = normalizeServiceTier(content);
|
|
171
178
|
content = ensureFastModeFeature(content, config?.fastMode);
|
|
179
|
+
// Codex's own web-search switch follows the sidecar's master switch. While the sidecar is off,
|
|
180
|
+
// the client must not keep offering a native `web_search` tool that an MCP search server is
|
|
181
|
+
// meant to replace. The journal carries both halves of an earlier pass — the value we wrote,
|
|
182
|
+
// because the marker comment does not survive a Codex app reserialize, and the operator line we
|
|
183
|
+
// had to remove, because the sidecar coming back on is what returns it.
|
|
184
|
+
const webSearch = ensureRootWebSearchDisabled(
|
|
185
|
+
content,
|
|
186
|
+
config?.webSearchSidecar?.enabled === false,
|
|
187
|
+
{
|
|
188
|
+
injectedValue: journaledInjectedRootWebSearch({ readOnly: ctx.journalReadOnly }),
|
|
189
|
+
replacedUserLine: journaledReplacedRootWebSearch({ readOnly: ctx.journalReadOnly }),
|
|
190
|
+
},
|
|
191
|
+
);
|
|
192
|
+
content = webSearch.content;
|
|
172
193
|
|
|
173
194
|
const catalogPath = chooseCatalogPathForInjection(
|
|
174
195
|
content,
|
|
@@ -352,6 +373,8 @@ export function deriveCodexInjectionPlan(
|
|
|
352
373
|
keepRootOverrideAlongsideTable,
|
|
353
374
|
keptUserBaseUrl,
|
|
354
375
|
keptUserRealtimeWsBaseUrl,
|
|
376
|
+
injectedRootWebSearch: webSearch.wroteValue,
|
|
377
|
+
replacedRootWebSearch: webSearch.replacedUserLine,
|
|
355
378
|
nativeSubagentDefaultsWarning,
|
|
356
379
|
managedDefaultsMessage,
|
|
357
380
|
historyRelabelRefusal: observedHistoryRefusal,
|
|
@@ -10,12 +10,15 @@ import { HISTORY_RELABEL_STANDS_DOWN, preflightCodexHistoryInjection } from "../
|
|
|
10
10
|
import {
|
|
11
11
|
journaledInjectedOpenaiBaseUrl,
|
|
12
12
|
journaledInjectedRealtimeWsBaseUrl,
|
|
13
|
+
journaledInjectedRootWebSearch,
|
|
14
|
+
journaledReplacedRootWebSearch,
|
|
13
15
|
} from "../journal";
|
|
14
16
|
import { CODEX_CONFIG_PATH, CODEX_PROFILE_PATH, readRootTomlString } from "../paths";
|
|
15
17
|
import { transformManagedSubagentDefaults } from "../subagent-defaults";
|
|
16
18
|
import {
|
|
17
19
|
applyEol,
|
|
18
20
|
dominantEol,
|
|
21
|
+
ensureRootWebSearchDisabled,
|
|
19
22
|
removeProfileSection,
|
|
20
23
|
stripInjectedOpenaiBaseUrl,
|
|
21
24
|
stripOpencodexCatalogPath,
|
|
@@ -65,6 +68,8 @@ function stripOpencodexConfigResult(
|
|
|
65
68
|
content: string,
|
|
66
69
|
journaledBaseUrl: string | null = null,
|
|
67
70
|
journaledRealtimeWsBaseUrl: string | null = null,
|
|
71
|
+
journaledRootWebSearch: string | null = null,
|
|
72
|
+
journaledReplacedWebSearch: string | null = null,
|
|
68
73
|
): StripOpencodexConfigResult {
|
|
69
74
|
let out = content;
|
|
70
75
|
const hadRootOcxProvider =
|
|
@@ -75,6 +80,13 @@ function stripOpencodexConfigResult(
|
|
|
75
80
|
const hadInjectedBaseUrl = hasInjectedOpenaiBaseUrl(out)
|
|
76
81
|
|| (journaledBaseUrl !== null && rootTomlString(out, "openai_base_url") === journaledBaseUrl);
|
|
77
82
|
out = stripInjectedOpenaiBaseUrl(out); // before removeOcxSection — it keys on the marker line too
|
|
83
|
+
// The enabled direction of the web-search transform is also the purge: it drops our pair, drops a
|
|
84
|
+
// marker-less `disabled` the journal proves we wrote, and puts back the operator line we had to
|
|
85
|
+
// remove for as long as the switch was off. Nothing of ours is written here.
|
|
86
|
+
out = ensureRootWebSearchDisabled(out, false, {
|
|
87
|
+
injectedValue: journaledRootWebSearch,
|
|
88
|
+
replacedUserLine: journaledReplacedWebSearch,
|
|
89
|
+
}).content;
|
|
78
90
|
out = stripJournaledOpenaiBaseUrl(out, journaledBaseUrl, journaledRealtimeWsBaseUrl);
|
|
79
91
|
if (hasOcxProviderTable(out)) {
|
|
80
92
|
out = removeOcxSection(out);
|
|
@@ -174,11 +186,19 @@ export function removeCodexConfig(
|
|
|
174
186
|
// ownership verdict, which must agree with what was actually removed.
|
|
175
187
|
const journaledBaseUrl = journaledInjectedOpenaiBaseUrl();
|
|
176
188
|
const journaledRealtimeWsBaseUrl = journaledInjectedRealtimeWsBaseUrl();
|
|
189
|
+
const journaledRootWebSearch = journaledInjectedRootWebSearch();
|
|
190
|
+
const journaledReplaced = journaledReplacedRootWebSearch();
|
|
177
191
|
const had = hasOpencodexRouting(content)
|
|
178
192
|
|| (journaledBaseUrl !== null && rootTomlString(content, "openai_base_url") === journaledBaseUrl)
|
|
179
193
|
|| (journaledRealtimeWsBaseUrl !== null
|
|
180
194
|
&& rootTomlString(content, REALTIME_WS_BASE_URL_KEY) === journaledRealtimeWsBaseUrl);
|
|
181
|
-
const stripped = stripOpencodexConfigResult(
|
|
195
|
+
const stripped = stripOpencodexConfigResult(
|
|
196
|
+
content,
|
|
197
|
+
journaledBaseUrl,
|
|
198
|
+
journaledRealtimeWsBaseUrl,
|
|
199
|
+
journaledRootWebSearch,
|
|
200
|
+
journaledReplaced,
|
|
201
|
+
);
|
|
182
202
|
// Captured from the pre-strip bytes: the strip is what removes the table, so reading it
|
|
183
203
|
// afterwards would find nothing.
|
|
184
204
|
const retainedBlock = historyDisposition === "stand-down-retain"
|
package/src/codex/inject.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
1
|
+
import { closeSync, existsSync, openSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
2
3
|
import {
|
|
3
4
|
atomicWriteFile,
|
|
4
5
|
loadConfig,
|
|
@@ -211,14 +212,15 @@ async function injectCodexConfigImpl(
|
|
|
211
212
|
} catch (error) {
|
|
212
213
|
return { success: false, message: error instanceof Error ? error.message : "Invalid Codex routing target" };
|
|
213
214
|
}
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
};
|
|
219
|
-
}
|
|
215
|
+
const missingConfig = !existsSync(CODEX_CONFIG_PATH)
|
|
216
|
+
? missingCodexConfigAdmission()
|
|
217
|
+
: null;
|
|
218
|
+
if (missingConfig && !missingConfig.ok) return { success: false, message: missingConfig.message };
|
|
220
219
|
|
|
221
|
-
|
|
220
|
+
// An absent config.toml in an existing home is planned as an empty file. The file itself
|
|
221
|
+
// is created only inside the write boundary, after the pre-images are captured, so any
|
|
222
|
+
// later refusal or failure rolls it back to absent (issue 5422).
|
|
223
|
+
const rawContent = missingConfig ? "" : readFileSync(CODEX_CONFIG_PATH, "utf-8");
|
|
222
224
|
const activeProvider = externalCodexModelProvider(rawContent);
|
|
223
225
|
if (activeProvider) {
|
|
224
226
|
// A launcher may have journaled before the provider manager took ownership. Never let shutdown
|
|
@@ -402,6 +404,7 @@ async function injectCodexConfigImpl(
|
|
|
402
404
|
* flip included — before the result is reported.
|
|
403
405
|
*/
|
|
404
406
|
const reconcileAndDerivePlan = (): { plan: CodexInjectionPlanOk; nativeInput: string } => {
|
|
407
|
+
if (missingConfig) createEmptyCodexConfigInBoundary();
|
|
405
408
|
let nativeInput = rawContent;
|
|
406
409
|
let plan = admittedPlan;
|
|
407
410
|
if (v1Reconcile) {
|
|
@@ -465,6 +468,12 @@ async function injectCodexConfigImpl(
|
|
|
465
468
|
injectedRealtimeWsBaseUrl: plan.providerTableMode || plan.keptUserBaseUrl || plan.keptUserRealtimeWsBaseUrl
|
|
466
469
|
? null
|
|
467
470
|
: rootTomlString(plan.content, REALTIME_WS_BASE_URL_KEY),
|
|
471
|
+
// The web-search pair follows the sidecar's master switch, and it is the one root value we
|
|
472
|
+
// REPLACE rather than only add: the operator's own mode has to leave the file while the
|
|
473
|
+
// switch is off. Both halves are recorded here — the value we wrote (the marker comment is
|
|
474
|
+
// not durable) and the line we removed (so re-enabling the sidecar can return it).
|
|
475
|
+
injectedRootWebSearch: plan.injectedRootWebSearch,
|
|
476
|
+
replacedRootWebSearch: plan.replacedRootWebSearch,
|
|
468
477
|
// This is the catalog artifact selected for this injection, even when config.toml
|
|
469
478
|
// already points at that path and therefore needs no textual rewrite.
|
|
470
479
|
injectedCatalogPath: plan.catalogPath,
|
|
@@ -860,3 +869,49 @@ export {
|
|
|
860
869
|
setBeforeRestoreConfigForTests,
|
|
861
870
|
skippedRestoreEnvelope,
|
|
862
871
|
} from "./inject/restore";
|
|
872
|
+
|
|
873
|
+
type MissingCodexConfig = { ok: true } | { ok: false; message: string };
|
|
874
|
+
|
|
875
|
+
/**
|
|
876
|
+
* A fresh Codex install can have its home directory but no config.toml yet: Codex writes
|
|
877
|
+
* that file lazily, and a user who never signed in to OpenAI (authless Desktop with a
|
|
878
|
+
* third-party provider, issue 5422) may never get one. A missing optional file is not
|
|
879
|
+
* evidence that Codex is absent, so injection plans against an empty config.toml and
|
|
880
|
+
* creates it inside the write boundary. A missing home DIRECTORY is different: that is
|
|
881
|
+
* either an uninitialized install or the wrong home, and guessing would write provider
|
|
882
|
+
* state where Codex is not looking.
|
|
883
|
+
*/
|
|
884
|
+
function missingCodexConfigAdmission(): MissingCodexConfig {
|
|
885
|
+
const home = dirname(CODEX_CONFIG_PATH);
|
|
886
|
+
let homeIsDirectory = false;
|
|
887
|
+
try {
|
|
888
|
+
homeIsDirectory = statSync(home).isDirectory();
|
|
889
|
+
} catch {
|
|
890
|
+
homeIsDirectory = false;
|
|
891
|
+
}
|
|
892
|
+
if (homeIsDirectory) return { ok: true };
|
|
893
|
+
return {
|
|
894
|
+
ok: false,
|
|
895
|
+
message: `Codex home ${home} does not exist yet, so there is no config.toml to route. Start Codex once so it creates its home, then rerun 'ocx sync'. If Codex uses a different home, set CODEX_HOME to it.`,
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/**
|
|
900
|
+
* Create the planned empty config.toml under the write boundary. It runs after the
|
|
901
|
+
* pre-images were captured (config absent), so compensation removes it again. The create is
|
|
902
|
+
* exclusive: a file that appeared since admission belongs to another writer, and this plan,
|
|
903
|
+
* derived from an absent file, must not replace it.
|
|
904
|
+
*/
|
|
905
|
+
function createEmptyCodexConfigInBoundary(): void {
|
|
906
|
+
try {
|
|
907
|
+
closeSync(openSync(CODEX_CONFIG_PATH, "wx", 0o600));
|
|
908
|
+
} catch (error) {
|
|
909
|
+
const appeared = (error as NodeJS.ErrnoException | null)?.code === "EEXIST";
|
|
910
|
+
throw new CodexInjectRefusal({
|
|
911
|
+
success: false,
|
|
912
|
+
message: appeared
|
|
913
|
+
? `Codex config ${CODEX_CONFIG_PATH} appeared while injection was planned against its absence; nothing was changed. Rerun 'ocx sync'.`
|
|
914
|
+
: `Codex config not found at ${CODEX_CONFIG_PATH}, and creating it failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
915
|
+
});
|
|
916
|
+
}
|
|
917
|
+
}
|
package/src/codex/journal.ts
CHANGED
|
@@ -42,6 +42,26 @@ interface Journal {
|
|
|
42
42
|
* that as ours. Null when the key was preserved or not injected.
|
|
43
43
|
*/
|
|
44
44
|
injectedRealtimeWsBaseUrl?: string | null;
|
|
45
|
+
/**
|
|
46
|
+
* The root `web_search` value this injection wrote, when it wrote one.
|
|
47
|
+
*
|
|
48
|
+
* Same reasoning as {@link injectedOpenaiBaseUrl}, for the one other root key whose value is a
|
|
49
|
+
* mode Codex reads: the marker comment above `web_search = "disabled"` does not survive a Codex
|
|
50
|
+
* app reserialize, and without value evidence the next injection cannot tell our line from the
|
|
51
|
+
* operator's. The failure that buys is quiet: a re-enabled sidecar whose client still has the
|
|
52
|
+
* native tool switched off has nothing to intercept, which is the empty state the switch exists
|
|
53
|
+
* to avoid.
|
|
54
|
+
*/
|
|
55
|
+
injectedRootWebSearch?: string | null;
|
|
56
|
+
/**
|
|
57
|
+
* The user-owned root `web_search` line this injection REMOVED while the sidecar was off.
|
|
58
|
+
*
|
|
59
|
+
* Two root keys of the same name are invalid TOML, so the operator's mode has to leave the file
|
|
60
|
+
* for as long as the switch is off. Recording the exact line is what puts it back when the
|
|
61
|
+
* sidecar is switched on again — including for a line the snapshot predates, which is the case
|
|
62
|
+
* `ocx restore` alone cannot cover.
|
|
63
|
+
*/
|
|
64
|
+
replacedRootWebSearch?: string | null;
|
|
45
65
|
/**
|
|
46
66
|
* The catalog path this injection actually wrote to.
|
|
47
67
|
*
|
|
@@ -156,6 +176,10 @@ export interface InjectedJournalOwnership {
|
|
|
156
176
|
injectedOpenaiBaseUrl: string | null;
|
|
157
177
|
injectedRealtimeWsBaseUrl: string | null;
|
|
158
178
|
injectedCatalogPath: string | null;
|
|
179
|
+
/** Omitted by callers that inject no `web_search` line, which is the value it then records. */
|
|
180
|
+
injectedRootWebSearch?: string | null;
|
|
181
|
+
/** Omitted by callers that removed no user `web_search` line. */
|
|
182
|
+
replacedRootWebSearch?: string | null;
|
|
159
183
|
}
|
|
160
184
|
|
|
161
185
|
export function markJournalInjectedState(
|
|
@@ -177,6 +201,8 @@ export function markJournalInjectedState(
|
|
|
177
201
|
// would mistake a preserved user override for injected routing.
|
|
178
202
|
journal.injectedOpenaiBaseUrl = ownership.injectedOpenaiBaseUrl;
|
|
179
203
|
journal.injectedRealtimeWsBaseUrl = ownership.injectedRealtimeWsBaseUrl;
|
|
204
|
+
journal.injectedRootWebSearch = ownership.injectedRootWebSearch ?? null;
|
|
205
|
+
journal.replacedRootWebSearch = ownership.replacedRootWebSearch ?? null;
|
|
180
206
|
journal.injectedCatalogPath = ownership.injectedCatalogPath;
|
|
181
207
|
atomicWriteFile(JOURNAL_PATH, JSON.stringify(journal));
|
|
182
208
|
}
|
|
@@ -198,6 +224,16 @@ export function journaledInjectedRealtimeWsBaseUrl(options: { readOnly?: boolean
|
|
|
198
224
|
return readJournal(options.readOnly !== true)?.injectedRealtimeWsBaseUrl ?? null;
|
|
199
225
|
}
|
|
200
226
|
|
|
227
|
+
/** The root `web_search` value the last injection wrote, or null when it wrote none. */
|
|
228
|
+
export function journaledInjectedRootWebSearch(options: { readOnly?: boolean } = {}): string | null {
|
|
229
|
+
return readJournal(options.readOnly !== true)?.injectedRootWebSearch ?? null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** The user-owned root `web_search` line the last injection removed, or null when there was none. */
|
|
233
|
+
export function journaledReplacedRootWebSearch(options: { readOnly?: boolean } = {}): string | null {
|
|
234
|
+
return readJournal(options.readOnly !== true)?.replacedRootWebSearch ?? null;
|
|
235
|
+
}
|
|
236
|
+
|
|
201
237
|
/** The catalog path the last injection wrote to, or null when none was recorded. */
|
|
202
238
|
export function journaledInjectedCatalogPath(): string | null {
|
|
203
239
|
return readJournal()?.injectedCatalogPath ?? null;
|
|
@@ -13,6 +13,19 @@ export interface MainAccountHardLockStatus {
|
|
|
13
13
|
|
|
14
14
|
type PolicyConfig = Pick<OcxConfig, "codexMainAccountHardLock">;
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Whether the main-account hard lock applies to this config (#5694).
|
|
18
|
+
*
|
|
19
|
+
* Absent key or `true` means on; only the persisted `false` opt-out turns it off. The
|
|
20
|
+
* `undefined` branch is the trap this resolver cannot close on its own: no config object is
|
|
21
|
+
* "the caller supplied no policy", not "the operator opted out", so a call site holding an
|
|
22
|
+
* optional config must test for one before asking. Sites that hold a config always (a loaded
|
|
23
|
+
* config, a resolved policy) call this directly.
|
|
24
|
+
*/
|
|
25
|
+
export function isMainAccountHardLockEnabled(config: PolicyConfig | undefined): boolean {
|
|
26
|
+
return config?.codexMainAccountHardLock !== false;
|
|
27
|
+
}
|
|
28
|
+
|
|
16
29
|
function resetTimestamp(value: number | undefined): number | undefined {
|
|
17
30
|
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return undefined;
|
|
18
31
|
return value < 10_000_000_000 ? value * 1000 : value;
|
|
@@ -23,7 +36,7 @@ export function getMainAccountHardLockStatus(
|
|
|
23
36
|
config: PolicyConfig,
|
|
24
37
|
now = Date.now(),
|
|
25
38
|
): MainAccountHardLockStatus {
|
|
26
|
-
if (config
|
|
39
|
+
if (!isMainAccountHardLockEnabled(config)) return { enabled: false, state: "off" };
|
|
27
40
|
const quota = getMainPolicyQuota();
|
|
28
41
|
if (!quota) return { enabled: true, state: "unknown" };
|
|
29
42
|
// Account window priority is deliberate: a 5h account uses that window, even if
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isMainAccountPolicyBindingPending,
|
|
3
|
+
waitForNativeMainStartupGate,
|
|
4
|
+
} from "./native-profile-startup";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* How long a request waits for an owned startup to finish binding the main-account policy
|
|
8
|
+
* before it is refused as draining.
|
|
9
|
+
*
|
|
10
|
+
* `codexMainAccountHardLock` is on by default (#5694), so the admission fence beside this
|
|
11
|
+
* constant is no longer an opt-in rarity: every request that arrives while a process-owned
|
|
12
|
+
* startup is still recovering, sweeping stages, and binding the pinned home lands on it. That
|
|
13
|
+
* window is milliseconds on a warm Linux host and seconds on Windows, which is why the fence
|
|
14
|
+
* waits for the gate instead of failing the request the moment it arrives.
|
|
15
|
+
*
|
|
16
|
+
* 15 s is deliberately just above one startup claim wait: both exclusive claims
|
|
17
|
+
* (`withNativeMainExclusiveClaim` in `convergeOwnedStartup`) allow 10 s before they give up, so a
|
|
18
|
+
* request that arrived during the last claim is not refused in the instant before the gate would
|
|
19
|
+
* have opened. Anything longer stops being a courtesy to the client and starts being a hung
|
|
20
|
+
* request, and a gate still blocked after this leaves a real answer -- retained recovery or a
|
|
21
|
+
* manual-recovery requirement -- where waiting cannot help, which is what the draining error says.
|
|
22
|
+
*/
|
|
23
|
+
export const MAIN_ACCOUNT_POLICY_BINDING_WAIT_MS = 15_000;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Repoll interval for the one window where the settle promise is already resolved and the gate
|
|
27
|
+
* still reads pending: a manual-recovery fence publishes a resolved promise while an in-flight
|
|
28
|
+
* convergence is still marked pending, and re-awaiting that same resolved promise would spin the
|
|
29
|
+
* microtask queue -- which never lets the deadline timer fire.
|
|
30
|
+
*/
|
|
31
|
+
const MAIN_ACCOUNT_POLICY_BINDING_REPOLL_MS = 25;
|
|
32
|
+
|
|
33
|
+
export interface MainAccountPolicyBindingWait {
|
|
34
|
+
/** Ends the wait with the signal's reason, as an aborted request must. */
|
|
35
|
+
signal?: AbortSignal;
|
|
36
|
+
/** Overrides {@link MAIN_ACCOUNT_POLICY_BINDING_WAIT_MS} for a bounded focused test. */
|
|
37
|
+
timeoutMs?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Wait, bounded, for an owned startup's main-account policy binding to settle.
|
|
42
|
+
*
|
|
43
|
+
* Returns `true` once the binding is no longer pending, `false` when the deadline passed with it
|
|
44
|
+
* still in flight. Callers fail closed on `false`; the gate itself is re-read every iteration
|
|
45
|
+
* because a rearm replaces the settle promise rather than resolving the one already held.
|
|
46
|
+
*/
|
|
47
|
+
export async function waitForMainAccountPolicyBinding(
|
|
48
|
+
wait: MainAccountPolicyBindingWait = {},
|
|
49
|
+
): Promise<boolean> {
|
|
50
|
+
const signal = wait.signal;
|
|
51
|
+
const timeoutMs = Math.max(0, wait.timeoutMs ?? MAIN_ACCOUNT_POLICY_BINDING_WAIT_MS);
|
|
52
|
+
const deadline = Date.now() + timeoutMs;
|
|
53
|
+
for (;;) {
|
|
54
|
+
if (!isMainAccountPolicyBindingPending()) return true;
|
|
55
|
+
const remaining = deadline - Date.now();
|
|
56
|
+
if (remaining <= 0) return false;
|
|
57
|
+
const settledNow = await raceSettle(waitForNativeMainStartupGate(), remaining, signal);
|
|
58
|
+
// Settling does not always clear the flag: the gate may have been rearmed, or the resolved
|
|
59
|
+
// promise above may belong to a fence that never released the entry. Yield to the event loop
|
|
60
|
+
// before asking again so this stays a poll rather than a busy-wait.
|
|
61
|
+
if (settledNow && isMainAccountPolicyBindingPending()) {
|
|
62
|
+
await pause(Math.min(remaining, MAIN_ACCOUNT_POLICY_BINDING_REPOLL_MS), signal);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** `true` when the settle promise resolved first, `false` on the deadline. */
|
|
68
|
+
async function raceSettle(
|
|
69
|
+
settle: Promise<unknown>,
|
|
70
|
+
timeoutMs: number,
|
|
71
|
+
signal?: AbortSignal,
|
|
72
|
+
): Promise<boolean> {
|
|
73
|
+
if (signal?.aborted) throw signal.reason;
|
|
74
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
75
|
+
let onAbort: (() => void) | undefined;
|
|
76
|
+
const deadline = new Promise<boolean>(resolve => {
|
|
77
|
+
timer = setTimeout(() => resolve(false), timeoutMs);
|
|
78
|
+
// A request waiting on a startup gate must never be the reason the process stays alive.
|
|
79
|
+
timer.unref?.();
|
|
80
|
+
});
|
|
81
|
+
const aborted = new Promise<never>((_resolve, reject) => {
|
|
82
|
+
if (!signal) return;
|
|
83
|
+
onAbort = () => reject(signal.reason);
|
|
84
|
+
signal.addEventListener("abort", onAbort);
|
|
85
|
+
});
|
|
86
|
+
try {
|
|
87
|
+
// A rejected settle is not an error here: the loop re-reads the gate and decides.
|
|
88
|
+
return await Promise.race([settle.then(() => true, () => true), deadline, aborted]);
|
|
89
|
+
} finally {
|
|
90
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
91
|
+
if (signal && onAbort) signal.removeEventListener("abort", onAbort);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** One short, abortable, unref'd yield so the loop cannot monopolize the microtask queue. */
|
|
96
|
+
function pause(ms: number, signal?: AbortSignal): Promise<void> {
|
|
97
|
+
if (signal?.aborted) return Promise.reject(signal.reason);
|
|
98
|
+
return new Promise<void>((resolve, reject) => {
|
|
99
|
+
const finish = (settle: () => void) => {
|
|
100
|
+
clearTimeout(timer);
|
|
101
|
+
signal?.removeEventListener("abort", onAbort);
|
|
102
|
+
settle();
|
|
103
|
+
};
|
|
104
|
+
const timer = setTimeout(() => finish(resolve), ms);
|
|
105
|
+
timer.unref?.();
|
|
106
|
+
const onAbort = () => finish(() => reject(signal?.reason));
|
|
107
|
+
signal?.addEventListener("abort", onAbort);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { NativeProfileManager } from "./native-profile-manager";
|
|
2
2
|
import { loadConfig } from "../config";
|
|
3
3
|
import { initializeMainAccountPolicyBinding } from "./account-lifecycle";
|
|
4
|
+
import { isMainAccountHardLockEnabled } from "./main-account-hard-lock";
|
|
4
5
|
import { clearAccountNeedsReauth } from "./account-runtime-state";
|
|
5
6
|
import { MAIN_CODEX_ACCOUNT_ID } from "./main-account";
|
|
6
7
|
import {
|
|
@@ -188,7 +189,7 @@ function scheduleStageSweep(entry: StartupEntry): void {
|
|
|
188
189
|
|| entry.epoch !== sweepEpoch || entry.policyBindingPending) return;
|
|
189
190
|
if (!safe) snapshot = { status: "blocked", homeId: entry.homeId, reason: "stage-cleanup-required" };
|
|
190
191
|
else if (snapshot.homeId === entry.homeId && snapshot.status === "blocked" && snapshot.reason === "stage-cleanup-required") {
|
|
191
|
-
if (loadConfig()
|
|
192
|
+
if (isMainAccountHardLockEnabled(loadConfig())) rearmOwnedMainPolicyBinding(entry);
|
|
192
193
|
else snapshot = ready(entry.homeId);
|
|
193
194
|
}
|
|
194
195
|
})().finally(() => {
|
|
@@ -200,7 +201,9 @@ function scheduleStageSweep(entry: StartupEntry): void {
|
|
|
200
201
|
}
|
|
201
202
|
|
|
202
203
|
function convergeOwnedStartup(entry: StartupEntry): void {
|
|
203
|
-
|
|
204
|
+
// The map entry is the gate's owner of record. A released one has been deleted, and every
|
|
205
|
+
// write below belongs to a generation nothing is waiting for any more.
|
|
206
|
+
if (entry.recoveryStarted || startupEntries.get(entry.homeId) !== entry) return;
|
|
204
207
|
entry.recoveryStarted = true;
|
|
205
208
|
const currentEpoch = entry.epoch;
|
|
206
209
|
snapshot = { status: "blocked", homeId: entry.homeId, reason: "recovery-pending" };
|
|
@@ -225,7 +228,7 @@ function convergeOwnedStartup(entry: StartupEntry): void {
|
|
|
225
228
|
));
|
|
226
229
|
const stageSweepSafe = recoveryState === "none" ? await runOwnedStageSweep(entry) : false;
|
|
227
230
|
if (startupEntries.get(entry.homeId) === entry && entry.epoch === currentEpoch && recoveryState === "none" && stageSweepSafe) {
|
|
228
|
-
if (loadConfig()
|
|
231
|
+
if (isMainAccountHardLockEnabled(loadConfig())) {
|
|
229
232
|
await withNativeMainOwnerOperation(entry.manager.context, () => withNativeMainExclusiveClaim(
|
|
230
233
|
entry.manager.context,
|
|
231
234
|
async () => {
|
|
@@ -236,7 +239,7 @@ function convergeOwnedStartup(entry: StartupEntry): void {
|
|
|
236
239
|
}
|
|
237
240
|
// The HMAC is deliberately not persisted. Bind only the pinned owned home,
|
|
238
241
|
// after recovery/cleanup, and before caller-owned admission can observe ready.
|
|
239
|
-
if (loadConfig()
|
|
242
|
+
if (isMainAccountHardLockEnabled(loadConfig())) {
|
|
240
243
|
initializeMainAccountPolicyBinding(entry.manager.context.authPath);
|
|
241
244
|
}
|
|
242
245
|
clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
|
|
@@ -386,7 +389,7 @@ export function startNativeMainStartupLifecycle(
|
|
|
386
389
|
entry.unsubscribe = owner.subscribe(ownerState => observeOwner(entry!, ownerState));
|
|
387
390
|
} else if (!entry.policyBindingPending
|
|
388
391
|
&& snapshot.status === "ready" && snapshot.homeId === homeId
|
|
389
|
-
&& loadConfig()
|
|
392
|
+
&& isMainAccountHardLockEnabled(loadConfig())) {
|
|
390
393
|
// A new same-process listener can enable protection or follow a credential replacement.
|
|
391
394
|
// Re-read its pinned home through the held owner before admitting caller-owned main.
|
|
392
395
|
rearmOwnedMainPolicyBinding(entry);
|
|
@@ -404,6 +407,21 @@ export function startNativeMainStartupLifecycle(
|
|
|
404
407
|
entry!.sweepTimer = undefined;
|
|
405
408
|
entry!.unsubscribe();
|
|
406
409
|
startupEntries.delete(homeId);
|
|
410
|
+
// A released owner cannot leave the process fenced. Convergence runs in the background, and
|
|
411
|
+
// its only guard is this entry, so a server that stops mid-convergence used to keep the
|
|
412
|
+
// "recovery-pending" snapshot the entry armed: every later native request answered 503 until
|
|
413
|
+
// the process exited, because a server whose config does not sync Codex installs a no-op
|
|
414
|
+
// lifecycle that never touches the gate.
|
|
415
|
+
//
|
|
416
|
+
// The gate state belonged only to this entry, so reset it to the process-initial state here,
|
|
417
|
+
// synchronously and before the first await: a NEW entry created for the same home afterwards
|
|
418
|
+
// re-arms its own gate and cannot be clobbered by this release. The epoch bump retires any
|
|
419
|
+
// in-flight `initializeNativeMainStartupGate`/convergence write from the released generation.
|
|
420
|
+
if (snapshot.homeId === homeId && !startupEntries.has(homeId)) {
|
|
421
|
+
epoch += 1;
|
|
422
|
+
snapshot = ready(null);
|
|
423
|
+
settled = Promise.resolve(snapshot);
|
|
424
|
+
}
|
|
407
425
|
entry!.resolveAcquisition?.(snapshot);
|
|
408
426
|
entry!.resolveAcquisition = undefined;
|
|
409
427
|
// Startup convergence can transition from the exclusive recovery claim
|
|
@@ -705,7 +723,7 @@ export function blockNativeMainRecovery(
|
|
|
705
723
|
export function completeNativeMainRecovery(homeId: string): boolean {
|
|
706
724
|
if (snapshot.status !== "blocked" || snapshot.homeId !== homeId) return false;
|
|
707
725
|
const entry = startupEntries.get(homeId);
|
|
708
|
-
if (entry && loadConfig()
|
|
726
|
+
if (entry && isMainAccountHardLockEnabled(loadConfig())) return rearmOwnedMainPolicyBinding(entry);
|
|
709
727
|
epoch += 1;
|
|
710
728
|
clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
|
|
711
729
|
snapshot = ready(homeId);
|