@bitkyc08/opencodex 2.90.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-DgxkE6Md.js → App-nZ34cR5G.js} +18 -18
- package/gui/dist/assets/{Tray-DbTNc_88.js → Tray-BuLy7hL1.js} +1 -1
- package/gui/dist/assets/{index-TmaHa54u.js → index-DGXS51sp.js} +12 -12
- package/gui/dist/assets/{quota-summary-B16x5DZZ.js → quota-summary-DO1Sx5TM.js} +1 -1
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/adapters/anthropic.ts +2 -5
- package/src/adapters/claude-cli/usage-admission.ts +14 -0
- package/src/adapters/openai-responses/passthrough.ts +2 -1
- package/src/adapters/openai-responses/request-strips.ts +32 -0
- package/src/adapters/openai-responses/transport.ts +6 -0
- package/src/chat/inbound.ts +20 -2
- package/src/chat/outbound.ts +36 -4
- package/src/chat/reasoning-details.ts +137 -0
- 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/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/auth-context.ts +10 -6
- package/src/codex/catalog/parsing.ts +4 -0
- package/src/codex/desktop-compatibility/windows-key-protection.ts +18 -6
- package/src/codex/quota-history.ts +7 -0
- package/src/codex/quota.ts +10 -0
- package/src/codex/subagent-model-fallback.ts +24 -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/schema/config-schema.ts +3 -0
- package/src/config/schema/fallback-policy.ts +40 -0
- package/src/generated/compatibility-version.json +205 -97
- package/src/lib/authorization-scheme.ts +95 -0
- package/src/lib/provider-client-headers.ts +1 -0
- package/src/lib/redact.ts +2 -2
- package/src/lib/system-restart-contract.ts +24 -0
- package/src/lib/test-home-guard.ts +78 -11
- 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/index.ts +1 -1
- package/src/oauth/kimi.ts +2 -0
- 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/mistral-transport.ts +37 -4
- package/src/providers/quota/antigravity.ts +2 -8
- 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/parser.ts +7 -0
- package/src/responses/reasoning-envelope.ts +12 -0
- 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/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 +19 -2
- 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/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/responses/agent-task-recovery.ts +1 -1
- package/src/server/responses/compact.ts +2 -2
- package/src/server/responses/core-auth.ts +11 -1
- package/src/server/responses/core-combo.ts +72 -1
- package/src/server/responses/encrypted-payload.ts +3 -3
- package/src/server/responses/main-hard-lock-routing.ts +54 -2
- package/src/server/responses/policy-fallback.ts +44 -1
- package/src/server/responses/request-prepare.ts +9 -1
- 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/status-json.ts +116 -0
- package/src/types/config.ts +7 -0
- package/src/types/fallback.ts +38 -0
- package/gui/dist/assets/App-dnkJ85te.css +0 -1
|
@@ -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 {
|
|
@@ -98,6 +98,7 @@ import { getMainReserveAuthorization, isMainReserveAuthorizationLive, nativeUser
|
|
|
98
98
|
import { UpstreamRetryEvidenceError } from "../lib/upstream-retry";
|
|
99
99
|
import { getEffectiveCodexAutoSwitchThreshold } from "./account-auto-switch";
|
|
100
100
|
import { CODEX_MAIN_DUPLICATE_ACTION, configuredCodexMainDuplicate } from "./collision-identity";
|
|
101
|
+
import { AgentAssertionUnsupportedError, authorizationCredential, hasAgentAssertionAuthorization } from "../lib/authorization-scheme";
|
|
101
102
|
|
|
102
103
|
/**
|
|
103
104
|
* A request-owned bearer cannot inspect the physical main credential for its plan, but cached
|
|
@@ -701,7 +702,7 @@ async function authorizeReserveCredential(
|
|
|
701
702
|
|
|
702
703
|
function selectedCodexToken(headers: Headers): { accessToken: string; chatgptAccountId: string } {
|
|
703
704
|
return {
|
|
704
|
-
accessToken: headers
|
|
705
|
+
accessToken: authorizationCredential(headers) ?? "",
|
|
705
706
|
chatgptAccountId: headers.get("chatgpt-account-id") ?? "",
|
|
706
707
|
};
|
|
707
708
|
}
|
|
@@ -716,7 +717,7 @@ function selectedCodexToken(headers: Headers): { accessToken: string; chatgptAcc
|
|
|
716
717
|
export function callerCodexWorkspaceAccountId(headers: Headers): string | undefined {
|
|
717
718
|
const explicit = headers.get("chatgpt-account-id");
|
|
718
719
|
if (explicit) return explicit;
|
|
719
|
-
const bearer = headers
|
|
720
|
+
const bearer = authorizationCredential(headers);
|
|
720
721
|
return bearer ? extractAccountId(undefined, bearer) : undefined;
|
|
721
722
|
}
|
|
722
723
|
|
|
@@ -807,7 +808,7 @@ function assertMainAccountPolicy(
|
|
|
807
808
|
|
|
808
809
|
/** No auth-file I/O: an unsigned claim alone never identifies a caller as stored main. */
|
|
809
810
|
function callerMatchesObservedMain(headers: Headers): boolean {
|
|
810
|
-
const bearer = headers
|
|
811
|
+
const bearer = authorizationCredential(headers);
|
|
811
812
|
if (!bearer) return false;
|
|
812
813
|
const effectiveAccountId = headers.get("chatgpt-account-id")
|
|
813
814
|
?? extractAccountId(undefined, bearer);
|
|
@@ -831,7 +832,7 @@ function sameCredentialMaterial(a: string, b: string): boolean {
|
|
|
831
832
|
* cooldown — the safe direction.
|
|
832
833
|
*/
|
|
833
834
|
function callerIsCooledPoolAccount(headers: Headers, config: OcxConfig, accountId: string): boolean {
|
|
834
|
-
const bearer = headers
|
|
835
|
+
const bearer = authorizationCredential(headers);
|
|
835
836
|
if (!bearer) return true;
|
|
836
837
|
const callerAccountId = headers.get("chatgpt-account-id") ?? extractAccountId(undefined, bearer);
|
|
837
838
|
if (callerAccountId === undefined) return true;
|
|
@@ -1767,7 +1768,7 @@ export class CodexMainSubstitutionUnavailableError extends Error {
|
|
|
1767
1768
|
|
|
1768
1769
|
/** Dispatch identity comes only from an owned observation of the selected credential. */
|
|
1769
1770
|
function selectedMainQuotaDispatch(selected: Headers): MainQuotaDispatch | undefined {
|
|
1770
|
-
const bearer = selected
|
|
1771
|
+
const bearer = authorizationCredential(selected);
|
|
1771
1772
|
if (!bearer) return undefined;
|
|
1772
1773
|
const accountId = selected.get("chatgpt-account-id") ?? extractAccountId(undefined, bearer);
|
|
1773
1774
|
return captureMainQuotaDispatch(bearer, accountId, captureConfigGeneration());
|
|
@@ -1792,6 +1793,8 @@ export function materializeCodexUpstreamAuth(
|
|
|
1792
1793
|
ctx: CodexAuthContext,
|
|
1793
1794
|
options: CodexAuthMaterializationOptions = {},
|
|
1794
1795
|
): Headers {
|
|
1796
|
+
// assertion_only must never be answered with a pool, stored-main, or other substituted Bearer.
|
|
1797
|
+
if (hasAgentAssertionAuthorization(headers)) throw new AgentAssertionUnsupportedError();
|
|
1795
1798
|
if (ctx.kind === "main") ctx.mainQuotaDispatch = undefined;
|
|
1796
1799
|
const selected = new Headers();
|
|
1797
1800
|
for (const name of FORWARD_HEADERS) {
|
|
@@ -1814,7 +1817,7 @@ export function materializeCodexUpstreamAuth(
|
|
|
1814
1817
|
}
|
|
1815
1818
|
if (ctx.kind === "main" && options.substituteMainCredential !== true
|
|
1816
1819
|
&& !selected.has("chatgpt-account-id")) {
|
|
1817
|
-
const bearer = selected
|
|
1820
|
+
const bearer = authorizationCredential(selected);
|
|
1818
1821
|
const accountId = bearer ? extractAccountId(undefined, bearer) : undefined;
|
|
1819
1822
|
if (accountId) selected.set("chatgpt-account-id", accountId);
|
|
1820
1823
|
}
|
|
@@ -1892,6 +1895,7 @@ export async function materializeCodexUpstreamAuthAsync(
|
|
|
1892
1895
|
ctx: CodexAuthContext,
|
|
1893
1896
|
options: CodexAuthMaterializationOptions = {},
|
|
1894
1897
|
): Promise<Headers> {
|
|
1898
|
+
if (hasAgentAssertionAuthorization(headers)) throw new AgentAssertionUnsupportedError();
|
|
1895
1899
|
if (ctx.kind === "main") ctx.mainQuotaDispatch = undefined;
|
|
1896
1900
|
if (requiresReserveAuthorization(options.config, options.modelId, options.admission)) {
|
|
1897
1901
|
return materializeReserveUpstreamAuth(headers, ctx, options);
|
|
@@ -809,6 +809,10 @@ export function normalizeRoutedCatalogEntry(
|
|
|
809
809
|
delete entry.multi_agent_version;
|
|
810
810
|
delete entry.multi_agent_reasoning_effort;
|
|
811
811
|
delete entry.use_responses_lite;
|
|
812
|
+
// Codex 0.164 sends positional `configuration_update` effort items only for rows declaring this
|
|
813
|
+
// and freezes the request-level effort for the context window. A routed provider never
|
|
814
|
+
// negotiated that contract, so a row cloned from a GPT-6 template must not inherit it.
|
|
815
|
+
delete entry.supports_reasoning_effort_updates;
|
|
812
816
|
delete entry.supports_websockets;
|
|
813
817
|
// Routed rows cloned from native templates must not inherit OpenAI-only experimental context
|
|
814
818
|
// delivery. Codex reads the flag as "this model accepts experimental context history" and drives
|
|
@@ -10,17 +10,24 @@ const ENTROPY = new TextEncoder().encode(PURPOSE);
|
|
|
10
10
|
// Only the PowerShell fallback is bounded by a clock. A cold Windows PowerShell that must also load
|
|
11
11
|
// System.Security has been measured past 10s on a loaded machine (Windows CI run 38591626343).
|
|
12
12
|
const DPAPI_TIMEOUT_MS = 20_000;
|
|
13
|
+
// Cmdlet-free on purpose. The first cmdlet a PowerShell session runs (Add-Type, ConvertFrom-Json)
|
|
14
|
+
// sends it through command discovery and module auto-loading, which without a module-analysis cache
|
|
15
|
+
// for the profile scans the modules on PSModulePath. The test runner gives every run a fresh profile
|
|
16
|
+
// and CI images carry large module sets, so that scan, not DPAPI, is what cold runs waited on.
|
|
17
|
+
// Assembly loading, string splitting and base64 are plain .NET calls outside command discovery.
|
|
18
|
+
// The request is "<operation>\n<base64 data>" on stdin.
|
|
13
19
|
const SCRIPT = [
|
|
14
20
|
"$ErrorActionPreference='Stop'",
|
|
15
|
-
"
|
|
16
|
-
"$r=[Console]::In.ReadToEnd()
|
|
17
|
-
"
|
|
21
|
+
"[void][Reflection.Assembly]::Load('System.Security, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a')",
|
|
22
|
+
"$r=[Console]::In.ReadToEnd().Split([char]10)",
|
|
23
|
+
"if($r.Length -ne 2){throw 'Invalid request'}",
|
|
24
|
+
"$data=[Convert]::FromBase64String($r[1])",
|
|
18
25
|
`if($data.Length -eq 0 -or $data.Length -gt ${MAX_INPUT}){throw 'Invalid data size'}`,
|
|
19
26
|
`$entropy=[Text.Encoding]::UTF8.GetBytes('${PURPOSE}')`,
|
|
20
27
|
"$scope=[Security.Cryptography.DataProtectionScope]::CurrentUser",
|
|
21
|
-
"if($r
|
|
28
|
+
"if($r[0] -ceq 'protect'){",
|
|
22
29
|
" $out=[Security.Cryptography.ProtectedData]::Protect($data,$entropy,$scope)",
|
|
23
|
-
"}elseif($r
|
|
30
|
+
"}elseif($r[0] -ceq 'unprotect'){",
|
|
24
31
|
" $out=[Security.Cryptography.ProtectedData]::Unprotect($data,$entropy,$scope)",
|
|
25
32
|
"}else{throw 'Invalid operation'}",
|
|
26
33
|
"[Console]::Out.Write([Convert]::ToBase64String($out))",
|
|
@@ -126,7 +133,7 @@ function runPowerShellDpapi(operation: Operation, data: Uint8Array, timeoutMs =
|
|
|
126
133
|
resolve(bytes);
|
|
127
134
|
});
|
|
128
135
|
child.stdin?.on("error", () => { /* execFile's completion reports the failed child. */ });
|
|
129
|
-
child.stdin?.end(
|
|
136
|
+
child.stdin?.end(`${operation}\n${Buffer.from(data).toString("base64")}`);
|
|
130
137
|
});
|
|
131
138
|
}
|
|
132
139
|
|
|
@@ -163,3 +170,8 @@ export function powerShellAuthorityKeyProtectionForTests(timeoutMs: number): Aut
|
|
|
163
170
|
export function usesNativeDpapiForTests(): boolean {
|
|
164
171
|
return process.platform === "win32" && loadNativeDpapi() !== null;
|
|
165
172
|
}
|
|
173
|
+
|
|
174
|
+
/** Test-only: the PowerShell helper's script, so its cmdlet-free contract can be checked anywhere. */
|
|
175
|
+
export function powerShellDpapiScriptForTests(): string {
|
|
176
|
+
return SCRIPT;
|
|
177
|
+
}
|
|
@@ -92,6 +92,13 @@ export class CodexQuotaHistory {
|
|
|
92
92
|
return { samples: structuredClone(bucket.samples.slice(-capped)), truncated: bucket.samples.length > capped };
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
/** The newest sample's origin, without pruning or clearing: a passive read changes nothing. */
|
|
96
|
+
peekLatestSource(accountId: string, identity: string | undefined): QuotaHistorySample["source"] | undefined {
|
|
97
|
+
const bucket = this.accounts.get(accountId);
|
|
98
|
+
if (!identity || !bucket || bucket.identity !== identity) return undefined;
|
|
99
|
+
return bucket.samples.at(-1)?.source;
|
|
100
|
+
}
|
|
101
|
+
|
|
95
102
|
clear(accountId?: string): number {
|
|
96
103
|
if (accountId === undefined) {
|
|
97
104
|
const count = this.accounts.size;
|
package/src/codex/quota.ts
CHANGED
|
@@ -1099,3 +1099,13 @@ export function getAccountQuotaHistory(accountId: string, limit: number = QUOTA_
|
|
|
1099
1099
|
return { observations: result.samples.map(({ credentialGeneration: _generation, ...sample }) => sample),
|
|
1100
1100
|
truncated: result.truncated, retention: { maxObservations: QUOTA_HISTORY_LIMITS.perAccount, maxAgeDays: 30 } };
|
|
1101
1101
|
}
|
|
1102
|
+
|
|
1103
|
+
/**
|
|
1104
|
+
* Where the newest stored reading for a pool account came from (a WHAM usage body or upstream
|
|
1105
|
+
* response headers), or undefined when no current history exists. Read-only: unlike
|
|
1106
|
+
* `getAccountQuotaHistory` it never prunes or clears a mismatched bucket.
|
|
1107
|
+
*/
|
|
1108
|
+
export function latestCodexQuotaObservationSource(accountId: string): "wham" | "response-header" | undefined {
|
|
1109
|
+
hydrateAccountQuotasFromDisk();
|
|
1110
|
+
return quotaHistory.peekLatestSource(accountId, poolQuotaHistoryIdentity(accountId));
|
|
1111
|
+
}
|