@bitkyc08/opencodex 2.91.0 → 2.92.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/bin/ocx.mjs +37 -3
- package/gui/dist/assets/{App-nZ34cR5G.js → App-sUf_8afJ.js} +18 -18
- package/gui/dist/assets/{Tray-BuLy7hL1.js → Tray-BSsxznDm.js} +1 -1
- package/gui/dist/assets/index-BlFE_VB_.js +94 -0
- package/gui/dist/assets/quota-summary-BRlbXgw0.js +1 -0
- package/gui/dist/index.html +1 -1
- package/gui/dist/provider-icons/workbuddy-color.svg +29 -0
- package/package.json +4 -3
- package/src/adapters/google-vertex-endpoint.ts +9 -1
- package/src/adapters/google-vertex-key-project.ts +223 -0
- package/src/adapters/google.ts +13 -3
- package/src/adapters/openai-chat/tool-name-registry.ts +3 -1
- package/src/adapters/openai-chat.ts +2 -2
- package/src/adapters/openai-responses/request-strips.ts +60 -0
- package/src/adapters/registry.ts +5 -0
- package/src/adapters/workbuddy-schema.ts +61 -0
- package/src/adapters/workbuddy.ts +68 -0
- package/src/bridge/response-json.ts +1 -1
- package/src/bridge/sse.ts +1 -1
- package/src/chat/outbound.ts +11 -4
- package/src/cli/capabilities-agents-routing.ts +3 -3
- package/src/cli/codex-cli-update.ts +8 -0
- package/src/cli/combo-result.ts +6 -0
- package/src/cli/combo-stats.ts +3 -0
- package/src/cli/combo.ts +66 -42
- package/src/cli/launcher-context.ts +49 -2
- package/src/cli/link.ts +45 -32
- package/src/cli/provider.ts +2 -1
- package/src/cli/usage-compare.ts +68 -12
- package/src/client/link-ingress.ts +1 -1
- package/src/client/link-readiness.ts +97 -0
- package/src/client/link-relay-transport.ts +79 -11
- package/src/client/link-relay.ts +74 -15
- package/src/client/link-tunnel.ts +45 -18
- package/src/client/machine-listener.ts +20 -2
- package/src/client/runtime.ts +20 -2
- package/src/codex/account-store.ts +17 -3
- package/src/codex/app-server-restart-service.ts +6 -5
- package/src/codex/catalog/effort.ts +9 -2
- package/src/codex/catalog/gather-capture.ts +4 -2
- package/src/codex/catalog/provider-models.ts +14 -8
- package/src/codex/chatgpt-refresh-failure.ts +6 -2
- package/src/codex/cli-install-provenance.ts +418 -0
- package/src/codex/cli-installation-targets.ts +5 -1
- package/src/codex/cli-update-plan.ts +43 -16
- package/src/codex/desktop-app/picker-freshness.ts +10 -8
- package/src/codex/desktop-compatibility/usage-activation.ts +2 -1
- package/src/codex/desktop-compatibility/usage-controller.ts +7 -1
- package/src/codex/pool-refresh-backoff.ts +3 -1
- package/src/codex/user-identity.ts +35 -6
- package/src/codex/windows-current-sid.ts +162 -0
- package/src/combos/jev-quota-activation.ts +5 -1
- package/src/combos/jev-quota-route.ts +1 -1
- package/src/combos/jev-quota.ts +9 -1
- package/src/combos/jev-session-pin-config.ts +51 -0
- package/src/combos/jev.ts +6 -0
- package/src/combos/types.ts +19 -0
- package/src/config/provider-validation.ts +7 -0
- package/src/config/schema/leaf-validators.ts +1 -0
- package/src/generated/compatibility-version.json +194 -122
- package/src/lib/decision-quota-sink.ts +10 -0
- package/src/lib/translator-budget.ts +4 -4
- package/src/lib/upstream-retry.ts +4 -1
- package/src/link/diagnostics.ts +193 -0
- package/src/link/relay-auth.ts +6 -0
- package/src/link/status-projection.ts +39 -0
- package/src/link/supervisor.ts +45 -12
- package/src/link/tunnel-history.ts +109 -0
- package/src/link/tunnel-state.ts +80 -17
- package/src/messaging/remote-files.ts +24 -1
- package/src/oauth/generic-account-failover.ts +91 -7
- package/src/oauth/index.ts +15 -0
- package/src/oauth/model-discovery-auth.ts +5 -0
- package/src/oauth/store.ts +4 -1
- package/src/oauth/token-guardian.ts +7 -0
- package/src/oauth/types.ts +9 -0
- package/src/oauth/workbuddy.ts +139 -0
- package/src/providers/antigravity-quota-identity.ts +37 -0
- package/src/providers/base-url-choices.ts +8 -5
- package/src/providers/derive.ts +23 -3
- package/src/providers/model-discovery.ts +2 -0
- package/src/providers/model-rename-fields.ts +1 -0
- package/src/providers/quota/account-cache.ts +3 -2
- package/src/providers/quota/antigravity.ts +25 -14
- package/src/providers/quota/report-cache.ts +13 -4
- package/src/providers/quota/vendor-probes-key.ts +60 -0
- package/src/providers/quota/workbuddy.ts +83 -0
- package/src/providers/quota-decision-antigravity.ts +64 -0
- package/src/providers/quota-decision-publication.ts +11 -0
- package/src/providers/quota-decision-snapshot.ts +21 -4
- package/src/providers/quota-types.ts +2 -0
- package/src/providers/quota.ts +3 -1
- package/src/providers/registry/entries-core.ts +9 -0
- package/src/providers/registry/entries-extended.ts +20 -7
- package/src/providers/registry/model-ids.ts +3 -0
- package/src/providers/registry/model-seeds.ts +27 -28
- package/src/providers/registry/types.ts +19 -1
- package/src/providers/resolved-model-policy.ts +4 -4
- package/src/providers/routed-config.ts +15 -0
- package/src/providers/stale-model-roster-migration.ts +83 -15
- package/src/providers/workbuddy-credit-balance.ts +81 -0
- package/src/providers/workbuddy-models.ts +37 -0
- package/src/providers/workbuddy.ts +60 -0
- package/src/responses/reasoning-replay-cache.ts +18 -14
- package/src/router.ts +2 -0
- package/src/routing/analytics.ts +2 -3
- package/src/routing/fallback-policy.ts +3 -3
- package/src/server/auth-cors.ts +1 -0
- package/src/server/chat-completions.ts +14 -1
- package/src/server/chat-native-reasoning.ts +10 -3
- package/src/server/chat-native.ts +13 -2
- package/src/server/claude-messages.ts +3 -0
- package/src/server/index/link-relay-sessions.ts +9 -5
- package/src/server/index/optional-listeners.ts +4 -0
- package/src/server/local-management-read-client.ts +3 -1
- package/src/server/management/combo-routes.ts +10 -4
- package/src/server/management/link-routes.ts +5 -2
- package/src/server/management/provider-routes.ts +1 -1
- package/src/server/port-reclaim.ts +51 -9
- package/src/server/request-log-conversation.ts +49 -0
- package/src/server/request-log.ts +2 -0
- package/src/server/responses/anthropic-thinking-recovery.ts +1 -0
- package/src/server/responses/compact.ts +10 -2
- package/src/server/responses/core-combo.ts +53 -31
- package/src/server/responses/core-lifetime.ts +2 -2
- package/src/server/responses/core-normalize.ts +6 -3
- package/src/server/responses/core-opaque-recovery.ts +1 -0
- package/src/server/responses/core-options.ts +5 -0
- package/src/server/responses/core-replay.ts +2 -1
- package/src/server/responses/jev-combo-choice.ts +39 -0
- package/src/server/responses/jev-session-pin.ts +203 -0
- package/src/server/responses/passthrough-delivery.ts +4 -0
- package/src/server/responses/request-prepare.ts +10 -5
- package/src/server/responses/request-transport.ts +25 -6
- package/src/server/responses/run-turn-execution.ts +2 -4
- package/src/server/responses/sidecar-execution.ts +2 -4
- package/src/server/responses-phase-inference.ts +267 -0
- package/src/server/sse-payload-rewrite.ts +14 -1
- package/src/service/definition.ts +20 -8
- package/src/types/config.ts +6 -0
- package/src/types/provider.ts +9 -1
- package/src/types/request.ts +1 -0
- package/src/types/wire.ts +1 -0
- package/src/update/codex-cli-update-launch-policy.d.mts +9 -0
- package/src/update/codex-cli-update-launch-policy.mjs +30 -0
- package/src/usage/jev-stats.ts +54 -14
- package/gui/dist/assets/index-DGXS51sp.js +0 -94
- package/gui/dist/assets/quota-summary-DO1Sx5TM.js +0 -1
package/src/client/link-relay.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { fetchBoundLinkRelay, LinkRelayAuthenticationError, LinkRelayAuthTimeoutError } from "./link-relay-transport";
|
|
2
3
|
import { homeUnreachableBody } from "./home-unreachable";
|
|
3
4
|
import {
|
|
4
5
|
boundedRelayResponseStream,
|
|
@@ -6,6 +7,7 @@ import {
|
|
|
6
7
|
headersWithinLimit,
|
|
7
8
|
validateHubRelayRequestHeaders,
|
|
8
9
|
} from "./hub-relay";
|
|
10
|
+
import type { LinkDiagnosticRecord, LinkDiagnosticSink } from "../link/diagnostics";
|
|
9
11
|
import { linkRouteAllowed } from "../link/routes";
|
|
10
12
|
import { isLinkPort } from "../link/ports";
|
|
11
13
|
import { resolveInboundBodyLimitBytes } from "../server/request-decompress";
|
|
@@ -26,14 +28,23 @@ export interface LinkRelayClock {
|
|
|
26
28
|
clearTimeout: typeof clearTimeout;
|
|
27
29
|
}
|
|
28
30
|
|
|
31
|
+
/** A fresh admission verdict plus optional diagnostic evidence; metadata never grants admission. */
|
|
32
|
+
export interface LinkAdmissionResult {
|
|
33
|
+
kind: "match" | "mismatch" | "lookup_error" | "deadline" | "stale" | "identity_unknown" | "identity_mismatch" | "unavailable";
|
|
34
|
+
elapsedMs?: number;
|
|
35
|
+
generation?: number;
|
|
36
|
+
state?: string;
|
|
37
|
+
checkId?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
29
40
|
/**
|
|
30
41
|
* The Child's tunnel as the relay sees it (the client link supervisor implements it). A request
|
|
31
42
|
* waits on it only while the tunnel is being (re)established. Every relayed fetch waits for a
|
|
32
43
|
* fresh bounded asynchronous ownership proof, including after a reconnect or refused retry.
|
|
33
44
|
*/
|
|
34
45
|
export interface LinkTunnelGate {
|
|
35
|
-
/**
|
|
36
|
-
connected():
|
|
46
|
+
/** Admission and its evidence are returned together; only match permits a fetch. */
|
|
47
|
+
connected(context?: { requestId?: string }): LinkAdmissionResult | Promise<LinkAdmissionResult>;
|
|
37
48
|
/** True while the tunnel is connecting or reconnecting. */
|
|
38
49
|
pending(): boolean;
|
|
39
50
|
/**
|
|
@@ -51,12 +62,13 @@ export interface LinkTunnelGate {
|
|
|
51
62
|
* supervisor, and a relay with no gate at all still refuses every request.
|
|
52
63
|
*/
|
|
53
64
|
export const HOME_INITIATED_LINK_TUNNEL: LinkTunnelGate = {
|
|
54
|
-
connected: () =>
|
|
65
|
+
connected: () => ({ kind: "match" }),
|
|
55
66
|
pending: () => false,
|
|
56
67
|
waitForConnected: async () => false,
|
|
57
68
|
};
|
|
58
69
|
|
|
59
70
|
export interface LinkRelayDeps {
|
|
71
|
+
diagnostic?: LinkDiagnosticSink;
|
|
60
72
|
/** Trusted complete-send test seam. Production always uses the connection-bound transport. */
|
|
61
73
|
fetchImpl?: typeof fetch;
|
|
62
74
|
clock?: LinkRelayClock;
|
|
@@ -136,6 +148,7 @@ function relayAuthenticationFailed(error: string): Response {
|
|
|
136
148
|
});
|
|
137
149
|
}
|
|
138
150
|
|
|
151
|
+
/** Preserve path/query on a fixed IPv4 loopback origin; throw when the tunnel port is invalid. */
|
|
139
152
|
export function linkRelayDestination(url: URL, target: Pick<LinkRelayTarget, "tunnelPort">): string {
|
|
140
153
|
if (!isLinkPort(target.tunnelPort)) {
|
|
141
154
|
throw new RangeError("invalid link tunnel port");
|
|
@@ -180,6 +193,7 @@ function linkRequestHeaders(
|
|
|
180
193
|
return headers;
|
|
181
194
|
}
|
|
182
195
|
|
|
196
|
+
/** Drop hop-by-hop and stale length/encoding headers before exposing the decoded response. */
|
|
183
197
|
export function sanitizeLinkResponseHeaders(source: Headers): Headers {
|
|
184
198
|
const connectionNamed = new Set((source.get("connection") ?? "")
|
|
185
199
|
.split(",").map(value => value.trim().toLowerCase()).filter(Boolean));
|
|
@@ -328,6 +342,11 @@ function idleBoundedStream(
|
|
|
328
342
|
});
|
|
329
343
|
}
|
|
330
344
|
|
|
345
|
+
/**
|
|
346
|
+
* Admit an allowlisted route and stream it through the authenticated tunnel with bounded bodies.
|
|
347
|
+
* Owner/auth preflight deadlines share one recovery attempt before data dispatch; an ambiguous
|
|
348
|
+
* post-dispatch failure never earns that recovery. Diagnostic failures cannot change admission.
|
|
349
|
+
*/
|
|
331
350
|
export async function relayLinkDataRequest(
|
|
332
351
|
req: Request,
|
|
333
352
|
target: LinkRelayTarget,
|
|
@@ -352,6 +371,16 @@ export async function relayLinkDataRequest(
|
|
|
352
371
|
return jsonError(431, "link relay request headers too large");
|
|
353
372
|
}
|
|
354
373
|
|
|
374
|
+
const requestId = randomUUID();
|
|
375
|
+
let admission: LinkAdmissionResult = { kind: "unavailable" };
|
|
376
|
+
let recoveryAttempts = 0;
|
|
377
|
+
const diagnostic = (event: LinkDiagnosticRecord["event"], outcome: LinkDiagnosticRecord["outcome"], elapsedMs?: number) => {
|
|
378
|
+
try { deps.diagnostic?.({ event, outcome, requestId,
|
|
379
|
+
checkId: admission.checkId, generation: admission.generation,
|
|
380
|
+
state: admission.state, elapsedMs: admission.elapsedMs,
|
|
381
|
+
...(elapsedMs !== undefined ? { elapsedMs } : {}), attempt: recoveryAttempts }); }
|
|
382
|
+
catch { /* diagnostics never alter admission */ }
|
|
383
|
+
};
|
|
355
384
|
const relayAbort = new AbortController();
|
|
356
385
|
const clock = deps.clock ?? defaultClock;
|
|
357
386
|
let headerTimer: ReturnType<typeof setTimeout> | undefined = clock.setTimeout(() => {
|
|
@@ -390,20 +419,45 @@ export async function relayLinkDataRequest(
|
|
|
390
419
|
const remaining = holdUntil - now;
|
|
391
420
|
return remaining > 0 && await gate.waitForConnected(remaining, relayAbort.signal);
|
|
392
421
|
};
|
|
393
|
-
const tunnelUnavailable = async (
|
|
422
|
+
const tunnelUnavailable = async (
|
|
423
|
+
reason: LinkDiagnosticRecord["outcome"] = admission.kind === "match" ? "unavailable" : admission.kind,
|
|
424
|
+
message = "link tunnel unavailable",
|
|
425
|
+
elapsedMs?: number,
|
|
426
|
+
): Promise<Response> => {
|
|
427
|
+
diagnostic("relay_failure", relayAbort.signal.aborted
|
|
428
|
+
? req.signal.aborted ? "client_abort" : "header_timeout" : reason, elapsedMs);
|
|
394
429
|
cleanup();
|
|
395
430
|
try { await body?.cancel(); } catch { /* best effort */ }
|
|
396
|
-
|
|
431
|
+
const response = reason === "auth_failed" ? relayAuthenticationFailed(message) : homeUnreachable(message);
|
|
432
|
+
response.headers.set("x-opencodex-link-request-id", requestId);
|
|
433
|
+
return response;
|
|
434
|
+
};
|
|
435
|
+
const retryPreflight = (reason: "deadline" | "auth_timeout", elapsedMs?: number): boolean => {
|
|
436
|
+
if (recoveryAttempts >= 1 || relayAbort.signal.aborted || bodyOverflow || bodyCancelled || body?.locked) return false;
|
|
437
|
+
recoveryAttempts += 1;
|
|
438
|
+
diagnostic("relay_retry", reason, elapsedMs);
|
|
439
|
+
return true;
|
|
440
|
+
};
|
|
441
|
+
const checkConnected = async (): Promise<boolean> => {
|
|
442
|
+
admission = tunnel ? await tunnel.connected({ requestId }) : { kind: "unavailable" };
|
|
443
|
+
return admission.kind === "match";
|
|
397
444
|
};
|
|
398
445
|
const readyForFetch = async (): Promise<boolean> => {
|
|
399
|
-
if (!tunnel) return false;
|
|
400
|
-
if (await
|
|
401
|
-
|
|
446
|
+
if (!tunnel || relayAbort.signal.aborted) return false;
|
|
447
|
+
if (await checkConnected()) return !relayAbort.signal.aborted;
|
|
448
|
+
// No fetch or body read has occurred. A deadline proves neither a foreign owner nor a
|
|
449
|
+
// broken SSH process: give this request one fresh proof, without caching or restarting it.
|
|
450
|
+
if (admission.kind === "deadline" && retryPreflight("deadline")) {
|
|
451
|
+
if (await checkConnected()) return !relayAbort.signal.aborted;
|
|
452
|
+
}
|
|
453
|
+
return !relayAbort.signal.aborted && tunnel.pending() && await holdForTunnel(tunnel)
|
|
454
|
+
&& await checkConnected() && !relayAbort.signal.aborted;
|
|
402
455
|
};
|
|
403
456
|
|
|
404
457
|
let upstream: Response | undefined;
|
|
405
458
|
while (!upstream) {
|
|
406
459
|
if (!await readyForFetch()) return await tunnelUnavailable();
|
|
460
|
+
const fetchStartedAt = performance.now();
|
|
407
461
|
try {
|
|
408
462
|
const init: RequestInit & { duplex?: "half" } = {
|
|
409
463
|
method: req.method,
|
|
@@ -415,24 +469,28 @@ export async function relayLinkDataRequest(
|
|
|
415
469
|
upstream = await (deps.fetchImpl ? deps.fetchImpl(destination, init) : fetchBoundLinkRelay(target, destination, init));
|
|
416
470
|
} catch (error) {
|
|
417
471
|
if (error instanceof LinkRelayAuthenticationError) {
|
|
418
|
-
|
|
419
|
-
try { await body?.cancel(); } catch { /* an upload might already be drained */ }
|
|
420
|
-
return relayAuthenticationFailed(error.message);
|
|
472
|
+
return await tunnelUnavailable("auth_failed", error.message);
|
|
421
473
|
}
|
|
422
474
|
if (bodyOverflow) {
|
|
423
475
|
cleanup();
|
|
424
476
|
return jsonError(413, "link relay request body too large");
|
|
425
477
|
}
|
|
478
|
+
if (error instanceof LinkRelayAuthTimeoutError) {
|
|
479
|
+
// The transport certifies that only its credential-free challenge was sent. A new
|
|
480
|
+
// iteration obtains a fresh ownership proof and a new authenticated physical socket.
|
|
481
|
+
const elapsedMs = performance.now() - fetchStartedAt;
|
|
482
|
+
if (retryPreflight("auth_timeout", elapsedMs)) continue;
|
|
483
|
+
return await tunnelUnavailable("auth_timeout", "link tunnel unavailable", elapsedMs);
|
|
484
|
+
}
|
|
426
485
|
// A refused connection sent nothing, and an untouched body can go again once the tunnel
|
|
427
486
|
// is back inside the same hold window. Anything else is never replayed.
|
|
428
487
|
const resendable = connectionRefused(error) && (body === null || (!body.locked && !bodyCancelled));
|
|
429
|
-
if (!tunnel || !resendable || !tunnel.pending() || !await holdForTunnel(tunnel)) return await tunnelUnavailable();
|
|
488
|
+
if (!tunnel || !resendable || !tunnel.pending() || !await holdForTunnel(tunnel)) return await tunnelUnavailable("transport_error");
|
|
430
489
|
}
|
|
431
490
|
}
|
|
432
491
|
if (relayAbort.signal.aborted) {
|
|
433
|
-
cleanup();
|
|
434
492
|
try { await upstream.body?.cancel(); } catch { /* best effort */ }
|
|
435
|
-
return
|
|
493
|
+
return await tunnelUnavailable();
|
|
436
494
|
}
|
|
437
495
|
|
|
438
496
|
const sse = isSse(upstream.headers);
|
|
@@ -449,6 +507,7 @@ export async function relayLinkDataRequest(
|
|
|
449
507
|
try { await upstream.body?.cancel(); } catch { /* best effort */ }
|
|
450
508
|
return jsonError(502, "link relay response body too large");
|
|
451
509
|
}
|
|
510
|
+
if (recoveryAttempts > 0 && upstream.ok) diagnostic("relay_recovered", "recovered");
|
|
452
511
|
if (req.method === "HEAD" || !upstream.body) {
|
|
453
512
|
cleanup();
|
|
454
513
|
try { await upstream.body?.cancel(); } catch { /* best effort */ }
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import { chmodSync, mkdirSync, readFileSync, statSync, unlinkSync } from "node:fs";
|
|
2
3
|
import { execFile } from "node:child_process";
|
|
3
4
|
import { readFile as readFileAsync } from "node:fs/promises";
|
|
@@ -13,17 +14,18 @@ import {
|
|
|
13
14
|
CLIENT_TUNNEL_RETRY_POLICY,
|
|
14
15
|
classifySshStderr,
|
|
15
16
|
dueForSpawn,
|
|
16
|
-
|
|
17
|
+
failedSpawnTunnel,
|
|
17
18
|
IDLE,
|
|
18
19
|
reduceTunnel,
|
|
19
20
|
type StderrClass,
|
|
20
21
|
type TunnelState,
|
|
21
22
|
} from "../link/tunnel-state";
|
|
22
23
|
import { isLinkPort } from "../link/ports";
|
|
23
|
-
import {
|
|
24
|
+
import { inspectIpv4LoopbackListener, type LoopbackOwnerLookupResult } from "../server/port-reclaim";
|
|
25
|
+
import type { LinkDiagnosticSink } from "../link/diagnostics";
|
|
24
26
|
import { isLinkConnection, readClientConnectionState } from "./state";
|
|
25
27
|
import { clientLinkStatePath, readClientLinkState, type ClientLinkState } from "./link-state";
|
|
26
|
-
import type { LinkTunnelGate } from "./link-relay";
|
|
28
|
+
import type { LinkTunnelGate, LinkAdmissionResult } from "./link-relay";
|
|
27
29
|
|
|
28
30
|
/**
|
|
29
31
|
* The client-owned `ssh -N -L 127.0.0.1:<tunnelPort>:127.0.0.1:<peerListenerPort> <alias>`
|
|
@@ -134,7 +136,8 @@ export interface ClientLinkSupervisorDeps extends ClientLinkTunnelDeps, OrphanRe
|
|
|
134
136
|
/** The link key for the keyed readiness probe. The runtime passes its cached key source. */
|
|
135
137
|
linkKey?: () => string | null;
|
|
136
138
|
/** Async local IPv4 LISTEN ownership; an unknown or foreign owner never receives the link key. */
|
|
137
|
-
|
|
139
|
+
inspectLoopbackListener?: (port: number, pid: number) => Promise<LoopbackOwnerLookupResult>;
|
|
140
|
+
diagnostic?: LinkDiagnosticSink;
|
|
138
141
|
fetchImpl?: typeof fetch;
|
|
139
142
|
now?: () => number;
|
|
140
143
|
random?: () => number;
|
|
@@ -563,6 +566,10 @@ async function probeTunnel(fetchImpl: typeof fetch, tunnelPort: number, key: str
|
|
|
563
566
|
}
|
|
564
567
|
}
|
|
565
568
|
|
|
569
|
+
/**
|
|
570
|
+
* Own the Child's SSH lifecycle and reconnect holds. Each connected() call checks fresh listener
|
|
571
|
+
* ownership and fences results against tunnel replacement or stop; diagnostics cannot admit it.
|
|
572
|
+
*/
|
|
566
573
|
export function createClientLinkSupervisor(deps: ClientLinkSupervisorDeps = {}): ClientLinkSupervisor {
|
|
567
574
|
const sidecarPath = (): string => clientLinkStatePath(deps.configDir);
|
|
568
575
|
const readSidecar = deps.readSidecar ?? readWhenFileChanges(sidecarPath, () => readClientLinkState(sidecarPath()));
|
|
@@ -573,7 +580,7 @@ export function createClientLinkSupervisor(deps: ClientLinkSupervisorDeps = {}):
|
|
|
573
580
|
const policy = CLIENT_TUNNEL_RETRY_POLICY;
|
|
574
581
|
const isAlive = deps.isAlive ?? defaultIsAlive;
|
|
575
582
|
const linkKey = deps.linkKey ?? (() => null);
|
|
576
|
-
const ownerLookup = deps.
|
|
583
|
+
const ownerLookup = deps.inspectLoopbackListener ?? inspectIpv4LoopbackListener;
|
|
577
584
|
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
578
585
|
const setSupervisorTimer = deps.setTimer ?? ((callback: () => void, ms: number) => {
|
|
579
586
|
const interval = setInterval(callback, ms);
|
|
@@ -620,9 +627,16 @@ export function createClientLinkSupervisor(deps: ClientLinkSupervisorDeps = {}):
|
|
|
620
627
|
if (pid === null || pid === undefined || (adopted !== null && !adoptedRecord)) return null;
|
|
621
628
|
return { pid, generation: ownerGeneration };
|
|
622
629
|
};
|
|
623
|
-
const listenerOwnedByTunnel = async (port: number): Promise<
|
|
630
|
+
const listenerOwnedByTunnel = async (port: number, context?: { requestId?: string }): Promise<LinkAdmissionResult> => {
|
|
624
631
|
const candidate = ownerCandidate(port);
|
|
625
|
-
if (!candidate) return
|
|
632
|
+
if (!candidate) return { kind: "unavailable" };
|
|
633
|
+
const checkId = randomUUID();
|
|
634
|
+
const report = (kind: LinkAdmissionResult["kind"], elapsedMs = 0): LinkAdmissionResult => {
|
|
635
|
+
const check = { kind, elapsedMs, checkId, generation: candidate.generation, state: state.kind };
|
|
636
|
+
if (kind !== "match") try { deps.diagnostic?.({ event: "owner_lookup", outcome: kind, requestId: context?.requestId,
|
|
637
|
+
checkId, generation: candidate.generation, state: state.kind, elapsedMs }); } catch { /* best effort */ }
|
|
638
|
+
return check;
|
|
639
|
+
};
|
|
626
640
|
if (adopted !== null) {
|
|
627
641
|
const record = adoptedRecord;
|
|
628
642
|
const identity = record ? await inspectAdoptedIdentity(record, deps) : "unknown";
|
|
@@ -640,16 +654,28 @@ export function createClientLinkSupervisor(deps: ClientLinkSupervisorDeps = {}):
|
|
|
640
654
|
invalidateOwnerProof();
|
|
641
655
|
setState(IDLE);
|
|
642
656
|
}
|
|
643
|
-
return
|
|
657
|
+
return report(identity === "mismatch" ? "identity_mismatch" : "identity_unknown");
|
|
644
658
|
}
|
|
645
659
|
}
|
|
646
|
-
const
|
|
647
|
-
|
|
648
|
-
|
|
660
|
+
const result = await Promise.resolve().then(() => ownerLookup(port, candidate.pid))
|
|
661
|
+
.catch((): LoopbackOwnerLookupResult => ({ kind: "lookup_error", elapsedMs: 0 }));
|
|
662
|
+
if (stopping || failure !== undefined || ownerGeneration !== candidate.generation || ownerCandidate(port)?.pid !== candidate.pid) {
|
|
663
|
+
return report("stale", result.elapsedMs);
|
|
664
|
+
}
|
|
665
|
+
return report(result.kind, result.elapsedMs);
|
|
666
|
+
};
|
|
667
|
+
const connected = async (context?: { requestId?: string }): Promise<LinkAdmissionResult> => {
|
|
668
|
+
if (!started || stopping || failure !== undefined || state.kind !== "connected" || linkId === null || tunnelPort === null) {
|
|
669
|
+
return { kind: "unavailable" };
|
|
670
|
+
}
|
|
671
|
+
const result = await listenerOwnedByTunnel(tunnelPort, context);
|
|
672
|
+
// State may change during the asynchronous lookup or an optional diagnostic callback.
|
|
673
|
+
if (result.kind === "match" && (!started || stopping || failure !== undefined
|
|
674
|
+
|| state.kind !== "connected" || result.generation !== ownerGeneration)) {
|
|
675
|
+
return { ...result, kind: "stale" };
|
|
676
|
+
}
|
|
677
|
+
return result;
|
|
649
678
|
};
|
|
650
|
-
const connected = async (): Promise<boolean> => started && !stopping && failure === undefined
|
|
651
|
-
&& state.kind === "connected" && linkId !== null && tunnelPort !== null
|
|
652
|
-
&& await listenerOwnedByTunnel(tunnelPort);
|
|
653
679
|
|
|
654
680
|
const settleWaiters = (): void => {
|
|
655
681
|
if (waiters.size === 0) return;
|
|
@@ -733,7 +759,8 @@ export function createClientLinkSupervisor(deps: ClientLinkSupervisorDeps = {}):
|
|
|
733
759
|
peerListenerPort: sidecar.peerListenerPort,
|
|
734
760
|
}, deps);
|
|
735
761
|
} catch {
|
|
736
|
-
|
|
762
|
+
// A due retry that cannot start counts as a failed retry, so the backoff keeps its step.
|
|
763
|
+
setState(failedSpawnTunnel(state, "forward", timestamp, policy));
|
|
737
764
|
deps.warn?.("client link tunnel could not be started");
|
|
738
765
|
return;
|
|
739
766
|
}
|
|
@@ -800,7 +827,7 @@ export function createClientLinkSupervisor(deps: ClientLinkSupervisorDeps = {}):
|
|
|
800
827
|
probeFlight = flight;
|
|
801
828
|
const wasConnected = state.kind === "connected";
|
|
802
829
|
void (async () => {
|
|
803
|
-
if (
|
|
830
|
+
if ((await listenerOwnedByTunnel(tunnelPort)).kind !== "match") return;
|
|
804
831
|
if (probeFlight !== flight || stopping || flight.abort.signal.aborted
|
|
805
832
|
|| child !== flight.child || adopted !== flight.adopted) return;
|
|
806
833
|
const key = linkKey();
|
|
@@ -808,7 +835,7 @@ export function createClientLinkSupervisor(deps: ClientLinkSupervisorDeps = {}):
|
|
|
808
835
|
const result = await probeTunnel(fetchImpl, tunnelPort, key, flight.abort.signal);
|
|
809
836
|
// An aborted probe, or one whose listener changed while it ran, proves nothing.
|
|
810
837
|
if (probeFlight !== flight || stopping || child !== flight.child || adopted !== flight.adopted
|
|
811
|
-
||
|
|
838
|
+
|| (await listenerOwnedByTunnel(tunnelPort)).kind !== "match") return;
|
|
812
839
|
applyProbe(result, wasConnected);
|
|
813
840
|
})().catch(() => undefined).finally(() => {
|
|
814
841
|
if (probeFlight === flight) probeFlight = undefined;
|
|
@@ -971,7 +998,7 @@ export function createClientLinkSupervisor(deps: ClientLinkSupervisorDeps = {}):
|
|
|
971
998
|
pending,
|
|
972
999
|
connected,
|
|
973
1000
|
waitForConnected(timeoutMs: number, signal?: AbortSignal): Promise<boolean> {
|
|
974
|
-
if (state.kind === "connected") return connected();
|
|
1001
|
+
if (state.kind === "connected") return connected().then(result => result.kind === "match");
|
|
975
1002
|
if (!pending() || waiters.size >= CLIENT_LINK_MAX_HOLDS || signal?.aborted || !(timeoutMs > 0)) {
|
|
976
1003
|
return Promise.resolve(false);
|
|
977
1004
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { recordLinkDiagnostic } from "../link/diagnostics";
|
|
1
2
|
import type { Server } from "bun";
|
|
2
3
|
import { loadConfig } from "../config";
|
|
3
4
|
import { browserSecurityHeaders, requestPolicyView } from "../server/auth-cors";
|
|
@@ -21,6 +22,7 @@ import type { LinkTunnelGate } from "./link-relay";
|
|
|
21
22
|
import { readClientLinkState, type ClientLinkState } from "./link-state";
|
|
22
23
|
import { projectClientLinkChild, type ClientLinkSidecarRead } from "./link-status";
|
|
23
24
|
import type { ClientLinkSupervisorStatus } from "./link-tunnel";
|
|
25
|
+
import type { LinkReadiness } from "./link-readiness";
|
|
24
26
|
import { packageVersion } from "../lib/package-version";
|
|
25
27
|
import { createLocalAttestationProof, LOCAL_ATTESTATION_CHALLENGE_HEADER, LOCAL_ATTESTATION_PROOF_HEADER } from "../lib/local-management-attestation";
|
|
26
28
|
import {
|
|
@@ -65,6 +67,11 @@ export interface MachineListenerDeps {
|
|
|
65
67
|
linkTunnel?: LinkTunnelGate;
|
|
66
68
|
/** Link mode: relay seams (deadline clock and byte cap). */
|
|
67
69
|
linkRelay?: LinkIngress["relay"];
|
|
70
|
+
/**
|
|
71
|
+
* Link mode: whether relayed requests can be served now. `GET /readyz` answers 503 `pending`
|
|
72
|
+
* with `link.reason` while it is not; `/healthz` is unaffected (`link-readiness.ts`).
|
|
73
|
+
*/
|
|
74
|
+
linkReadiness?: () => LinkReadiness | Promise<LinkReadiness>;
|
|
68
75
|
/**
|
|
69
76
|
* The bound restart (OCX-6). With it, `/healthz` proves this process's identity to a challenge,
|
|
70
77
|
* advertises `restartCapability` and `startedAt`, and `POST /api/system/restart` accepts a
|
|
@@ -152,7 +159,7 @@ export function startMachineListener(
|
|
|
152
159
|
apiKeyId: connection.apiKeyId,
|
|
153
160
|
policy: requestPolicyView(config, "127.0.0.1"),
|
|
154
161
|
linkKey: deps.linkKeySource ?? createLinkKeySource(connection.tokenFingerprint, deps.linkKey),
|
|
155
|
-
relay: { fetchImpl: deps.fetchImpl, bodyLimitBytes: inboundBodyLimit, tunnel: deps.linkTunnel, ...deps.linkRelay },
|
|
162
|
+
relay: { diagnostic: recordLinkDiagnostic, fetchImpl: deps.fetchImpl, bodyLimitBytes: inboundBodyLimit, tunnel: deps.linkTunnel, ...deps.linkRelay },
|
|
156
163
|
}
|
|
157
164
|
: null;
|
|
158
165
|
return (deps.serve ?? (options => Bun.serve(options)))({
|
|
@@ -186,7 +193,18 @@ export function startMachineListener(
|
|
|
186
193
|
return await handleBoundRestart(req, server, deps.restart);
|
|
187
194
|
}
|
|
188
195
|
if (url.pathname === "/readyz" && req.method === "GET") {
|
|
189
|
-
|
|
196
|
+
const identity = { service: "opencodex", version: VERSION, role: "client" } as const;
|
|
197
|
+
const facts = { uptime: process.uptime(), pid: process.pid, port: server.port, protocolVersion: 1 };
|
|
198
|
+
let link: LinkReadiness | null = null;
|
|
199
|
+
if (linkMode && deps.linkReadiness) {
|
|
200
|
+
try { link = await deps.linkReadiness(); } catch { link = { ready: false, reason: "link_unverified" }; }
|
|
201
|
+
}
|
|
202
|
+
if (link && !link.ready) {
|
|
203
|
+
// The /readyz contract: not ready is 503 with Retry-After and status pending or failed.
|
|
204
|
+
return Response.json({ ...identity, status: "pending", ...facts, link: { state: "unavailable", reason: link.reason } },
|
|
205
|
+
{ status: 503, headers: { "Retry-After": "1", "Cache-Control": "no-store" } });
|
|
206
|
+
}
|
|
207
|
+
return Response.json({ ...identity, status: "ready", ...facts, ...(link ? { link: { state: "available" } } : {}) });
|
|
190
208
|
}
|
|
191
209
|
if (url.pathname.startsWith("/api/machine/hub-relay/")) {
|
|
192
210
|
if (!relayEnabled) return json404(req);
|
package/src/client/runtime.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { flushLinkDiagnostics, recordLinkDiagnostic } from "../link/diagnostics";
|
|
1
2
|
import { existsSync } from "node:fs";
|
|
2
3
|
import type { Server } from "bun";
|
|
3
4
|
import { siblingRuntimeField, withSiblingMarker } from "../codex/sibling-start";
|
|
@@ -20,6 +21,7 @@ import type { ReplacementStartRequest } from "../server/restart-replacement";
|
|
|
20
21
|
import type { OcxClientConnectionConfig } from "../types";
|
|
21
22
|
import { createLinkKeySource } from "./link-ingress";
|
|
22
23
|
import { HOME_INITIATED_LINK_TUNNEL } from "./link-relay";
|
|
24
|
+
import { clientInitiatedLinkReadiness, createHomeInitiatedLinkReadiness } from "./link-readiness";
|
|
23
25
|
import { createClientLinkSupervisor, type ClientLinkSupervisor } from "./link-tunnel";
|
|
24
26
|
import { clientLinkStatePath, isChildInitiatedLink, recordChildInitiatedLink } from "./link-state";
|
|
25
27
|
import { startMachineListener, type MachineListenerDeps } from "./machine-listener";
|
|
@@ -164,6 +166,7 @@ export async function recycleStandalone(
|
|
|
164
166
|
console.warn(`[client] listener stop failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
165
167
|
}
|
|
166
168
|
cleanup();
|
|
169
|
+
await flushLinkDiagnostics();
|
|
167
170
|
// Recycling back to standalone after `ocx disconnect` must actually bring a standalone
|
|
168
171
|
// proxy back, under every launch shape.
|
|
169
172
|
//
|
|
@@ -227,7 +230,7 @@ export async function bindClientListener(
|
|
|
227
230
|
preferred: number;
|
|
228
231
|
explicitPort: boolean;
|
|
229
232
|
configuredPort: number;
|
|
230
|
-
} & Pick<MachineListenerDeps, "linkStatus" | "linkKeySource" | "linkTunnel" | "restart">,
|
|
233
|
+
} & Pick<MachineListenerDeps, "linkStatus" | "linkKeySource" | "linkTunnel" | "linkReadiness" | "restart">,
|
|
231
234
|
io: ClientRuntimeIo = {},
|
|
232
235
|
): Promise<{ server: Server<unknown>; port: number }> {
|
|
233
236
|
const { linkMode } = request;
|
|
@@ -267,6 +270,7 @@ export async function bindClientListener(
|
|
|
267
270
|
...(request.linkStatus ? { linkStatus: request.linkStatus } : {}),
|
|
268
271
|
...(request.linkKeySource ? { linkKeySource: request.linkKeySource } : {}),
|
|
269
272
|
...(request.linkTunnel ? { linkTunnel: request.linkTunnel } : {}),
|
|
273
|
+
...(request.linkReadiness ? { linkReadiness: request.linkReadiness } : {}),
|
|
270
274
|
...(request.restart ? { restart: request.restart } : {}),
|
|
271
275
|
});
|
|
272
276
|
return { server, port: server.port ?? port };
|
|
@@ -318,10 +322,19 @@ export async function startClientRuntime(
|
|
|
318
322
|
const serve = async (rollback?: { port: number; portWaitMs: number }): Promise<boolean> => {
|
|
319
323
|
const supervisor = supervised
|
|
320
324
|
? createClientLinkSupervisor({
|
|
325
|
+
diagnostic: recordLinkDiagnostic,
|
|
321
326
|
onLinkEnded: () => scheduleStandaloneRecycle(state.value.tokenFingerprint),
|
|
322
327
|
linkKey,
|
|
323
328
|
})
|
|
324
329
|
: null;
|
|
330
|
+
// What `/readyz` reports about the link: the supervisor's tunnel, or for a Home-initiated
|
|
331
|
+
// link the shared relay preflight. A link with neither refuses every relayed request.
|
|
332
|
+
const link = state.value.link;
|
|
333
|
+
const linkReadiness: MachineListenerDeps["linkReadiness"] = !linkMode ? undefined
|
|
334
|
+
: supervisor ? () => clientInitiatedLinkReadiness(supervisor.status())
|
|
335
|
+
: homeInitiated && link && linkKey
|
|
336
|
+
? createHomeInitiatedLinkReadiness({ target: { tunnelPort: link.tunnelPort, linkId: link.linkId, apiKeyId: state.value.apiKeyId }, linkKey })
|
|
337
|
+
: () => ({ ready: false, reason: "tunnel_unavailable" });
|
|
325
338
|
const { server, port: boundPort } = await bindClientListener({
|
|
326
339
|
state: state.value,
|
|
327
340
|
linkMode,
|
|
@@ -332,6 +345,7 @@ export async function startClientRuntime(
|
|
|
332
345
|
restart: { attestationSecret, startedAt: CLIENT_RUNTIME_STARTED_AT, request: () => restartController.request() },
|
|
333
346
|
...(linkMode ? { linkStatus: () => activeSupervisor?.status() ?? { kind: "stopped" as const }, linkKeySource: linkKey } : {}),
|
|
334
347
|
...(supervisor ? { linkTunnel: supervisor } : homeInitiated ? { linkTunnel: HOME_INITIATED_LINK_TUNNEL } : {}),
|
|
348
|
+
...(linkReadiness ? { linkReadiness } : {}),
|
|
335
349
|
}, rollback === undefined ? io : { ...io, portWaitMs: rollback.portWaitMs });
|
|
336
350
|
const publish = (): void => {
|
|
337
351
|
activeServer = server;
|
|
@@ -417,7 +431,10 @@ export async function startClientRuntime(
|
|
|
417
431
|
await recycleStandalone(state.value.tokenFingerprint);
|
|
418
432
|
}
|
|
419
433
|
},
|
|
420
|
-
exitProcess: code => {
|
|
434
|
+
exitProcess: async code => {
|
|
435
|
+
await flushLinkDiagnostics();
|
|
436
|
+
process.exit(code);
|
|
437
|
+
},
|
|
421
438
|
});
|
|
422
439
|
|
|
423
440
|
installCrashGuards();
|
|
@@ -444,6 +461,7 @@ export async function startClientRuntime(
|
|
|
444
461
|
console.warn(`[client] listener stop failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
445
462
|
} finally {
|
|
446
463
|
cleanup();
|
|
464
|
+
await flushLinkDiagnostics();
|
|
447
465
|
// launchd relaunches only unsuccessful exits; an external signal must still count.
|
|
448
466
|
process.exit(handledSignalExitCode(signal));
|
|
449
467
|
}
|
|
@@ -12,13 +12,15 @@ import {
|
|
|
12
12
|
backupInvalidConfig,
|
|
13
13
|
hardenConfigDir,
|
|
14
14
|
hardenExistingSecret,
|
|
15
|
+
loadConfig,
|
|
15
16
|
withConfigMutationLockSync,
|
|
16
17
|
} from "../config";
|
|
17
18
|
import { notifyCodexStoreLoaded } from "../lib/decision-quota-sink";
|
|
18
19
|
import { assertNotRealHomeUnderTest } from "../lib/test-home-guard";
|
|
19
|
-
import type { CodexAccountCredentialRecord, CodexAccountCredentials } from "../types";
|
|
20
|
+
import type { CodexAccount, CodexAccountCredentialRecord, CodexAccountCredentials } from "../types";
|
|
20
21
|
import { advanceCodexCredentialMutationEpoch } from "./credential-mutation-epoch";
|
|
21
22
|
import { isValidCodexAccountId } from "./account-id";
|
|
23
|
+
import { codexAccountLogLabel, fallbackCodexAccountLogLabel } from "./account-label";
|
|
22
24
|
import type { PoolQuotaWriter } from "./quota-types";
|
|
23
25
|
import { CODEX_REFRESH_FLIGHT_CEILING_MS } from "./quota-recovery-timing";
|
|
24
26
|
|
|
@@ -183,6 +185,18 @@ function persistCredentialMutation(store: CodexAccountStore): void {
|
|
|
183
185
|
advanceCodexCredentialMutationEpoch();
|
|
184
186
|
}
|
|
185
187
|
|
|
188
|
+
/**
|
|
189
|
+
* The durable `p<hex6>` label the dashboard and usage log show for this pool account, so a refresh
|
|
190
|
+
* failure line can be matched to the account row (#7592). The id hash is only the label of a
|
|
191
|
+
* legacy row that never received one. A config read failure must not replace the refresh error
|
|
192
|
+
* being reported, so it degrades to that same hash.
|
|
193
|
+
*/
|
|
194
|
+
function poolAccountLogLabel(id: string): string {
|
|
195
|
+
let account: CodexAccount | undefined;
|
|
196
|
+
try { account = loadConfig().codexAccounts?.find(row => row.id === id); } catch { account = undefined; }
|
|
197
|
+
return account ? codexAccountLogLabel(account) : fallbackCodexAccountLogLabel(id);
|
|
198
|
+
}
|
|
199
|
+
|
|
186
200
|
/**
|
|
187
201
|
* Validation metadata that survives a credential write.
|
|
188
202
|
*
|
|
@@ -1055,7 +1069,7 @@ export async function forceRefreshCodexPoolToken(
|
|
|
1055
1069
|
if (isTerminalCodexPoolRefreshFailure(error) || isOperationalCodexPoolRefreshFailure(error)) {
|
|
1056
1070
|
if (isTerminalCodexPoolRefreshFailure(error)) clearCodexPoolRefreshFailure(id);
|
|
1057
1071
|
} else {
|
|
1058
|
-
noteCodexPoolRefreshFailure(id, classifyCodexPoolRefreshFailureReason(error), undefined, refreshFence);
|
|
1072
|
+
noteCodexPoolRefreshFailure(id, classifyCodexPoolRefreshFailureReason(error), undefined, refreshFence, poolAccountLogLabel);
|
|
1059
1073
|
}
|
|
1060
1074
|
settle({ kind: "failed", error });
|
|
1061
1075
|
},
|
|
@@ -1376,7 +1390,7 @@ async function resolveCodexToken(
|
|
|
1376
1390
|
.catch(() => undefined);
|
|
1377
1391
|
const failure = classifyChatgptRefreshFailure(res.status, body?.displaySafe ? body.text : "");
|
|
1378
1392
|
const { reason } = failure;
|
|
1379
|
-
noteChatgptRefreshFailure("pool", res.status, failure);
|
|
1393
|
+
noteChatgptRefreshFailure("pool", res.status, failure, poolAccountLogLabel(id));
|
|
1380
1394
|
throw new TokenRefreshError(reason, `Codex token refresh failed (${reason}); reauthenticate the account.`, res.status, failure.code);
|
|
1381
1395
|
}
|
|
1382
1396
|
const data = (await res.json()) as { access_token: string; refresh_token?: string; expires_in: number };
|
|
@@ -182,11 +182,12 @@ async function runCodexRestart(io: CodexRestartServiceIo): Promise<CodexRestartR
|
|
|
182
182
|
// Decided here, before the early returns, because the model picker lives in the DESKTOP
|
|
183
183
|
// app: "no app-server is running" is not a reason to leave a stale roster on screen.
|
|
184
184
|
//
|
|
185
|
-
// The shell
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
//
|
|
185
|
+
// The shell gets a restart attempt whenever its picker cannot be proven current: at
|
|
186
|
+
// or before the catalog mtime it is stale, and newer is unknown because an earlier
|
|
187
|
+
// sync may have rebound an already-existing catalog file (#4761). Quitting it
|
|
188
|
+
// discards composer drafts, unsent picker selections and pending approvals. The
|
|
189
|
+
// attempt is explicit, not a guarantee: the restart helper can refuse or partially
|
|
190
|
+
// fail, and desktopApp reports that outcome.
|
|
190
191
|
//
|
|
191
192
|
// allowHandoff is false and that is deliberate. The handoff waits for the CALLING
|
|
192
193
|
// process to exit, and this runs inside a long-lived proxy that does not, so every
|
|
@@ -55,6 +55,12 @@ import {
|
|
|
55
55
|
export function nativeEffortClamp(slug: string, effort: string | undefined): string | null {
|
|
56
56
|
if (!effort || (effort !== "max" && effort !== "ultra")) return null;
|
|
57
57
|
if (slug.includes("/")) return null; // routed models map efforts in their adapters
|
|
58
|
+
// `ultra` is a client-facing catalog choice (max reasoning plus proactive delegation) that
|
|
59
|
+
// codex-rs converts to `max` before any request (core/src/client.rs
|
|
60
|
+
// `reasoning_effort_for_request`). The ChatGPT backend rejects it with HTTP 400 even for a
|
|
61
|
+
// model whose pinned ladder lists it (#7590), so it is never a native wire value: fold it to
|
|
62
|
+
// `max`, then clamp `max` like a direct selection.
|
|
63
|
+
const wire = "max";
|
|
58
64
|
const entry = UPSTREAM_NATIVE_ENTRIES.get(slug);
|
|
59
65
|
const levels = Array.isArray(entry?.supported_reasoning_levels)
|
|
60
66
|
? entry.supported_reasoning_levels as Array<{ effort?: string }>
|
|
@@ -65,10 +71,11 @@ export function nativeEffortClamp(slug: string, effort: string | undefined): str
|
|
|
65
71
|
// a retired slug a client still asks for, and future old-ladder slugs)
|
|
66
72
|
// really stops at xhigh — the ChatGPT backend error names exactly none..xhigh — so clamp
|
|
67
73
|
// the synthetic top tier.
|
|
68
|
-
|
|
74
|
+
if (!isGpt56NativeSlug(slug)) return "xhigh";
|
|
75
|
+
return effort === wire ? null : wire;
|
|
69
76
|
}
|
|
70
77
|
const supported = levels.flatMap(l => typeof l.effort === "string" ? [l.effort] : []);
|
|
71
|
-
if (supported.includes(
|
|
78
|
+
if (supported.includes(wire)) return effort === wire ? null : wire;
|
|
72
79
|
const rank = ["minimal", "low", "medium", "high", "xhigh", "max"];
|
|
73
80
|
const highest = supported
|
|
74
81
|
.filter(e => rank.includes(e))
|
|
@@ -119,6 +119,7 @@ export interface CatalogGatherProviderModelOutcome {
|
|
|
119
119
|
readonly state: "authoritative" | "degraded";
|
|
120
120
|
}
|
|
121
121
|
export interface ModelsAuthResolution {
|
|
122
|
+
readonly workbuddy?: import("../../oauth/types").WorkBuddyOAuthMetadata;
|
|
122
123
|
readonly apiKey: string | undefined;
|
|
123
124
|
readonly observed: boolean;
|
|
124
125
|
readonly oauthAccountId?: string;
|
|
@@ -334,8 +335,9 @@ export function captureModelsRequest(
|
|
|
334
335
|
name: string,
|
|
335
336
|
provider: OcxProviderConfig,
|
|
336
337
|
oauthApiBaseUrl: string | undefined,
|
|
338
|
+
workbuddy?: import("../../oauth/types").WorkBuddyOAuthMetadata,
|
|
337
339
|
): CapturedModelsRequest {
|
|
338
|
-
const observed = { oauthApiBaseUrl };
|
|
340
|
+
const observed = { oauthApiBaseUrl, workbuddy };
|
|
339
341
|
const withoutCredential = buildModelsRequest(provider, undefined, name, observed);
|
|
340
342
|
const withCredential = buildModelsRequest(provider, REQUEST_CREDENTIAL_SENTINEL, name, observed);
|
|
341
343
|
const method = withoutCredential.method ?? "GET";
|
|
@@ -400,7 +402,7 @@ export function captureProviderGather(
|
|
|
400
402
|
: authResolver.kind === "refreshing" && provider.authMode === "oauth" && mayResolveModelsOAuth(name, provider)
|
|
401
403
|
? getOAuthCredentialApiBaseUrl(name)
|
|
402
404
|
: undefined;
|
|
403
|
-
const request = captureModelsRequest(name, provider, oauthApiBaseUrl);
|
|
405
|
+
const request = captureModelsRequest(name, provider, oauthApiBaseUrl, observedAuth?.workbuddy);
|
|
404
406
|
const refreshingOAuthAccountId = !observedAuth && authResolver.kind === "refreshing" && provider.authMode === "oauth" && mayResolveModelsOAuth(name, provider)
|
|
405
407
|
? getAccountSet(name)?.activeAccountId
|
|
406
408
|
: undefined;
|