@bitkyc08/opencodex 2.89.0 → 2.91.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/gui/dist/assets/App-DMps4OLk.css +1 -0
- package/gui/dist/assets/App-nZ34cR5G.js +58 -0
- package/gui/dist/assets/{Tray-7z1COnmz.js → Tray-BuLy7hL1.js} +1 -1
- package/gui/dist/assets/{index-uchD-ngf.js → index-DGXS51sp.js} +15 -15
- package/gui/dist/assets/quota-summary-DO1Sx5TM.js +1 -0
- package/gui/dist/index.html +1 -1
- package/package.json +2 -2
- package/src/adapters/anthropic.ts +2 -5
- package/src/adapters/base.ts +4 -0
- package/src/adapters/claude-cli/adapter.ts +13 -7
- package/src/adapters/claude-cli/usage-admission.ts +28 -2
- package/src/adapters/coding-agent/turn.ts +14 -4
- package/src/adapters/cursor/protobuf-request.ts +3 -0
- package/src/adapters/google-progress-history.ts +19 -1
- package/src/adapters/google.ts +28 -9
- package/src/adapters/image.ts +5 -3
- package/src/adapters/input-media-guard.ts +5 -2
- package/src/adapters/kiro-refusal.ts +41 -1
- package/src/adapters/ollama-native.ts +1 -0
- package/src/adapters/openai-chat/messages.ts +34 -4
- package/src/adapters/openai-chat/passthrough.ts +37 -4
- package/src/adapters/openai-chat/reasoning-replay.ts +98 -13
- package/src/adapters/openai-chat/serialized-tool-call-content.ts +95 -134
- package/src/adapters/openai-chat/serialized-tool-call-markup.ts +108 -0
- package/src/adapters/openai-chat/standalone-bare-tool-calls.ts +136 -0
- package/src/adapters/openai-chat.ts +7 -6
- package/src/adapters/openai-responses/opengateway-input.ts +139 -0
- package/src/adapters/openai-responses/passthrough.ts +7 -2
- package/src/adapters/openai-responses/request-strips.ts +32 -0
- package/src/adapters/openai-responses/transport.ts +19 -1
- package/src/chat/inbound.ts +20 -2
- package/src/chat/outbound.ts +36 -4
- package/src/chat/reasoning-details.ts +137 -0
- package/src/claude/desktop-3p-library.ts +43 -3
- package/src/claude/desktop-3p.ts +20 -10
- package/src/claude/desktop-picker-profile.ts +18 -9
- package/src/claude/desktop-remote-store-io.ts +2 -2
- package/src/cli/capabilities-agents-routing.ts +50 -0
- package/src/cli/capabilities-base.ts +15 -2
- package/src/cli/capabilities-observe-system.ts +4 -4
- package/src/cli/codex-shim-autorestore.ts +5 -0
- package/src/cli/dispatch.ts +30 -0
- package/src/cli/ensure-desktop-cold-start.ts +2 -2
- package/src/cli/fallback.ts +127 -0
- package/src/cli/help.ts +1 -0
- package/src/cli/index.ts +1 -2
- package/src/cli/link-apply-restart.ts +72 -0
- package/src/cli/link-child.ts +6 -2
- package/src/cli/link-cleanup.ts +95 -0
- package/src/cli/link-enrollment.ts +4 -0
- package/src/cli/link.ts +84 -9
- package/src/cli/provider.ts +44 -2
- package/src/cli/registry.ts +13 -1
- package/src/cli/restart-failure.ts +4 -0
- package/src/cli/runtime-api.ts +1 -1
- package/src/cli/status-codex-settings.ts +56 -0
- package/src/cli/status-fallback.ts +29 -0
- package/src/cli/status-install.ts +52 -0
- package/src/cli/status.ts +20 -1
- package/src/cli/system-restart-client.ts +32 -1
- package/src/cli/update-restart-child.ts +4 -3
- package/src/cli/update-restart-home.ts +20 -8
- package/src/client/connect.ts +82 -6
- package/src/client/home-unreachable.ts +16 -0
- package/src/client/hub-relay.ts +8 -2
- package/src/client/link-home.ts +119 -6
- package/src/client/link-parity-reporter.ts +107 -0
- package/src/client/link-relay-transport.ts +8 -1
- package/src/client/link-relay.ts +27 -3
- package/src/client/machine-listener.ts +83 -1
- package/src/client/runtime-restart.ts +127 -0
- package/src/client/runtime.ts +137 -25
- package/src/codex/account-lifecycle.ts +19 -6
- package/src/codex/account-store.ts +27 -8
- package/src/codex/account-usability.ts +4 -1
- package/src/codex/auth-api/account-list.ts +10 -5
- package/src/codex/auth-api/account-pause-group.ts +2 -22
- package/src/codex/auth-api/login-collision.ts +59 -0
- package/src/codex/auth-api/login-flow.ts +12 -7
- package/src/codex/auth-api/pool-mode-gate.ts +81 -13
- package/src/codex/auth-collision.ts +31 -4
- package/src/codex/auth-context.ts +58 -12
- package/src/codex/catalog/parsing.ts +4 -0
- package/src/codex/cli-update-plan.ts +18 -3
- package/src/codex/collision-identity.ts +78 -0
- package/src/codex/desktop-compatibility/windows-key-protection.ts +18 -6
- package/src/codex/main-account-hard-lock.ts +14 -0
- package/src/codex/quota-history.ts +7 -0
- package/src/codex/quota.ts +10 -0
- package/src/codex/routing/cooldown-math.ts +5 -0
- package/src/codex/routing/failure-window.ts +61 -9
- package/src/codex/routing.ts +4 -3
- package/src/codex/shim-diagnostics.ts +2 -1
- package/src/codex/shim-fingerprint.ts +11 -2
- package/src/codex/shim-templates.ts +16 -5
- package/src/codex/shim-windows-refresh.ts +224 -0
- package/src/codex/shim.ts +45 -21
- package/src/codex/subagent-model-fallback.ts +24 -1
- package/src/combos/failover.ts +8 -1
- package/src/combos/index.ts +1 -0
- package/src/combos/resolve.ts +12 -0
- package/src/config/diagnostics.ts +5 -0
- package/src/config/load-degrade.ts +2 -0
- package/src/config/provider-validation.ts +7 -2
- package/src/config/schema/config-schema.ts +3 -0
- package/src/config/schema/fallback-policy.ts +40 -0
- package/src/generated/compatibility-version.json +360 -192
- package/src/generated/model-metadata.ts +1 -1
- package/src/images/loop.ts +2 -2
- package/src/lib/authorization-scheme.ts +95 -0
- package/src/lib/bun-transpiler-cache-scope.mjs +31 -17
- package/src/lib/provider-admission-wait.ts +14 -0
- package/src/lib/provider-client-headers.ts +1 -0
- package/src/lib/redact.ts +2 -2
- package/src/lib/service-secrets.ts +37 -2
- package/src/lib/system-restart-contract.ts +24 -0
- package/src/lib/test-home-guard.ts +78 -11
- package/src/lib/translator-budget.ts +1 -1
- package/src/lib/upstream-retry.ts +15 -0
- package/src/link/child-parity.ts +124 -0
- package/src/link/codex-settings.ts +37 -0
- package/src/link/compatibility.ts +119 -0
- package/src/link/management-routes.ts +2 -0
- package/src/link/peer-report.ts +100 -0
- package/src/link/remote-platform.ts +78 -0
- package/src/link/ssh-argv.ts +19 -0
- package/src/link/status-projection.ts +24 -2
- package/src/oauth/account-import/google-antigravity-adapter.ts +38 -2
- package/src/oauth/account-import/parser.ts +15 -2
- package/src/oauth/account-import/types.ts +7 -0
- package/src/oauth/chatgpt-shared.ts +10 -1
- package/src/oauth/dsh.ts +45 -2
- package/src/oauth/index.ts +1 -1
- package/src/oauth/kimi.ts +2 -0
- package/src/oauth/kiro.ts +44 -1
- package/src/oauth/login-flow-state.ts +11 -2
- package/src/oauth/nous.ts +4 -1
- package/src/oauth/store.ts +2 -1
- package/src/protocols/codecs/chat.ts +3 -3
- package/src/protocols/encoders/adapter-events.ts +26 -2
- package/src/protocols/encoders/chat.ts +55 -3
- package/src/providers/claude-cli-usage.ts +20 -5
- package/src/providers/initial-model-selection.ts +2 -2
- package/src/providers/key-failover.ts +10 -2
- package/src/providers/key-store.ts +8 -3
- package/src/providers/mistral-transport.ts +240 -0
- package/src/providers/openai-sidecar.ts +5 -0
- package/src/providers/quota/anthropic-account-quota.ts +18 -6
- package/src/providers/quota/antigravity.ts +8 -10
- package/src/providers/quota/failure-codes.ts +18 -0
- package/src/providers/quota/vendor-probes-oauth.ts +12 -3
- package/src/providers/quota-routing-cache.ts +1 -1
- package/src/providers/quota.ts +5 -1
- package/src/providers/registry/entries-extended.ts +6 -1
- package/src/providers/routed-config.ts +1 -1
- package/src/providers/xai-transport.ts +2 -1
- package/src/quota-pool/assemble.ts +326 -0
- package/src/quota-pool/contract.ts +58 -0
- package/src/quota-pool/identity.ts +203 -0
- package/src/remote/features.ts +74 -0
- package/src/remote/protocol.ts +2 -0
- package/src/remote/served-by.ts +57 -0
- package/src/responses/audio.ts +46 -0
- package/src/responses/input-media.ts +37 -15
- package/src/responses/parser-content.ts +22 -27
- package/src/responses/parser.ts +7 -0
- package/src/responses/reasoning-envelope.ts +12 -0
- package/src/responses/reasoning-retention.ts +218 -11
- package/src/responses/schema.ts +3 -4
- package/src/responses/web-search-replay.ts +11 -2
- package/src/routing/fallback-policy.ts +290 -0
- package/src/routing/fallback-state.ts +179 -0
- package/src/server/audio-client.ts +2 -1
- package/src/server/audio-dictation.ts +2 -1
- package/src/server/audio-upstream.ts +2 -1
- package/src/server/auth-cors.ts +6 -5
- package/src/server/background-lifecycle.ts +4 -0
- package/src/server/chat-completions.ts +8 -1
- package/src/server/chat-native.ts +8 -7
- package/src/server/gui-session.ts +2 -1
- package/src/server/index/link-listener.ts +11 -1
- package/src/server/index/link-management.ts +47 -0
- package/src/server/index/link-relay-sessions.ts +11 -2
- package/src/server/index/optional-listeners.ts +7 -2
- package/src/server/index/serve-options.ts +27 -4
- package/src/server/index/websocket-handler.ts +10 -0
- package/src/server/lifecycle.ts +17 -1
- package/src/server/live.ts +5 -0
- package/src/server/management/context.ts +1 -1
- package/src/server/management/fallback-routes.ts +75 -0
- package/src/server/management/link-enrollment.ts +21 -3
- package/src/server/management/link-route-state.ts +2 -0
- package/src/server/management/link-routes.ts +12 -1
- package/src/server/management/oauth-account-routes.ts +2 -1
- package/src/server/management/quota-pool-routes.ts +24 -0
- package/src/server/management/route-registry.ts +3 -0
- package/src/server/management-api.ts +16 -0
- package/src/server/management-auth.ts +2 -1
- package/src/server/messages-native.ts +11 -0
- package/src/server/request-log.ts +16 -7
- package/src/server/responses/adapter-continuation.ts +2 -2
- package/src/server/responses/adapter-dispatch.ts +2 -2
- package/src/server/responses/agent-task-recovery.ts +1 -1
- package/src/server/responses/codex-ws-exchange.ts +3 -0
- package/src/server/responses/codex-ws-metadata.ts +44 -0
- package/src/server/responses/codex-ws-session.ts +21 -1
- package/src/server/responses/combo-stream-preflight.ts +7 -2
- package/src/server/responses/compact.ts +4 -2
- package/src/server/responses/core-auth.ts +31 -1
- package/src/server/responses/core-codex-account.ts +21 -14
- package/src/server/responses/core-combo.ts +75 -1
- package/src/server/responses/core-opaque-recovery.ts +20 -11
- package/src/server/responses/deepseek-image-files.ts +121 -0
- package/src/server/responses/empty-completion-guard.ts +8 -0
- package/src/server/responses/encrypted-payload.ts +3 -3
- package/src/server/responses/fetch-helpers.ts +30 -10
- package/src/server/responses/input-admission.ts +6 -2
- package/src/server/responses/main-hard-lock-routing.ts +85 -8
- package/src/server/responses/opengateway-completion-guard.ts +232 -0
- package/src/server/responses/passthrough-completion.ts +41 -0
- package/src/server/responses/passthrough-delivery.ts +2 -0
- package/src/server/responses/passthrough-dispatch.ts +13 -13
- package/src/server/responses/passthrough-key-rotation.ts +40 -0
- package/src/server/responses/policy-fallback.ts +44 -1
- package/src/server/responses/reasoning-archive-error.ts +21 -0
- package/src/server/responses/request-prepare.ts +19 -1
- package/src/server/responses/request-transport.ts +61 -3
- package/src/server/restart-replacement.ts +50 -2
- package/src/server/ws-bridge.ts +5 -0
- package/src/service/cli.ts +25 -2
- package/src/service/diagnostics.ts +20 -6
- package/src/service/orchestration.ts +17 -0
- package/src/service/status-json.ts +116 -0
- package/src/types/config.ts +7 -0
- package/src/types/fallback.ts +38 -0
- package/src/types/provider.ts +2 -0
- package/src/types/request.ts +16 -1
- package/src/types.ts +1 -0
- package/src/web-search/loop.ts +2 -2
- package/gui/dist/assets/App-BnRSujOq.js +0 -58
- package/gui/dist/assets/App-dnkJ85te.css +0 -1
- package/gui/dist/assets/quota-summary-BY_10IEz.js +0 -1
package/src/client/link-relay.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { fetchBoundLinkRelay, LinkRelayAuthenticationError } from "./link-relay-transport";
|
|
2
|
+
import { homeUnreachableBody } from "./home-unreachable";
|
|
2
3
|
import {
|
|
3
4
|
boundedRelayResponseStream,
|
|
4
5
|
filterRelayHeaders,
|
|
@@ -112,6 +113,29 @@ function jsonError(status: number, error: string, retry = false): Response {
|
|
|
112
113
|
return Response.json({ error }, { status, headers });
|
|
113
114
|
}
|
|
114
115
|
|
|
116
|
+
/** A retryable 503 for a Home this Child cannot reach (OCX-11 `home_unreachable`). */
|
|
117
|
+
function homeUnreachable(error: string): Response {
|
|
118
|
+
return Response.json(homeUnreachableBody(error), {
|
|
119
|
+
status: 503,
|
|
120
|
+
headers: { "Retry-After": String(LINK_RELAY_RETRY_AFTER_SECONDS) },
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The peer answered but the connection-bound relay authentication failed. That is not a Home
|
|
126
|
+
* outage (an unrecognized protocol needs an upgrade or a re-link), so it must never read as
|
|
127
|
+
* `home_unreachable`, which GOAT's device-local fallback keys on. The status and Retry-After it
|
|
128
|
+
* carried before OCX-11 are kept: a lost bound socket can succeed on a fresh connection.
|
|
129
|
+
*/
|
|
130
|
+
export const LINK_RELAY_AUTH_FAILED_CODE = "link_relay_auth_failed";
|
|
131
|
+
|
|
132
|
+
function relayAuthenticationFailed(error: string): Response {
|
|
133
|
+
return Response.json({ error, code: LINK_RELAY_AUTH_FAILED_CODE }, {
|
|
134
|
+
status: 503,
|
|
135
|
+
headers: { "Retry-After": String(LINK_RELAY_RETRY_AFTER_SECONDS) },
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
115
139
|
export function linkRelayDestination(url: URL, target: Pick<LinkRelayTarget, "tunnelPort">): string {
|
|
116
140
|
if (!isLinkPort(target.tunnelPort)) {
|
|
117
141
|
throw new RangeError("invalid link tunnel port");
|
|
@@ -369,7 +393,7 @@ export async function relayLinkDataRequest(
|
|
|
369
393
|
const tunnelUnavailable = async (): Promise<Response> => {
|
|
370
394
|
cleanup();
|
|
371
395
|
try { await body?.cancel(); } catch { /* best effort */ }
|
|
372
|
-
return
|
|
396
|
+
return homeUnreachable("link tunnel unavailable");
|
|
373
397
|
};
|
|
374
398
|
const readyForFetch = async (): Promise<boolean> => {
|
|
375
399
|
if (!tunnel) return false;
|
|
@@ -393,7 +417,7 @@ export async function relayLinkDataRequest(
|
|
|
393
417
|
if (error instanceof LinkRelayAuthenticationError) {
|
|
394
418
|
cleanup();
|
|
395
419
|
try { await body?.cancel(); } catch { /* an upload might already be drained */ }
|
|
396
|
-
return
|
|
420
|
+
return relayAuthenticationFailed(error.message);
|
|
397
421
|
}
|
|
398
422
|
if (bodyOverflow) {
|
|
399
423
|
cleanup();
|
|
@@ -408,7 +432,7 @@ export async function relayLinkDataRequest(
|
|
|
408
432
|
if (relayAbort.signal.aborted) {
|
|
409
433
|
cleanup();
|
|
410
434
|
try { await upstream.body?.cancel(); } catch { /* best effort */ }
|
|
411
|
-
return
|
|
435
|
+
return homeUnreachable("link tunnel unavailable");
|
|
412
436
|
}
|
|
413
437
|
|
|
414
438
|
const sse = isSse(upstream.headers);
|
|
@@ -22,9 +22,23 @@ import { readClientLinkState, type ClientLinkState } from "./link-state";
|
|
|
22
22
|
import { projectClientLinkChild, type ClientLinkSidecarRead } from "./link-status";
|
|
23
23
|
import type { ClientLinkSupervisorStatus } from "./link-tunnel";
|
|
24
24
|
import { packageVersion } from "../lib/package-version";
|
|
25
|
+
import { createLocalAttestationProof, LOCAL_ATTESTATION_CHALLENGE_HEADER, LOCAL_ATTESTATION_PROOF_HEADER } from "../lib/local-management-attestation";
|
|
26
|
+
import {
|
|
27
|
+
CLIENT_RESTART_BUDGET_MS,
|
|
28
|
+
parseExpectedSystemRestartPid,
|
|
29
|
+
RESTART_SUPERVISED_ERROR,
|
|
30
|
+
SYSTEM_RESTART_CAPABILITY_HEADER,
|
|
31
|
+
SYSTEM_RESTART_CAPABILITY_VERSION,
|
|
32
|
+
SYSTEM_RESTART_EXPECTED_PID_HEADER,
|
|
33
|
+
SYSTEM_RESTART_METHOD,
|
|
34
|
+
SYSTEM_RESTART_NONCE_HEADER,
|
|
35
|
+
SYSTEM_RESTART_PATH,
|
|
36
|
+
verifySystemRestartCapability,
|
|
37
|
+
} from "../lib/system-restart-contract";
|
|
25
38
|
import { linkRouteAllowed } from "../link/routes";
|
|
26
39
|
import { linkHomeName, linkHomePhrase } from "./link-home";
|
|
27
40
|
import { relayLinkManagementRead } from "./link-management-relay";
|
|
41
|
+
import type { ClientRestartAdmission } from "./runtime-restart";
|
|
28
42
|
|
|
29
43
|
const VERSION = packageVersion("0.0.0");
|
|
30
44
|
const GUI_SPA_PATHS = new Set([
|
|
@@ -51,6 +65,17 @@ export interface MachineListenerDeps {
|
|
|
51
65
|
linkTunnel?: LinkTunnelGate;
|
|
52
66
|
/** Link mode: relay seams (deadline clock and byte cap). */
|
|
53
67
|
linkRelay?: LinkIngress["relay"];
|
|
68
|
+
/**
|
|
69
|
+
* The bound restart (OCX-6). With it, `/healthz` proves this process's identity to a challenge,
|
|
70
|
+
* advertises `restartCapability` and `startedAt`, and `POST /api/system/restart` accepts a
|
|
71
|
+
* capability signed with `attestationSecret` for this pid and port, and nothing else.
|
|
72
|
+
*/
|
|
73
|
+
restart?: {
|
|
74
|
+
attestationSecret: string;
|
|
75
|
+
/** ISO start time of this runtime's process. */
|
|
76
|
+
startedAt: string;
|
|
77
|
+
request: () => Promise<ClientRestartAdmission>;
|
|
78
|
+
};
|
|
54
79
|
serve?: (options: Parameters<typeof Bun.serve>[0]) => Server<unknown>;
|
|
55
80
|
}
|
|
56
81
|
|
|
@@ -68,6 +93,8 @@ export function machineRouteAllowed(url: URL, req: Request, relayEnabled: boolea
|
|
|
68
93
|
if (linkMode && linkRouteAllowed(url, req)) return true;
|
|
69
94
|
const path = url.pathname;
|
|
70
95
|
if (req.method === "GET" && (path === "/healthz" || path === "/readyz" || path === "/" || path === "/opencodex-session")) return true;
|
|
96
|
+
// The bound restart. Only a capability signed for this exact process admits it (see the handler).
|
|
97
|
+
if (req.method === SYSTEM_RESTART_METHOD && path === SYSTEM_RESTART_PATH) return true;
|
|
71
98
|
if ((req.method === "GET" || req.method === "HEAD") && (path === "/api/machine/status" || path === "/api/machine/clients" || path === "/api/machine/shim")) return true;
|
|
72
99
|
// The one link route a connected Child serves: its own read-only link status.
|
|
73
100
|
if (linkMode && (req.method === "GET" || req.method === "HEAD") && path === "/api/link/status") return true;
|
|
@@ -143,7 +170,20 @@ export function startMachineListener(
|
|
|
143
170
|
}
|
|
144
171
|
if (!machineRouteAllowed(url, req, relayEnabled, linkMode)) return json404(req);
|
|
145
172
|
if (url.pathname === "/healthz" && req.method === "GET") {
|
|
146
|
-
|
|
173
|
+
const restart = deps.restart;
|
|
174
|
+
const response = Response.json({
|
|
175
|
+
service: "opencodex", version: VERSION, role: "client", uptime: process.uptime(), pid: process.pid, port: server.port,
|
|
176
|
+
...(restart ? { startedAt: restart.startedAt, restartCapability: SYSTEM_RESTART_CAPABILITY_VERSION, restartBudgetMs: CLIENT_RESTART_BUDGET_MS } : {}),
|
|
177
|
+
});
|
|
178
|
+
const challenge = restart ? req.headers.get(LOCAL_ATTESTATION_CHALLENGE_HEADER) : null;
|
|
179
|
+
const proof = challenge && server.port
|
|
180
|
+
? createLocalAttestationProof(restart!.attestationSecret, challenge, process.pid, server.port)
|
|
181
|
+
: null;
|
|
182
|
+
if (proof) response.headers.set(LOCAL_ATTESTATION_PROOF_HEADER, proof);
|
|
183
|
+
return response;
|
|
184
|
+
}
|
|
185
|
+
if (url.pathname === SYSTEM_RESTART_PATH && req.method === SYSTEM_RESTART_METHOD) {
|
|
186
|
+
return await handleBoundRestart(req, server, deps.restart);
|
|
147
187
|
}
|
|
148
188
|
if (url.pathname === "/readyz" && req.method === "GET") {
|
|
149
189
|
return Response.json({ service: "opencodex", version: VERSION, role: "client", status: "ready", uptime: process.uptime(), pid: process.pid, port: server.port, protocolVersion: 1 });
|
|
@@ -232,3 +272,45 @@ export function startMachineListener(
|
|
|
232
272
|
},
|
|
233
273
|
} as Parameters<typeof Bun.serve>[0]);
|
|
234
274
|
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* `POST /api/system/restart` on the client listener (OCX-6). The only accepted principal is the
|
|
278
|
+
* process-bound restart capability `ocx restart` derives from this runtime's attestation secret:
|
|
279
|
+
* no dashboard session, admin token or link key reaches it, and a browser cannot mint it.
|
|
280
|
+
*/
|
|
281
|
+
async function handleBoundRestart(
|
|
282
|
+
req: Request,
|
|
283
|
+
server: Server<unknown>,
|
|
284
|
+
restart: MachineListenerDeps["restart"],
|
|
285
|
+
): Promise<Response> {
|
|
286
|
+
const noStore = { "cache-control": "no-store" };
|
|
287
|
+
if (!restart || !server.port) return json404(req);
|
|
288
|
+
const expected = parseExpectedSystemRestartPid(req.headers.get(SYSTEM_RESTART_EXPECTED_PID_HEADER));
|
|
289
|
+
if (expected.kind !== "present" || expected.pid !== process.pid) {
|
|
290
|
+
return Response.json({ success: false, error: "Restart target identity changed." }, { status: 409, headers: noStore });
|
|
291
|
+
}
|
|
292
|
+
const authorized = verifySystemRestartCapability(
|
|
293
|
+
restart.attestationSecret,
|
|
294
|
+
req.headers.get(SYSTEM_RESTART_NONCE_HEADER),
|
|
295
|
+
SYSTEM_RESTART_METHOD,
|
|
296
|
+
SYSTEM_RESTART_PATH,
|
|
297
|
+
process.pid,
|
|
298
|
+
server.port,
|
|
299
|
+
req.headers.get(SYSTEM_RESTART_CAPABILITY_HEADER),
|
|
300
|
+
);
|
|
301
|
+
if (!authorized) return Response.json({ success: false, error: "restart capability required" }, { status: 401, headers: noStore });
|
|
302
|
+
const admission = await restart.request();
|
|
303
|
+
if (admission.kind === "supervised") {
|
|
304
|
+
return Response.json({
|
|
305
|
+
success: false,
|
|
306
|
+
error: { code: RESTART_SUPERVISED_ERROR, supervisor: admission.supervisor },
|
|
307
|
+
}, { status: 409, headers: noStore });
|
|
308
|
+
}
|
|
309
|
+
return Response.json({
|
|
310
|
+
success: true,
|
|
311
|
+
message: admission.alreadyRestarting ? "Restart already in progress." : "Restarting the client runtime.",
|
|
312
|
+
activeTurnCount: 0,
|
|
313
|
+
drainTimeoutMs: CLIENT_RESTART_BUDGET_MS,
|
|
314
|
+
alreadyDraining: admission.alreadyRestarting,
|
|
315
|
+
}, { status: 202, headers: noStore });
|
|
316
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The connected client runtime's answer to the bound `POST /api/system/restart` (OCX-6).
|
|
3
|
+
*
|
|
4
|
+
* It is the same restart `ocx restart` drives on a standalone proxy, with one difference the
|
|
5
|
+
* Remote Link parity work requires: a failed restart keeps the old runtime. That is only possible
|
|
6
|
+
* when this process starts its own replacement, so:
|
|
7
|
+
*
|
|
8
|
+
* - Under the desktop app or a viable service supervisor the restart is refused (409
|
|
9
|
+
* `restart_supervised`) and the runtime keeps serving. The supervisor owns this process and its
|
|
10
|
+
* port; handing off to it means exiting before anything proves the replacement can start.
|
|
11
|
+
* - Otherwise the listener drains, a replacement `ocx start` is spawned and must answer within the
|
|
12
|
+
* budget. Success exits this process. A replacement that exited, or never answered and was then
|
|
13
|
+
* stopped, leaves this process to bind the port again, start a fresh tunnel supervisor and
|
|
14
|
+
* republish its records: the old runtime keeps serving and the CLI, still seeing its pid,
|
|
15
|
+
* reports the restart as failed.
|
|
16
|
+
*
|
|
17
|
+
* Everything runs inside {@link CLIENT_RESTART_BUDGET_MS} from acceptance.
|
|
18
|
+
*/
|
|
19
|
+
import {
|
|
20
|
+
CLIENT_RESTART_DRAIN_MS,
|
|
21
|
+
CLIENT_RESTART_READY_BY_MS,
|
|
22
|
+
isDesktopSupervised,
|
|
23
|
+
} from "../lib/system-restart-contract";
|
|
24
|
+
import type { OcxClientConnectionConfig } from "../types";
|
|
25
|
+
import { sameClientConnectionOwner, type readClientConnectionState } from "./state";
|
|
26
|
+
|
|
27
|
+
type ClientConnectionState = ReturnType<typeof readClientConnectionState>;
|
|
28
|
+
|
|
29
|
+
export type ClientRestartSupervisor = "service" | "desktop";
|
|
30
|
+
|
|
31
|
+
export type ClientRestartAdmission =
|
|
32
|
+
| { kind: "accepted"; alreadyRestarting: boolean }
|
|
33
|
+
| { kind: "supervised"; supervisor: ClientRestartSupervisor };
|
|
34
|
+
|
|
35
|
+
export interface ClientRestartIo {
|
|
36
|
+
/** Which supervisor owns this process, or null when it starts its own replacement. */
|
|
37
|
+
supervisor(): Promise<ClientRestartSupervisor | null>;
|
|
38
|
+
/** Stop the tunnel supervisor and the listener, giving in-flight requests up to `drainMs`. */
|
|
39
|
+
stop(drainMs: number): Promise<void>;
|
|
40
|
+
/** Start the replacement; resolves once it answers, rejects when none survives. */
|
|
41
|
+
spawnReplacement(deadlineAt: number): Promise<void>;
|
|
42
|
+
/** Serve again on this process: listener, fresh tunnel supervisor, records. */
|
|
43
|
+
resume(): Promise<void>;
|
|
44
|
+
exitProcess(code: number): void;
|
|
45
|
+
now?: () => number;
|
|
46
|
+
sleep?: (ms: number) => Promise<void>;
|
|
47
|
+
warn?: (message: string) => void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ClientRestartController {
|
|
51
|
+
request(): Promise<ClientRestartAdmission>;
|
|
52
|
+
/** Settles when the restart in flight (if any) has finished or rolled back. Tests only. */
|
|
53
|
+
settled(): Promise<void>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Stable, path-free label for a failure (an error message can carry a path). */
|
|
57
|
+
function failureCode(error: unknown): string {
|
|
58
|
+
const code = error && typeof error === "object" && "code" in error ? (error as { code?: unknown }).code : undefined;
|
|
59
|
+
return typeof code === "string" && /^[A-Za-z0-9_]{1,64}$/.test(code) ? code : "failed";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function createClientRestartController(io: ClientRestartIo): ClientRestartController {
|
|
63
|
+
const now = io.now ?? Date.now;
|
|
64
|
+
const sleep = io.sleep ?? Bun.sleep;
|
|
65
|
+
const warn = io.warn ?? ((message: string) => console.warn(message));
|
|
66
|
+
let flight: Promise<void> | null = null;
|
|
67
|
+
|
|
68
|
+
const run = async (acceptedAt: number): Promise<void> => {
|
|
69
|
+
// Let the 202 reach the CLI before the listener starts closing.
|
|
70
|
+
await sleep(200);
|
|
71
|
+
try {
|
|
72
|
+
await io.stop(CLIENT_RESTART_DRAIN_MS);
|
|
73
|
+
} catch (error) {
|
|
74
|
+
warn(`[client] restart: stopping the runtime failed (${failureCode(error)}); continuing`);
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
await io.spawnReplacement(acceptedAt + CLIENT_RESTART_READY_BY_MS);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
warn(`[client] restart: the replacement did not start (${failureCode(error)}); this runtime keeps serving`);
|
|
80
|
+
try {
|
|
81
|
+
await io.resume();
|
|
82
|
+
} catch (resumeError) {
|
|
83
|
+
warn(`[client] restart: this runtime could not serve again (${failureCode(resumeError)}); run 'ocx start'`);
|
|
84
|
+
io.exitProcess(1);
|
|
85
|
+
}
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
io.exitProcess(0);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
async request() {
|
|
93
|
+
const supervisor = await io.supervisor();
|
|
94
|
+
if (supervisor) return { kind: "supervised", supervisor };
|
|
95
|
+
if (flight) return { kind: "accepted", alreadyRestarting: true };
|
|
96
|
+
flight = run(now()).finally(() => { flight = null; });
|
|
97
|
+
return { kind: "accepted", alreadyRestarting: false };
|
|
98
|
+
},
|
|
99
|
+
async settled() {
|
|
100
|
+
await flight;
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Production supervision check: the desktop app, then a service that will really relaunch us. */
|
|
106
|
+
export async function clientRestartSupervisor(): Promise<ClientRestartSupervisor | null> {
|
|
107
|
+
if (isDesktopSupervised()) return "desktop";
|
|
108
|
+
if (process.env.OCX_SERVICE !== "1") return null;
|
|
109
|
+
// Same viability test as the standalone restart: stale service assets would not relaunch.
|
|
110
|
+
const { isServiceViable } = await import("../service/orchestration");
|
|
111
|
+
return isServiceViable() ? "service" : null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* After a failed replacement: serve the same connection again only while it is still the durable
|
|
116
|
+
* one (same owner and link). Anything else, a disconnect or a different connection, goes to the
|
|
117
|
+
* recycle, which starts what the current state calls for instead of a stale client runtime.
|
|
118
|
+
*/
|
|
119
|
+
export function clientRestartRollbackAction(
|
|
120
|
+
served: OcxClientConnectionConfig,
|
|
121
|
+
current: ClientConnectionState,
|
|
122
|
+
): "resume" | "recycle" {
|
|
123
|
+
return current.kind === "connected" && sameClientConnectionOwner(current.value, served)
|
|
124
|
+
&& current.value.link?.linkId === served.link?.linkId
|
|
125
|
+
? "resume"
|
|
126
|
+
: "recycle";
|
|
127
|
+
}
|
package/src/client/runtime.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { createLocalAttestationSecret } from "../lib/local-management-attestatio
|
|
|
9
9
|
import { loadServiceTokenFromFile, serviceApiTokenFingerprint } from "../lib/service-secrets";
|
|
10
10
|
import { handledSignalExitCode } from "../lib/handled-signal-exit";
|
|
11
11
|
import {
|
|
12
|
+
CLIENT_RESTART_REBIND_WAIT_MS,
|
|
12
13
|
DESKTOP_RESTART_EXIT_CODE,
|
|
13
14
|
DESKTOP_SUPERVISED_ENV,
|
|
14
15
|
DESKTOP_SUPERVISED_PORT_WAIT_MS,
|
|
@@ -23,12 +24,22 @@ import { createClientLinkSupervisor, type ClientLinkSupervisor } from "./link-tu
|
|
|
23
24
|
import { clientLinkStatePath, isChildInitiatedLink, recordChildInitiatedLink } from "./link-state";
|
|
24
25
|
import { startMachineListener, type MachineListenerDeps } from "./machine-listener";
|
|
25
26
|
import { isLinkConnection, readClientConnectionState } from "./state";
|
|
27
|
+
import { withClientLifecycleSync } from "./lifecycle-lock";
|
|
28
|
+
import { clientRestartRollbackAction, clientRestartSupervisor, createClientRestartController, type ClientRestartController } from "./runtime-restart";
|
|
29
|
+
import { createLinkParityReporter, type LinkParityReporter } from "./link-parity-reporter";
|
|
26
30
|
|
|
27
31
|
let activeServer: Server<unknown> | null = null;
|
|
28
32
|
let activePort: number | null = null;
|
|
29
33
|
let activeSupervisor: ClientLinkSupervisor | null = null;
|
|
34
|
+
let activeReporter: LinkParityReporter | null = null;
|
|
30
35
|
let recycleScheduled = false;
|
|
31
36
|
|
|
37
|
+
/**
|
|
38
|
+
* When this runtime's process started (OCX-6, P5): what `/healthz` reports as `startedAt` and the
|
|
39
|
+
* Child reports to its Home. A Home settings apply recorded after it means this runtime predates it.
|
|
40
|
+
*/
|
|
41
|
+
export const CLIENT_RUNTIME_STARTED_AT = new Date(Date.now() - process.uptime() * 1000).toISOString();
|
|
42
|
+
|
|
32
43
|
/**
|
|
33
44
|
* How long link mode waits for its configured port: the budget a hard-pinned `ocx start --port`
|
|
34
45
|
* gives `reclaimListenPort` (`src/cli/index.ts`).
|
|
@@ -53,6 +64,8 @@ export interface ClientRuntimeIo {
|
|
|
53
64
|
/** Link-mode port budget; defaults to {@link LINK_PORT_WAIT_MS}. */
|
|
54
65
|
portWaitMs?: number;
|
|
55
66
|
startListener?: typeof startMachineListener;
|
|
67
|
+
/** Which supervisor owns this process for a bound restart; tests replace the production check. */
|
|
68
|
+
restartSupervisor?: () => Promise<"service" | "desktop" | null>;
|
|
56
69
|
}
|
|
57
70
|
|
|
58
71
|
export interface StandaloneRecycleIo {
|
|
@@ -214,7 +227,7 @@ export async function bindClientListener(
|
|
|
214
227
|
preferred: number;
|
|
215
228
|
explicitPort: boolean;
|
|
216
229
|
configuredPort: number;
|
|
217
|
-
} & Pick<MachineListenerDeps, "linkStatus" | "linkKeySource" | "linkTunnel">,
|
|
230
|
+
} & Pick<MachineListenerDeps, "linkStatus" | "linkKeySource" | "linkTunnel" | "restart">,
|
|
218
231
|
io: ClientRuntimeIo = {},
|
|
219
232
|
): Promise<{ server: Server<unknown>; port: number }> {
|
|
220
233
|
const { linkMode } = request;
|
|
@@ -254,6 +267,7 @@ export async function bindClientListener(
|
|
|
254
267
|
...(request.linkStatus ? { linkStatus: request.linkStatus } : {}),
|
|
255
268
|
...(request.linkKeySource ? { linkKeySource: request.linkKeySource } : {}),
|
|
256
269
|
...(request.linkTunnel ? { linkTunnel: request.linkTunnel } : {}),
|
|
270
|
+
...(request.restart ? { restart: request.restart } : {}),
|
|
257
271
|
});
|
|
258
272
|
return { server, port: server.port ?? port };
|
|
259
273
|
} catch (error) {
|
|
@@ -286,33 +300,129 @@ export async function startClientRuntime(
|
|
|
286
300
|
if (linkMode && state.value.link) {
|
|
287
301
|
try { recordChildInitiatedLink(state.value.link.linkId); } catch { /* the sidecar check below still applies */ }
|
|
288
302
|
}
|
|
289
|
-
const
|
|
290
|
-
? createClientLinkSupervisor({
|
|
291
|
-
onLinkEnded: () => scheduleStandaloneRecycle(state.value.tokenFingerprint),
|
|
292
|
-
linkKey,
|
|
293
|
-
})
|
|
294
|
-
: null;
|
|
303
|
+
const supervised = linkMode && existsSync(clientLinkStatePath());
|
|
295
304
|
// A Child with no sidecar and no record of joining itself was connected by its Home over
|
|
296
305
|
// `ssh -R`; that link keeps 2.67.0's unproven forward. A Child-initiated link that lost its
|
|
297
306
|
// sidecar gets no gate at all, so the relay refuses it.
|
|
298
|
-
const homeInitiated = linkMode && !
|
|
307
|
+
const homeInitiated = linkMode && !supervised && !!state.value.link
|
|
299
308
|
&& !isChildInitiatedLink(state.value.link.linkId);
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
309
|
+
// One secret for the life of this process: a rollback republishes the same identity.
|
|
310
|
+
const attestationSecret = createLocalAttestationSecret();
|
|
311
|
+
|
|
312
|
+
// Bind, start a fresh tunnel supervisor and publish. Runs once at start and again when a failed
|
|
313
|
+
// restart rolls back (OCX-6); the stopped supervisor cannot be started twice, so each call builds one.
|
|
314
|
+
// The port this process actually serves on; a rollback binds exactly this one again.
|
|
315
|
+
let servedPort = preferred;
|
|
316
|
+
// Returns false only for a rollback whose connection changed while it was binding: the new
|
|
317
|
+
// listener is then closed unpublished and the caller recycles.
|
|
318
|
+
const serve = async (rollback?: { port: number; portWaitMs: number }): Promise<boolean> => {
|
|
319
|
+
const supervisor = supervised
|
|
320
|
+
? createClientLinkSupervisor({
|
|
321
|
+
onLinkEnded: () => scheduleStandaloneRecycle(state.value.tokenFingerprint),
|
|
322
|
+
linkKey,
|
|
323
|
+
})
|
|
324
|
+
: null;
|
|
325
|
+
const { server, port: boundPort } = await bindClientListener({
|
|
326
|
+
state: state.value,
|
|
327
|
+
linkMode,
|
|
328
|
+
// A rollback takes back exactly the port it gave up: never the preferred one, never another.
|
|
329
|
+
preferred: rollback?.port ?? preferred,
|
|
330
|
+
explicitPort: rollback !== undefined || options.port !== undefined,
|
|
331
|
+
configuredPort: config.port,
|
|
332
|
+
restart: { attestationSecret, startedAt: CLIENT_RUNTIME_STARTED_AT, request: () => restartController.request() },
|
|
333
|
+
...(linkMode ? { linkStatus: () => activeSupervisor?.status() ?? { kind: "stopped" as const }, linkKeySource: linkKey } : {}),
|
|
334
|
+
...(supervisor ? { linkTunnel: supervisor } : homeInitiated ? { linkTunnel: HOME_INITIATED_LINK_TUNNEL } : {}),
|
|
335
|
+
}, rollback === undefined ? io : { ...io, portWaitMs: rollback.portWaitMs });
|
|
336
|
+
const publish = (): void => {
|
|
337
|
+
activeServer = server;
|
|
338
|
+
activePort = boundPort;
|
|
339
|
+
servedPort = boundPort;
|
|
340
|
+
activeSupervisor = supervisor;
|
|
341
|
+
supervisor?.start();
|
|
342
|
+
writePid(process.pid);
|
|
343
|
+
writeRuntimePort(clientRuntimeRecord(process.pid, boundPort, attestationSecret));
|
|
344
|
+
if (linkMode && state.value.link && linkKey) {
|
|
345
|
+
activeReporter = createLinkParityReporter({ connection: state.value, linkKey, runtimeStartedAt: CLIENT_RUNTIME_STARTED_AT });
|
|
346
|
+
activeReporter.start();
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
if (!rollback) {
|
|
350
|
+
publish();
|
|
351
|
+
return true;
|
|
352
|
+
}
|
|
353
|
+
// A rollback publishes under the client lifecycle lock, after rechecking the connection there,
|
|
354
|
+
// so a disconnect or connect that committed during the bind can never be served over. A lock
|
|
355
|
+
// held by such an operation counts as a change.
|
|
356
|
+
let published = false;
|
|
357
|
+
try {
|
|
358
|
+
published = withClientLifecycleSync(() => {
|
|
359
|
+
if (clientRestartRollbackAction(state.value, readClientConnectionState()) === "recycle") return false;
|
|
360
|
+
publish();
|
|
361
|
+
return true;
|
|
362
|
+
});
|
|
363
|
+
} catch {
|
|
364
|
+
published = false;
|
|
365
|
+
}
|
|
366
|
+
if (!published) {
|
|
367
|
+
try { await server.stop(true); } catch { /* never published */ }
|
|
368
|
+
}
|
|
369
|
+
return published;
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
// Stop everything this process serves, giving in-flight requests up to `drainMs`.
|
|
373
|
+
const stopServing = async (drainMs: number): Promise<void> => {
|
|
374
|
+
activeReporter?.stop();
|
|
375
|
+
activeReporter = null;
|
|
376
|
+
try {
|
|
377
|
+
await activeSupervisor?.stop();
|
|
378
|
+
} catch (error) {
|
|
379
|
+
console.warn(`[client] link supervisor stop failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
380
|
+
}
|
|
381
|
+
activeSupervisor = null;
|
|
382
|
+
const server = activeServer;
|
|
383
|
+
activeServer = null;
|
|
384
|
+
if (!server) return;
|
|
385
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
386
|
+
try {
|
|
387
|
+
await Promise.race([
|
|
388
|
+
Promise.resolve(server.stop(false)),
|
|
389
|
+
new Promise<void>(resolve => { timer = setTimeout(resolve, drainMs); }),
|
|
390
|
+
]);
|
|
391
|
+
} catch { /* closed below */ } finally {
|
|
392
|
+
if (timer) clearTimeout(timer);
|
|
393
|
+
}
|
|
394
|
+
try { await server.stop(true); } catch { /* already closed */ }
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
const restartController: ClientRestartController = createClientRestartController({
|
|
398
|
+
supervisor: io.restartSupervisor ?? clientRestartSupervisor,
|
|
399
|
+
stop: stopServing,
|
|
400
|
+
spawnReplacement: async deadlineAt => {
|
|
401
|
+
const { replacementStartEnvironment } = await import("../server/management/system-restart");
|
|
402
|
+
const { spawnReplacementStart } = await import("../server/restart-replacement");
|
|
403
|
+
await spawnReplacementStart(
|
|
404
|
+
{ port: activePort ?? config.port, waitForHealth: true, env: replacementStartEnvironment(true), notReady: "terminate" },
|
|
405
|
+
{ deadlineAt },
|
|
406
|
+
);
|
|
407
|
+
},
|
|
408
|
+
resume: async () => {
|
|
409
|
+
// The connection may have changed while the replacement was starting (`ocx disconnect`, a
|
|
410
|
+
// new connect). Serving the old one again would be a stale runtime nothing recycles; hand
|
|
411
|
+
// off to the recycle, which starts whatever the durable state now calls for.
|
|
412
|
+
if (clientRestartRollbackAction(state.value, readClientConnectionState()) === "recycle") {
|
|
413
|
+
await recycleStandalone(state.value.tokenFingerprint);
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
if (!(await serve({ port: servedPort, portWaitMs: CLIENT_RESTART_REBIND_WAIT_MS }))) {
|
|
417
|
+
await recycleStandalone(state.value.tokenFingerprint);
|
|
418
|
+
}
|
|
419
|
+
},
|
|
420
|
+
exitProcess: code => { process.exit(code); },
|
|
421
|
+
});
|
|
422
|
+
|
|
313
423
|
installCrashGuards();
|
|
314
|
-
|
|
315
|
-
|
|
424
|
+
// serve() publishes the pid and runtime record; the ownership lease is released only after it.
|
|
425
|
+
await serve();
|
|
316
426
|
options.afterPublish?.();
|
|
317
427
|
|
|
318
428
|
let shuttingDown = false;
|
|
@@ -320,14 +430,16 @@ export async function startClientRuntime(
|
|
|
320
430
|
if (shuttingDown) return;
|
|
321
431
|
shuttingDown = true;
|
|
322
432
|
void (async () => {
|
|
433
|
+
activeReporter?.stop();
|
|
434
|
+
activeReporter = null;
|
|
323
435
|
try {
|
|
324
|
-
await
|
|
436
|
+
await activeSupervisor?.stop();
|
|
325
437
|
} catch (error) {
|
|
326
438
|
console.warn(`[client] link supervisor stop failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
327
439
|
}
|
|
328
440
|
activeSupervisor = null;
|
|
329
441
|
try {
|
|
330
|
-
|
|
442
|
+
activeServer?.stop(true);
|
|
331
443
|
} catch (error) {
|
|
332
444
|
console.warn(`[client] listener stop failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
333
445
|
} finally {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { observeSignedInCodexIdentity } from "./signed-in-identity";
|
|
2
|
+
import { observeMainCodexCollisionIdentity } from "./collision-identity";
|
|
2
3
|
import { existsSync, readFileSync } from "node:fs";
|
|
3
4
|
import {
|
|
4
5
|
deleteConfigTopLevelKey,
|
|
@@ -9,11 +10,12 @@ import {
|
|
|
9
10
|
import { captureConfigTopLevelRollback } from "../config/rebase-provenance";
|
|
10
11
|
import { removeCodexAccountCredential } from "./account-store";
|
|
11
12
|
import { clearAccountNeedsReauth } from "./account-runtime-state";
|
|
12
|
-
import {
|
|
13
|
+
import { readMainAuth } from "./auth-collision";
|
|
13
14
|
import { MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "./main-account";
|
|
14
15
|
import { clearAccountQuota } from "./quota";
|
|
15
16
|
import { forgetManualPreference } from "./routing/active-account";
|
|
16
17
|
import { deleteAllHealthForAccount } from "./routing/health-store";
|
|
18
|
+
import { forgetCodexFailureWindow } from "./routing/failure-window";
|
|
17
19
|
import { clearThreadAccountMapForAccount } from "./routing/thread-affinity";
|
|
18
20
|
|
|
19
21
|
import { clearCodexPoolRefreshFailure } from "./pool-refresh-backoff";
|
|
@@ -49,11 +51,15 @@ export function purgeCodexAccountRuntimeState(accountId: string): void {
|
|
|
49
51
|
clearAccountNeedsReauth(accountId);
|
|
50
52
|
clearAccountQuota(accountId);
|
|
51
53
|
clearThreadAccountMapForAccount(accountId);
|
|
52
|
-
// Deliberately not via `./routing`: the account-delete path only needs the health rows
|
|
53
|
-
//
|
|
54
|
-
// routing/health-store and routing/active-account.
|
|
54
|
+
// Deliberately not via `./routing`: the account-delete path only needs the health rows
|
|
55
|
+
// and their sibling routing state gone, and importing the router barrel here closed a
|
|
56
|
+
// cycle through routing/health-store and routing/active-account. The leaf imports below
|
|
57
|
+
// carry every per-account forget that barrel helper used to perform — including the
|
|
58
|
+
// failure window, whose degrade and pin-warning state must not leak into a replacement
|
|
59
|
+
// account or a new physical login behind __main__.
|
|
55
60
|
deleteAllHealthForAccount(accountId);
|
|
56
61
|
forgetManualPreference(accountId);
|
|
62
|
+
forgetCodexFailureWindow(accountId);
|
|
57
63
|
clearCodexPoolRefreshFailure(accountId);
|
|
58
64
|
if (accountId === MAIN_CODEX_ACCOUNT_ID) {
|
|
59
65
|
clearMainAccountInfoCache();
|
|
@@ -74,7 +80,8 @@ function purgeMainCodexAccountRuntimeState(): void {
|
|
|
74
80
|
* cooldown, quota, reauth, and thread affinity do not leak across accounts.
|
|
75
81
|
*/
|
|
76
82
|
export function reconcileMainCodexAccountRuntimeState(): boolean {
|
|
77
|
-
const read =
|
|
83
|
+
const read = readMainAuth();
|
|
84
|
+
if (read.status === "missing" || read.status === "api-key-only") observeMainCodexCollisionIdentity();
|
|
78
85
|
const currentAccountId = read.status === "ok"
|
|
79
86
|
? extractAccountId(read.tokens.id_token, read.tokens.access_token) ?? (read.tokens.account_id || null)
|
|
80
87
|
: null;
|
|
@@ -86,11 +93,13 @@ export function reconcileMainCodexAccountRuntimeState(): boolean {
|
|
|
86
93
|
observedMainChatgptAccountId = currentAccountId;
|
|
87
94
|
if (previousAccountId === undefined || previousAccountId === currentAccountId) {
|
|
88
95
|
observeMainQuotaIdentity(currentAccountId);
|
|
96
|
+
if (read.status === "ok") observeMainCodexCollisionIdentity(read.tokens);
|
|
89
97
|
return false;
|
|
90
98
|
}
|
|
91
99
|
|
|
92
100
|
purgeMainCodexAccountRuntimeState();
|
|
93
101
|
observeMainQuotaIdentity(currentAccountId);
|
|
102
|
+
if (read.status === "ok") observeMainCodexCollisionIdentity(read.tokens);
|
|
94
103
|
return true;
|
|
95
104
|
}
|
|
96
105
|
|
|
@@ -103,7 +112,8 @@ export function reconcileMainCodexAccountRuntimeState(): boolean {
|
|
|
103
112
|
export function initializeMainAccountPolicyBinding(authPath: string): boolean {
|
|
104
113
|
// Startup observes the pinned owned path inside the exclusive claim: bound the read so a
|
|
105
114
|
// replaced non-regular or oversized file cannot stall startup inside that claim.
|
|
106
|
-
const result =
|
|
115
|
+
const result = readMainAuth(authPath);
|
|
116
|
+
if (result.status === "missing" || result.status === "api-key-only") observeMainCodexCollisionIdentity();
|
|
107
117
|
if (result.status !== "ok") return false;
|
|
108
118
|
const { tokens } = result;
|
|
109
119
|
if (typeof tokens.access_token !== "string" || !tokens.access_token
|
|
@@ -124,6 +134,7 @@ export function initializeMainAccountPolicyBinding(authPath: string): boolean {
|
|
|
124
134
|
if (previousAccountId !== undefined && previousAccountId !== accountId) purgeMainCodexAccountRuntimeState();
|
|
125
135
|
observeSignedInCodexIdentity(tokens);
|
|
126
136
|
observeMainQuotaIdentity(accountId);
|
|
137
|
+
observeMainCodexCollisionIdentity(tokens);
|
|
127
138
|
return observeMainQuotaCredential(tokens.access_token, accountId) !== undefined;
|
|
128
139
|
}
|
|
129
140
|
|
|
@@ -136,6 +147,7 @@ export function applyConfirmedMainCodexAccountTransition(
|
|
|
136
147
|
toAccountId: string,
|
|
137
148
|
): boolean {
|
|
138
149
|
observeSignedInCodexIdentity();
|
|
150
|
+
observeMainCodexCollisionIdentity();
|
|
139
151
|
if (!fromAccountId || !toAccountId || fromAccountId === toAccountId) {
|
|
140
152
|
if (toAccountId) {
|
|
141
153
|
observedMainChatgptAccountId = toAccountId;
|
|
@@ -150,6 +162,7 @@ export function applyConfirmedMainCodexAccountTransition(
|
|
|
150
162
|
}
|
|
151
163
|
|
|
152
164
|
export function resetMainCodexAccountIdentityTrackingForTests(): void {
|
|
165
|
+
observeMainCodexCollisionIdentity();
|
|
153
166
|
observedMainChatgptAccountId = undefined;
|
|
154
167
|
observeSignedInCodexIdentity();
|
|
155
168
|
clearMainAccountCredentialPresence();
|