@bitkyc08/opencodex 2.37.0 → 2.39.0-preview.20260901
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/ocx.mjs +127 -16
- package/gui/dist/assets/index-6HZDkIpX.js +112 -0
- package/gui/dist/assets/index-uvENYLin.css +1 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/aside.svg +3 -0
- package/gui/dist/provider-icons/baseten.svg +13 -0
- package/gui/dist/provider-icons/bizrouter.svg +41 -0
- package/gui/dist/provider-icons/cerebras.svg +26 -0
- package/gui/dist/provider-icons/deepinfra.svg +75 -0
- package/gui/dist/provider-icons/deepseek-harness.svg +3 -0
- package/gui/dist/provider-icons/digitalocean.svg +10 -0
- package/gui/dist/provider-icons/featherless.svg +4 -0
- package/gui/dist/provider-icons/gajae-code.svg +410 -0
- package/gui/dist/provider-icons/hermes-agent.svg +207 -0
- package/gui/dist/provider-icons/hyperbolic.svg +18 -0
- package/gui/dist/provider-icons/kilo.svg +13 -0
- package/gui/dist/provider-icons/litellm.svg +1 -0
- package/gui/dist/provider-icons/minimax.svg +1 -0
- package/gui/dist/provider-icons/nanogpt.svg +74 -0
- package/gui/dist/provider-icons/nebius.svg +1 -0
- package/gui/dist/provider-icons/neuralwatt.svg +27 -0
- package/gui/dist/provider-icons/nous.svg +149 -0
- package/gui/dist/provider-icons/novita.svg +32 -0
- package/gui/dist/provider-icons/oh-my-pi.svg +11 -0
- package/gui/dist/provider-icons/openclaw.svg +54 -0
- package/gui/dist/provider-icons/orcarouter.svg +175 -0
- package/gui/dist/provider-icons/parallel.svg +13 -0
- package/gui/dist/provider-icons/prime-agent.svg +21 -0
- package/gui/dist/provider-icons/sambanova.svg +276 -0
- package/gui/dist/provider-icons/scaleway.svg +11 -0
- package/gui/dist/provider-icons/siliconflow.svg +18 -0
- package/gui/dist/provider-icons/synthetic.svg +12 -0
- package/gui/dist/provider-icons/together.svg +18 -0
- package/gui/dist/provider-icons/umans.svg +30 -0
- package/gui/dist/provider-icons/venice.svg +165 -0
- package/gui/dist/provider-icons/vultr.svg +15 -0
- package/gui/dist/provider-icons/zai.svg +218 -0
- package/gui/dist/provider-icons/zcode.svg +219 -0
- package/gui/dist/provider-icons/zenmux.svg +1 -0
- package/package.json +1 -1
- package/src/adapters/cursor/live-models.ts +1 -0
- package/src/adapters/cursor/protobuf-request.ts +4 -1
- package/src/adapters/cursor/tool-definitions.ts +36 -4
- package/src/adapters/openai-responses.ts +72 -10
- package/src/bridge.ts +15 -15
- package/src/cli/capabilities.ts +14 -0
- package/src/cli/codex-cli-update.ts +96 -0
- package/src/cli/codex-shim-autorestore.ts +3 -0
- package/src/cli/dispatch.ts +54 -18
- package/src/cli/export-command.ts +18 -17
- package/src/cli/help.ts +2 -2
- package/src/cli/index.ts +423 -32
- package/src/cli/integrations.ts +24 -2
- package/src/cli/launcher-context.ts +53 -2
- package/src/cli/opencode.ts +126 -33
- package/src/cli/registry.ts +16 -10
- package/src/cli/system-command.ts +6 -1
- package/src/cli/uninstall-plan.ts +86 -0
- package/src/clients/config-export.ts +293 -28
- package/src/codex/account-store.ts +131 -12
- package/src/codex/auth-api.ts +202 -33
- package/src/codex/autostart-health.ts +3 -3
- package/src/codex/catalog/provider-fetch.ts +96 -10
- package/src/codex/catalog/sync.ts +4 -3
- package/src/codex/cli-install-provenance.ts +795 -0
- package/src/codex/convergence.ts +4 -3
- package/src/codex/credential-mutation-epoch.ts +11 -0
- package/src/codex/history-job.ts +10 -0
- package/src/codex/history-manifest.ts +35 -2
- package/src/codex/history-provider.ts +196 -19
- package/src/codex/history-worker.ts +3 -0
- package/src/codex/main-account.ts +2 -0
- package/src/codex/model-entitlements.ts +430 -27
- package/src/codex/native-profile-manager.ts +4 -0
- package/src/codex/quota-401-recovery.ts +190 -0
- package/src/codex/quota-recovery-timing.ts +28 -0
- package/src/codex/quota.ts +6 -0
- package/src/codex/reset-credit-operation-ledger.ts +1411 -0
- package/src/codex/reset-credit-recovery.ts +20 -2
- package/src/codex/routing.ts +68 -16
- package/src/codex/shim.ts +204 -18
- package/src/codex/subagent-model-fallback.ts +4 -1
- package/src/codex/user-identity.ts +2 -1
- package/src/config/paths.ts +18 -3
- package/src/config/pending-teardown-names.d.mts +8 -0
- package/src/config/pending-teardown-names.mjs +69 -0
- package/src/config/pending-teardown.ts +286 -0
- package/src/config.ts +23 -0
- package/src/generated/compatibility-version.json +155 -67
- package/src/integrations/journal.ts +12 -1
- package/src/integrations/registry.ts +112 -0
- package/src/integrations/state.ts +67 -5
- package/src/integrations/writer.ts +107 -16
- package/src/lib/bounded-subprocess.ts +36 -0
- package/src/lib/process-control.ts +30 -5
- package/src/lib/state-store-registrations.ts +8 -0
- package/src/lib/strict-semver.ts +47 -0
- package/src/lib/windows-elevation.ts +32 -1
- package/src/lib/windows-secret-acl.ts +47 -25
- package/src/lib/windows-service-mutation-lock.ts +133 -0
- package/src/lib/windows-user-principal.ts +15 -17
- package/src/oauth/index.ts +220 -13
- package/src/oauth/store.ts +220 -18
- package/src/responses/spill-store.ts +354 -29
- package/src/responses/state.ts +775 -8
- package/src/server/index.ts +4 -3
- package/src/server/lifecycle.ts +5 -1
- package/src/server/management/integration-routes.ts +36 -5
- package/src/server/management/model-rows.ts +11 -2
- package/src/server/management/provider-routes.ts +4 -0
- package/src/server/management/system-restart.ts +5 -5
- package/src/server/management-api.ts +72 -13
- package/src/server/responses/encrypted-payload.ts +18 -1
- package/src/server/startup-action-control.ts +3 -2
- package/src/server/stop-teardown.ts +84 -0
- package/src/service.ts +749 -53
- package/src/sidecar/candidates.ts +1 -1
- package/src/update/codex-cli-update-launch-policy.d.mts +18 -0
- package/src/update/codex-cli-update-launch-policy.mjs +30 -0
- package/src/update/index.ts +39 -6
- package/src/update/job.ts +10 -11
- package/src/update/proxy-liveness-probe.d.mts +6 -0
- package/src/update/proxy-liveness-probe.mjs +84 -0
- package/src/update/stop-contract.d.mts +2 -0
- package/src/update/stop-contract.mjs +15 -0
- package/src/update/stop-decision.d.mts +10 -0
- package/src/update/stop-decision.mjs +34 -0
- package/src/vision/eligibility.ts +19 -1
- package/src/vision/index.ts +4 -18
- package/gui/dist/assets/index-CowztZdo.js +0 -112
- package/gui/dist/assets/index-jqE_VOKI.css +0 -1
package/src/server/index.ts
CHANGED
|
@@ -66,6 +66,7 @@ import { codexAccountNamespaceEntries, isMainCodexAccountTarget } from "../codex
|
|
|
66
66
|
import { MAIN_CODEX_ACCOUNT_ID } from "../codex/main-account";
|
|
67
67
|
import {
|
|
68
68
|
availableAccountGatedNativeModels,
|
|
69
|
+
codexModelEntitlementStateForAccount,
|
|
69
70
|
resolveCodexModelEntitlements,
|
|
70
71
|
} from "../codex/model-entitlements";
|
|
71
72
|
export {
|
|
@@ -1211,10 +1212,10 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
|
|
|
1211
1212
|
? new Map([...accountBoundNativeOpenAiSlugsBySelector(config)].map(([selector, slugs]) => {
|
|
1212
1213
|
const target = accountTargets.get(selector);
|
|
1213
1214
|
const accountId = target && isMainCodexAccountTarget(target) ? MAIN_CODEX_ACCOUNT_ID : target;
|
|
1214
|
-
const entitled = accountId ? modelEntitlements.modelsByAccount.get(accountId) : undefined;
|
|
1215
|
-
const confirmed = accountId ? modelEntitlements.confirmedAccountIds.has(accountId) : false;
|
|
1216
1215
|
return [selector, slugs.filter(slug => (
|
|
1217
|
-
!ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug)
|
|
1216
|
+
!ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug)
|
|
1217
|
+
|| (accountId !== undefined
|
|
1218
|
+
&& codexModelEntitlementStateForAccount(modelEntitlements, accountId, slug) === "granted")
|
|
1218
1219
|
))] as const;
|
|
1219
1220
|
}))
|
|
1220
1221
|
: new Map<string, readonly string[]>();
|
package/src/server/lifecycle.ts
CHANGED
|
@@ -458,8 +458,9 @@ export function trackStreamLifetime(
|
|
|
458
458
|
export async function drainAndShutdown(
|
|
459
459
|
server: ReturnType<typeof Bun.serve> | undefined,
|
|
460
460
|
timeoutMs: number,
|
|
461
|
-
): Promise<
|
|
461
|
+
): Promise<boolean> {
|
|
462
462
|
const s = server ?? _serverRef;
|
|
463
|
+
let shutdownSucceeded = true;
|
|
463
464
|
// One absolute budget covers both a pre-existing scoped profile drain and
|
|
464
465
|
// ordinary in-flight turns. A stuck scoped owner must not pin shutdown forever.
|
|
465
466
|
const deadline = Date.now() + Math.max(0, timeoutMs);
|
|
@@ -491,9 +492,11 @@ export async function drainAndShutdown(
|
|
|
491
492
|
// shutdown is usually part of.
|
|
492
493
|
const stateFlush = await Promise.allSettled([flushResponseState(), flushAntigravityReplay()]);
|
|
493
494
|
if (stateFlush[0]?.status === "rejected") {
|
|
495
|
+
shutdownSucceeded = false;
|
|
494
496
|
console.warn("[responses] state flush during shutdown failed");
|
|
495
497
|
}
|
|
496
498
|
if (stateFlush[1]?.status === "rejected") {
|
|
499
|
+
shutdownSucceeded = false;
|
|
497
500
|
console.warn("[antigravity] replay flush during shutdown failed");
|
|
498
501
|
}
|
|
499
502
|
|
|
@@ -546,4 +549,5 @@ export async function drainAndShutdown(
|
|
|
546
549
|
// never resume admission merely because shutdown cleanup returned.
|
|
547
550
|
}
|
|
548
551
|
}
|
|
552
|
+
return shutdownSucceeded;
|
|
549
553
|
}
|
|
@@ -21,6 +21,7 @@ import { createIntegrationStateStore, type IntegrationStateStore } from "../../i
|
|
|
21
21
|
import {
|
|
22
22
|
applyIntegrationCoordinated,
|
|
23
23
|
disableIntegrationCoordinated,
|
|
24
|
+
overwriteIntegrationCoordinated,
|
|
24
25
|
restoreIntegrationCoordinated,
|
|
25
26
|
type IntegrationRestoreInput,
|
|
26
27
|
type IntegrationWriteInput,
|
|
@@ -70,7 +71,7 @@ export interface IntegrationJournalEnvelope {
|
|
|
70
71
|
export interface IntegrationJournalRow {
|
|
71
72
|
opId: string;
|
|
72
73
|
clientId: IntegrationClientId;
|
|
73
|
-
kind: "apply" | "disable" | "refresh" | "restore";
|
|
74
|
+
kind: "apply" | "disable" | "refresh" | "restore" | "overwrite";
|
|
74
75
|
at: string;
|
|
75
76
|
configPath: string;
|
|
76
77
|
snapshot: "none" | "stored" | "expired";
|
|
@@ -79,6 +80,14 @@ export interface IntegrationJournalRow {
|
|
|
79
80
|
|
|
80
81
|
export interface IntegrationToggleBody {
|
|
81
82
|
enabled: boolean;
|
|
83
|
+
/**
|
|
84
|
+
* Opt in to replacing a conflicted block with the one opencodex would write.
|
|
85
|
+
*
|
|
86
|
+
* Absent and `false` behave identically and are the only states a caller
|
|
87
|
+
* reaches by accident, which is the point: the conflict refusal protects work
|
|
88
|
+
* we did not author, so it can only be waived by asking for it by name.
|
|
89
|
+
*/
|
|
90
|
+
overwriteConflict?: boolean;
|
|
82
91
|
}
|
|
83
92
|
|
|
84
93
|
export interface IntegrationRestoreBody {
|
|
@@ -472,16 +481,38 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise<R
|
|
|
472
481
|
code: "invalid_enabled",
|
|
473
482
|
}, 400, req, ctx.config);
|
|
474
483
|
}
|
|
484
|
+
if (parsed.overwriteConflict !== undefined && typeof parsed.overwriteConflict !== "boolean") {
|
|
485
|
+
return jsonResponse({
|
|
486
|
+
error: "overwriteConflict must be a boolean",
|
|
487
|
+
code: "invalid_overwrite_conflict",
|
|
488
|
+
}, 400, req, ctx.config);
|
|
489
|
+
}
|
|
490
|
+
/*
|
|
491
|
+
* Rejected rather than ignored. Disabling a block we do not own is precisely
|
|
492
|
+
* the deletion this subsystem exists to prevent, so a caller sending this
|
|
493
|
+
* combination has misunderstood the field, and silently dropping it would
|
|
494
|
+
* answer 200 for a request whose intent we refused.
|
|
495
|
+
*/
|
|
496
|
+
if (parsed.overwriteConflict === true && parsed.enabled === false) {
|
|
497
|
+
return jsonResponse({
|
|
498
|
+
error: "overwriteConflict applies only to enabling an integration",
|
|
499
|
+
code: "invalid_overwrite_conflict",
|
|
500
|
+
}, 400, req, ctx.config);
|
|
501
|
+
}
|
|
475
502
|
|
|
476
503
|
try {
|
|
477
504
|
const input = await buildIntegrationWriteInput(requestedClient, ctx, integrationStore());
|
|
478
505
|
const result = await runIntegrationMutationFlight(
|
|
479
506
|
requestedClient,
|
|
480
|
-
parsed.enabled ? "apply" : "disable",
|
|
507
|
+
parsed.enabled ? (parsed.overwriteConflict === true ? "overwrite" : "apply") : "disable",
|
|
481
508
|
input.io?.now ?? Date.now,
|
|
482
|
-
() =>
|
|
483
|
-
|
|
484
|
-
|
|
509
|
+
() => {
|
|
510
|
+
const options = { lockSeams: integrationMutationTestHooks?.lockSeams };
|
|
511
|
+
if (!parsed.enabled) return disableIntegrationCoordinated(input, options);
|
|
512
|
+
return parsed.overwriteConflict === true
|
|
513
|
+
? overwriteIntegrationCoordinated(input, options)
|
|
514
|
+
: applyIntegrationCoordinated(input, options);
|
|
515
|
+
},
|
|
485
516
|
);
|
|
486
517
|
if (!result.ok) return writerFailureResponse(requestedClient, result, ctx);
|
|
487
518
|
return jsonResponse(result satisfies IntegrationToggleEnvelope, 200, req, ctx.config);
|
|
@@ -25,6 +25,7 @@ import { providerContextCap } from "../../providers/context-cap";
|
|
|
25
25
|
import { isVisionReasoningEffort } from "../../reasoning-effort";
|
|
26
26
|
import { routedSlug, slugEquals } from "../../providers/slug-codec";
|
|
27
27
|
import type { OcxConfig } from "../../types";
|
|
28
|
+
import { ensureCodexEntitlementFreshness } from "../../codex/model-entitlements";
|
|
28
29
|
import { fetchAllModels } from "./shared";
|
|
29
30
|
|
|
30
31
|
/**
|
|
@@ -47,8 +48,16 @@ export type ManagementModelRow = Partial<CatalogModel> & {
|
|
|
47
48
|
* models the GUI's Models tab shows — including this function's `disabled` computation,
|
|
48
49
|
* which the export core (src/clients/config-export.ts) deliberately does not perform.
|
|
49
50
|
*/
|
|
50
|
-
export async function listManagementModelRows(
|
|
51
|
-
|
|
51
|
+
export async function listManagementModelRows(
|
|
52
|
+
config: OcxConfig,
|
|
53
|
+
options: { entitlementWaitMs?: number } = {},
|
|
54
|
+
): Promise<ManagementModelRow[]> {
|
|
55
|
+
const [models] = await Promise.all([
|
|
56
|
+
fetchAllModels(config),
|
|
57
|
+
ensureCodexEntitlementFreshness(config, {
|
|
58
|
+
waitMs: options.entitlementWaitMs ?? 3_000,
|
|
59
|
+
}),
|
|
60
|
+
]);
|
|
52
61
|
const disabled = new Set(config.disabledModels ?? []);
|
|
53
62
|
// Native GPT passthrough rows lead (provider "openai", bare-slug namespaced ids): sourced
|
|
54
63
|
// from the static supported set so a disabled model stays listed and re-enableable.
|
|
@@ -53,6 +53,7 @@ import { codexAccountNamespaceProviderCollisionError } from "../../codex/account
|
|
|
53
53
|
import { clearThreadAccountMap } from "../../codex/routing";
|
|
54
54
|
import { primeCodexPoolQuotas } from "../../codex/auth-api";
|
|
55
55
|
import { clearModelCache, getProviderDiscoveryStatus } from "../../codex/model-cache";
|
|
56
|
+
import { getCodexModelEntitlementStatus } from "../../codex/model-entitlements";
|
|
56
57
|
import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap";
|
|
57
58
|
import { modelAutoCompactTokenLimitsConfigError } from "../../providers/auto-compact-budget";
|
|
58
59
|
import { resolveCodexHomeDir } from "../../codex/home";
|
|
@@ -474,6 +475,9 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
474
475
|
codexAccountMode: providerCodexAccountMode(name, p),
|
|
475
476
|
...(name === "xai" ? { xaiResponsesOptInState: xaiResponsesOptInState(p) } : {}),
|
|
476
477
|
discovery: p.liveModels === false ? undefined : getProviderDiscoveryStatus(name),
|
|
478
|
+
...(name === "openai" && isCanonicalOpenAiForwardProvider(p)
|
|
479
|
+
? { entitlement: getCodexModelEntitlementStatus(config) }
|
|
480
|
+
: {}),
|
|
477
481
|
})));
|
|
478
482
|
}
|
|
479
483
|
|
|
@@ -78,11 +78,11 @@ let restartIo: SystemRestartIo = {};
|
|
|
78
78
|
/** Prevents double-scheduling in the 200ms window before drainAndShutdown sets draining. */
|
|
79
79
|
let restartAccepted = false;
|
|
80
80
|
|
|
81
|
-
type RestartDrainOutcome = "completed" | "rejected" | "deadline";
|
|
81
|
+
type RestartDrainOutcome = "completed" | "failed" | "rejected" | "deadline";
|
|
82
82
|
type BoundedSettlementOutcome = "completed" | "rejected" | "deadline";
|
|
83
83
|
|
|
84
84
|
function waitForRestartDrain(
|
|
85
|
-
drainPromise: Promise<void>,
|
|
85
|
+
drainPromise: Promise<boolean | void>,
|
|
86
86
|
deadlineMs: number,
|
|
87
87
|
now: () => number,
|
|
88
88
|
scheduleDeadline: NonNullable<SystemRestartIo["scheduleDeadline"]>,
|
|
@@ -105,7 +105,7 @@ function waitForRestartDrain(
|
|
|
105
105
|
cancelDeadline = scheduleDeadline(() => finish("deadline"), remainingMs);
|
|
106
106
|
if (settled) cancelDeadline();
|
|
107
107
|
void drainPromise.then(
|
|
108
|
-
|
|
108
|
+
succeeded => finish(succeeded === false ? "failed" : "completed"),
|
|
109
109
|
() => finish("rejected"),
|
|
110
110
|
);
|
|
111
111
|
});
|
|
@@ -382,7 +382,7 @@ export function acceptSystemRestart(io: SystemRestartIo = restartIo): {
|
|
|
382
382
|
await completeDeadlineRestartHandoff(io, exitProcess, restartPort, scheduleDeadline);
|
|
383
383
|
return;
|
|
384
384
|
}
|
|
385
|
-
if (drainOutcome === "rejected") {
|
|
385
|
+
if (drainOutcome === "failed" || drainOutcome === "rejected") {
|
|
386
386
|
// drainAndShutdown stops the listener in finally. Even if ancillary cleanup
|
|
387
387
|
// rejects, an accepted restart must still reach replacement or terminal exit.
|
|
388
388
|
console.warn("Drain-and-restart cleanup failed; continuing terminal restart handoff");
|
|
@@ -422,7 +422,7 @@ export function acceptSystemRestart(io: SystemRestartIo = restartIo): {
|
|
|
422
422
|
return;
|
|
423
423
|
}
|
|
424
424
|
(io.markRecycling ?? markRecyclingForExit)();
|
|
425
|
-
exitProcess(0);
|
|
425
|
+
exitProcess(drainOutcome === "failed" || drainOutcome === "rejected" ? 1 : 0);
|
|
426
426
|
}, 200);
|
|
427
427
|
}
|
|
428
428
|
|
|
@@ -256,10 +256,46 @@ export async function handleManagementAPI(
|
|
|
256
256
|
if (routed) return routed;
|
|
257
257
|
|
|
258
258
|
if (url.pathname === "/api/stop" && req.method === "POST") {
|
|
259
|
-
const {
|
|
260
|
-
|
|
259
|
+
const { installedServiceRespawnRisk, stopServiceIfInstalledDetailed, isServiceOwnershipError } = await import("../service");
|
|
260
|
+
// `ocx stop` performs its own shared teardown AFTER verifying the scheduler did not
|
|
261
|
+
// respawn the proxy (#3008). Without this the child restores native Codex and strips
|
|
262
|
+
// the Grok fence here, so a survivor found moments later has already had the shared
|
|
263
|
+
// config pulled out from under it — and the parent's `ownershipBlocked` guard can
|
|
264
|
+
// only prevent a second, redundant teardown. A direct caller sends nothing and keeps
|
|
265
|
+
// the self-contained behaviour.
|
|
266
|
+
//
|
|
267
|
+
// The query flag alone is not enough to hand over the obligation: any authenticated
|
|
268
|
+
// caller could set it and simply exit, leaving client config pointed at a proxy that
|
|
269
|
+
// no longer exists. Honour the deferral only when the caller left a pending-teardown
|
|
270
|
+
// receipt on disk, which a later stop/update can find and finish.
|
|
271
|
+
// Decide BEFORE touching the manager. Stopping the Task Scheduler task and then
|
|
272
|
+
// refusing left the proxy running with its manager stopped — worse than either
|
|
273
|
+
// outcome. This process cannot verify its own post-exit respawn window; only the
|
|
274
|
+
// receipt-backed parent `ocx stop` can, which is what the deferral exists for.
|
|
275
|
+
const { deferralMatchesReceipt } = await import("../config/pending-teardown");
|
|
276
|
+
const { deferralHonored, performStopTeardown } = await import("./stop-teardown");
|
|
277
|
+
const holdsReceipt = deferralHonored(url, deferralMatchesReceipt);
|
|
278
|
+
const respawnRisk = holdsReceipt ? "none" : installedServiceRespawnRisk();
|
|
279
|
+
if (respawnRisk === "respawnable") {
|
|
280
|
+
return jsonResponse({
|
|
281
|
+
success: false,
|
|
282
|
+
code: "respawnable_service",
|
|
283
|
+
message: "This proxy is managed by a Task Scheduler wrapper that can respawn it, so the stop must be run by `ocx stop`, which verifies the respawn window. Nothing was changed.",
|
|
284
|
+
}, 409, req, config);
|
|
285
|
+
}
|
|
286
|
+
if (respawnRisk === "unknown") {
|
|
287
|
+
// Do NOT send them to `ocx stop`: it maps the same unanswerable probe to a stop
|
|
288
|
+
// failure, so that advice would be a loop. The scheduler query itself is what needs
|
|
289
|
+
// fixing (#3008).
|
|
290
|
+
return jsonResponse({
|
|
291
|
+
success: false,
|
|
292
|
+
code: "service_state_unknown",
|
|
293
|
+
message: "The Windows Task Scheduler state could not be read, so this proxy cannot tell whether a wrapper would respawn it. Nothing was changed. Run `ocx service status` to see the query error, repair Task Scheduler access, then retry.",
|
|
294
|
+
}, 409, req, config);
|
|
295
|
+
}
|
|
296
|
+
let serviceStop: import("../service").ServiceStopOutcome;
|
|
261
297
|
try {
|
|
262
|
-
|
|
298
|
+
serviceStop = stopServiceIfInstalledDetailed();
|
|
263
299
|
} catch (err) {
|
|
264
300
|
if (isServiceOwnershipError(err)) {
|
|
265
301
|
// The installed service belongs to another CODEX_HOME/OPENCODEX_HOME: it would respawn
|
|
@@ -269,20 +305,43 @@ export async function handleManagementAPI(
|
|
|
269
305
|
}
|
|
270
306
|
throw err;
|
|
271
307
|
}
|
|
272
|
-
|
|
308
|
+
// The boolean helper collapses "failed" into the same false as "no service installed",
|
|
309
|
+
// so this route used to tear down shared config and exit while a manager that refused
|
|
310
|
+
// to stop was still there to respawn the proxy (#3008).
|
|
311
|
+
if (serviceStop === "failed") {
|
|
312
|
+
return jsonResponse({
|
|
313
|
+
success: false,
|
|
314
|
+
message: "The installed service manager did not stop; it may respawn the proxy. Shared client config was left alone. Run `ocx stop` from the home that owns the service.",
|
|
315
|
+
}, 409, req, config);
|
|
316
|
+
}
|
|
317
|
+
if (serviceStop === "state-unknown") {
|
|
318
|
+
// Same case, same remedy as the pre-check: the query is what needs fixing.
|
|
319
|
+
return jsonResponse({
|
|
320
|
+
success: false,
|
|
321
|
+
code: "service_state_unknown",
|
|
322
|
+
message: "The Windows Task Scheduler state could not be read, so this proxy cannot tell whether a wrapper would respawn it. Shared client config was left alone. Run `ocx service status` to see the query error, repair Task Scheduler access, then retry.",
|
|
323
|
+
}, 409, req, config);
|
|
324
|
+
}
|
|
325
|
+
// The pre-check above already refused the respawnable case without a receipt, so
|
|
326
|
+
// reaching here with one means the parent owns the verification.
|
|
273
327
|
// Both managed configs come down together on an explicit teardown. The daemon's own
|
|
274
328
|
// syncCleanup skips this when OCX_SERVICE is set (so a crash/respawn keeps the fence),
|
|
275
|
-
// which is exactly why an intentional stop has to do it here
|
|
276
|
-
|
|
277
|
-
const
|
|
329
|
+
// which is exactly why an intentional stop has to do it here — unless the caller is
|
|
330
|
+
// `ocx stop`, which does it itself once the proxy is proven down.
|
|
331
|
+
const teardown = await performStopTeardown(url, { ownsReceipt: deferralMatchesReceipt });
|
|
278
332
|
setTimeout(async () => {
|
|
279
|
-
|
|
280
|
-
|
|
333
|
+
let shutdownSucceeded = false;
|
|
334
|
+
try {
|
|
335
|
+
shutdownSucceeded = await drainAndShutdown(undefined, config.shutdownTimeoutMs ?? 5000);
|
|
336
|
+
} catch {
|
|
337
|
+
console.warn("[opencodex] shutdown drain failed");
|
|
338
|
+
}
|
|
339
|
+
// A drained proxy whose shared teardown failed did not finish the job. Exiting 0
|
|
340
|
+
// told a supervisor the stop was clean while native Codex or the Grok fence was
|
|
341
|
+
// still pointed at this process (#3008).
|
|
342
|
+
process.exit(shutdownSucceeded && teardown.success ? 0 : 1);
|
|
281
343
|
}, 200);
|
|
282
|
-
|
|
283
|
-
return jsonResponse(restore.success
|
|
284
|
-
? { success: true, message: `Proxy stopping, native Codex restored.${grokNote}` }
|
|
285
|
-
: { success: false, message: `Proxy stopping, but native Codex restore failed: ${restore.message}. Run \`ocx restore\`.${grokNote}` });
|
|
344
|
+
return jsonResponse(teardown);
|
|
286
345
|
}
|
|
287
346
|
|
|
288
347
|
if (url.pathname.startsWith("/api/native-main-profiles")) {
|
|
@@ -175,7 +175,24 @@ function textWithoutFernetRuns(payload: string, runs: readonly FernetTokenRun[])
|
|
|
175
175
|
return `${text}${payload.slice(last)}`;
|
|
176
176
|
}
|
|
177
177
|
|
|
178
|
-
|
|
178
|
+
/**
|
|
179
|
+
* The routing header codex-rs writes above a delegated agent payload.
|
|
180
|
+
*
|
|
181
|
+
* `MESSAGE` is matched as well as `NEW_TASK`, and only for the unreadability CHECK --
|
|
182
|
+
* recovery stays NEW_TASK-only. #3021 reported a subagent `MESSAGE` arriving in the
|
|
183
|
+
* parent conversation as raw `gAAAA...` ciphertext after an `adapter_eof`. The detector
|
|
184
|
+
* decides "unreadable" by stripping the envelope and asking whether any plaintext
|
|
185
|
+
* survives, so an envelope shape it does not recognise counts as surviving text: a
|
|
186
|
+
* `MESSAGE` whose entire body is one Fernet token measured as READABLE and was forwarded
|
|
187
|
+
* verbatim.
|
|
188
|
+
*
|
|
189
|
+
* Widening the strip is not the same as widening recovery. Recovery decrypts, and
|
|
190
|
+
* decrypting a `MESSAGE` on the parent's behalf would build a plaintext oracle out of a
|
|
191
|
+
* payload the parent's session may have no right to read. This only lets the proxy
|
|
192
|
+
* NOTICE that what it is about to forward is unreadable ciphertext, which is what the
|
|
193
|
+
* report asks for: fail closed with a structured error rather than paste the token.
|
|
194
|
+
*/
|
|
195
|
+
export const AGENT_MESSAGE_ROUTING_ENVELOPE = /(?:^|\n)Message Type\s*:\s*(?:NEW_TASK|MESSAGE)[^\n]*\nTask name\s*:[^\n]*\nSender\s*:[^\n]*\nPayload\s*:\s*(?:\n|$)/gi;
|
|
179
196
|
|
|
180
197
|
// CXC is the compatibility-hook control namespace. Strip only the tagged paragraph:
|
|
181
198
|
// later untagged paragraphs may be genuine task text. Repeated CXC paragraphs are
|
|
@@ -233,8 +233,9 @@ function applyReconciliationOutcome(
|
|
|
233
233
|
/**
|
|
234
234
|
* Execute the existing fixed CLI installer outside the proxy event loop.
|
|
235
235
|
*
|
|
236
|
-
* Repair mode (`options.repair`) runs `ocx service repair
|
|
237
|
-
*
|
|
236
|
+
* Repair mode (`options.repair`) runs `ocx service repair`. A stale definition may be
|
|
237
|
+
* re-registered and elevate inside repair; this wrapper must not retry it through the separate
|
|
238
|
+
* fresh-install UAC path.
|
|
238
239
|
*
|
|
239
240
|
* After an elevation request timeout the lock becomes `indeterminate` until the
|
|
240
241
|
* original elevated transaction completes and is reconciled. A process restart
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { CodexNativeRestoreResult } from "../codex/inject";
|
|
2
|
+
import { deferralMatchesReceipt } from "../config/pending-teardown";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Shared-teardown decision and execution for `POST /api/stop` (#3008).
|
|
6
|
+
*
|
|
7
|
+
* Lives outside the route handler because the handler schedules `process.exit` 200ms
|
|
8
|
+
* after it answers, which makes it uncallable from a test. The part worth testing is
|
|
9
|
+
* exactly this: whether the deferral is honoured, whether the restores actually run, and
|
|
10
|
+
* whether the response says what happened.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export type GrokStripResult = { ok: boolean; changed: boolean; message: string };
|
|
14
|
+
|
|
15
|
+
export type StopTeardownIo = {
|
|
16
|
+
/** Does the nonce this request carries name a readable obligation on disk? */
|
|
17
|
+
ownsReceipt?: (nonce: string | null) => boolean;
|
|
18
|
+
restoreNativeCodex?: () => Promise<CodexNativeRestoreResult>;
|
|
19
|
+
stripGrok?: () => GrokStripResult;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type StopTeardownBody = {
|
|
23
|
+
success: boolean;
|
|
24
|
+
message: string;
|
|
25
|
+
sharedTeardown: "deferred" | "performed";
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A deferral is honoured only when the caller proves it owns the obligation.
|
|
30
|
+
*
|
|
31
|
+
* The query flag names an intention; the receipt is the obligation. Without the second
|
|
32
|
+
* half any authenticated caller could ask the proxy to skip teardown and then exit,
|
|
33
|
+
* leaving native Codex and the Grok fence pointed at a proxy that no longer exists.
|
|
34
|
+
*
|
|
35
|
+
* "A receipt exists" is not that proof either: it would let any caller ride on another
|
|
36
|
+
* stop's outstanding obligation and get a deferral it never owns. The request has to name
|
|
37
|
+
* the receipt's nonce, which only the process that wrote it (and anything that can read
|
|
38
|
+
* the 0700 config directory, which is already the trust boundary for the admin token)
|
|
39
|
+
* can know.
|
|
40
|
+
*/
|
|
41
|
+
export function deferralHonored(url: URL, ownsReceipt: (nonce: string | null) => boolean): boolean {
|
|
42
|
+
if (url.searchParams.get("deferSharedTeardown") !== "1") return false;
|
|
43
|
+
return ownsReceipt(url.searchParams.get("teardownNonce"));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Run (or skip) the shared teardown and describe the outcome truthfully. */
|
|
47
|
+
export async function performStopTeardown(url: URL, io: StopTeardownIo = {}): Promise<StopTeardownBody> {
|
|
48
|
+
const ownsReceipt = io.ownsReceipt ?? deferralMatchesReceipt;
|
|
49
|
+
if (deferralHonored(url, ownsReceipt)) {
|
|
50
|
+
// Not "native Codex restored": nothing was restored here, and claiming otherwise
|
|
51
|
+
// would be a success message the operator cannot verify.
|
|
52
|
+
return {
|
|
53
|
+
success: true,
|
|
54
|
+
message: "Proxy stopping; shared teardown deferred to the stopping client.",
|
|
55
|
+
sharedTeardown: "deferred",
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
const restore = io.restoreNativeCodex
|
|
59
|
+
? await io.restoreNativeCodex()
|
|
60
|
+
: await (await import("../codex/inject")).restoreNativeCodexAsync();
|
|
61
|
+
const grok = io.stripGrok
|
|
62
|
+
? io.stripGrok()
|
|
63
|
+
: (await import("../grok/inject")).stripGrokConfig();
|
|
64
|
+
// Success means BOTH halves came down. Deciding it from the native restore alone and
|
|
65
|
+
// appending the Grok text let a caller read `success: true` while the fence still
|
|
66
|
+
// pointed at a proxy that was exiting — the teardown reported done with half of it
|
|
67
|
+
// undone (#3008).
|
|
68
|
+
const grokNote = grok.ok ? "" : ` Grok config cleanup failed: ${grok.message}`;
|
|
69
|
+
if (restore.success && grok.ok) {
|
|
70
|
+
return { success: true, message: "Proxy stopping, native Codex restored.", sharedTeardown: "performed" };
|
|
71
|
+
}
|
|
72
|
+
if (restore.success) {
|
|
73
|
+
return {
|
|
74
|
+
success: false,
|
|
75
|
+
message: `Proxy stopping, native Codex restored, but the Grok fence was not removed:${grokNote} Run \`ocx restore\`.`,
|
|
76
|
+
sharedTeardown: "performed",
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
success: false,
|
|
81
|
+
message: `Proxy stopping, but native Codex restore failed: ${restore.message}. Run \`ocx restore\`.${grokNote}`,
|
|
82
|
+
sharedTeardown: "performed",
|
|
83
|
+
};
|
|
84
|
+
}
|