@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/cli/link.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { clientLinkStatusIdentity } from "../client/link-status";
|
|
2
2
|
import { clientLinkTunnelStatus } from "../client/link-tunnel";
|
|
3
3
|
import { loadConfig } from "../config";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
projectLinkStatus, type ClientLinkIdentity, type LinkStatusConfig, type LinkStatusDto, type LinkTunnelDiagnostics, type LinkWireState,
|
|
6
|
+
} from "../link/status-projection";
|
|
7
|
+
import { TUNNEL_CAUSES } from "../link/tunnel-history";
|
|
5
8
|
import { isEpochMs, type LinkChildParity } from "../link/child-parity";
|
|
6
9
|
import { CODEX_SETTINGS_DIGEST_PATTERN } from "../link/codex-settings";
|
|
7
10
|
import type { LinkLocalPeer, LinkPeerStatus } from "../link/compatibility";
|
|
@@ -40,30 +43,11 @@ export const LINK_USAGE = `Usage:
|
|
|
40
43
|
Forced revoke skips remote cleanup. Run ocx disconnect on the remote client.
|
|
41
44
|
Cleanup finds Windows Children and explains how to move them to Hub mode; it changes nothing.`;
|
|
42
45
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
listener: { state: ListenerState; port: number | null };
|
|
49
|
-
links: Array<LinkChildParity & {
|
|
50
|
-
id: string;
|
|
51
|
-
alias: string;
|
|
52
|
-
direction: LinkDirection;
|
|
53
|
-
state: LinkState;
|
|
54
|
-
since: string;
|
|
55
|
-
reason: string | null;
|
|
56
|
-
tunnelPort: number;
|
|
57
|
-
peer?: LinkPeerStatus;
|
|
58
|
-
}>;
|
|
59
|
-
child: null | {
|
|
60
|
-
alias: string;
|
|
61
|
-
state: LinkState;
|
|
62
|
-
since: string;
|
|
63
|
-
reason: string | null;
|
|
64
|
-
};
|
|
65
|
-
local?: LinkLocalPeer;
|
|
66
|
-
}
|
|
46
|
+
/**
|
|
47
|
+
* What `ocx link status` prints: the runtime's status DTO itself, so a field the server adds (like
|
|
48
|
+
* `peer` or the diagnostics `tunnel`) is part of this type the moment it is part of the wire.
|
|
49
|
+
*/
|
|
50
|
+
export type LinkStatusPayload = LinkStatusDto;
|
|
67
51
|
|
|
68
52
|
export interface LinkIssuePayload {
|
|
69
53
|
linkId: string;
|
|
@@ -165,11 +149,11 @@ function validateStatus(value: unknown): LinkStatusPayload {
|
|
|
165
149
|
throw new Error("invalid link API response: listener port");
|
|
166
150
|
}
|
|
167
151
|
if (!Array.isArray(value.links)) throw new Error("invalid link API response: links");
|
|
168
|
-
const links = value.links.map((candidate, index) => {
|
|
152
|
+
const links = value.links.map((candidate, index): LinkStatusPayload["links"][number] => {
|
|
169
153
|
if (!isRecord(candidate)) throw new Error(`invalid link API response: link ${index}`);
|
|
170
154
|
// The OCX-6 parity fields are optional on the wire (an older runtime omits them) and always
|
|
171
155
|
// present in this output, null when absent.
|
|
172
|
-
assertKeys(candidate, ["id", "alias", "direction", "state", "since", "reason", "tunnelPort"], [...PARITY_KEYS, "peer"], `link ${index}`);
|
|
156
|
+
assertKeys(candidate, ["id", "alias", "direction", "state", "since", "reason", "tunnelPort"], [...PARITY_KEYS, "peer", "tunnel"], `link ${index}`);
|
|
173
157
|
const parity = validateParity(candidate, index);
|
|
174
158
|
if (typeof candidate.id !== "string" || !LINK_ID.test(candidate.id)
|
|
175
159
|
|| !validString(candidate.alias) || (candidate.direction !== "hub-initiated" && candidate.direction !== "client-initiated")
|
|
@@ -180,14 +164,15 @@ function validateStatus(value: unknown): LinkStatusPayload {
|
|
|
180
164
|
return {
|
|
181
165
|
id: candidate.id,
|
|
182
166
|
alias: candidate.alias,
|
|
183
|
-
direction: candidate.direction,
|
|
184
|
-
state: candidate.state,
|
|
167
|
+
direction: candidate.direction as LinkDirection,
|
|
168
|
+
state: candidate.state as LinkWireState,
|
|
185
169
|
since: candidate.since,
|
|
186
170
|
reason: candidate.reason,
|
|
187
171
|
tunnelPort: candidate.tunnelPort,
|
|
188
172
|
...parity,
|
|
189
173
|
...(candidate.peer !== undefined ? { peer: validatePeer(candidate.peer, index) } : {}),
|
|
190
|
-
|
|
174
|
+
...(candidate.tunnel !== undefined ? { tunnel: validateTunnel(candidate.tunnel, index) } : {}),
|
|
175
|
+
};
|
|
191
176
|
});
|
|
192
177
|
let child: LinkStatusPayload["child"] = null;
|
|
193
178
|
if (value.child !== null) {
|
|
@@ -310,8 +295,9 @@ async function runStatus(args: string[], deps: LinkCliDeps): Promise<void> {
|
|
|
310
295
|
}
|
|
311
296
|
throw error;
|
|
312
297
|
}
|
|
313
|
-
// A proxy older than the compatibility report
|
|
314
|
-
|
|
298
|
+
// A proxy older than the compatibility report or the tunnel diagnostics ignores the query and
|
|
299
|
+
// answers without those fields.
|
|
300
|
+
const result = validateStatus(await linkRequest<unknown>("/api/link/status?compatibility=1&diagnostics=1", {}, { ...deps, baseUrl }));
|
|
315
301
|
console.log(JSON.stringify(result));
|
|
316
302
|
}
|
|
317
303
|
|
|
@@ -385,6 +371,33 @@ export async function runLinkCommand(rawArgs: string[], deps: LinkCliDeps = {}):
|
|
|
385
371
|
}
|
|
386
372
|
|
|
387
373
|
const PARITY_KEYS = ["lastApplyAt", "runtimeStartedAt", "appliedCodexSettingsDigest", "reportedAt"] as const;
|
|
374
|
+
const WIRE_STATES = new Set(["connecting", "connected", "reconnecting", "failed", "idle"]);
|
|
375
|
+
const CAUSES = new Set<string>(TUNNEL_CAUSES);
|
|
376
|
+
|
|
377
|
+
function validIso(value: unknown): value is string {
|
|
378
|
+
return typeof value === "string" && value.length <= 64 && Number.isFinite(Date.parse(value));
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function validateTunnel(value: unknown, index: number): LinkTunnelDiagnostics {
|
|
382
|
+
const fail = (): never => { throw new Error(`invalid link API response: link ${index} tunnel`); };
|
|
383
|
+
if (!isRecord(value)) return fail();
|
|
384
|
+
assertExactKeys(value, ["attempt", "nextRetryAt", "lastTransition", "lastFailure"], `link ${index} tunnel`);
|
|
385
|
+
const { attempt, nextRetryAt, lastTransition, lastFailure } = value;
|
|
386
|
+
if (typeof attempt !== "number" || !Number.isSafeInteger(attempt) || attempt < 0) fail();
|
|
387
|
+
if (nextRetryAt !== null && !validIso(nextRetryAt)) fail();
|
|
388
|
+
if (lastTransition !== null) {
|
|
389
|
+
if (!isRecord(lastTransition)) return fail();
|
|
390
|
+
assertExactKeys(lastTransition, ["from", "to", "at", "cause"], `link ${index} tunnel transition`);
|
|
391
|
+
if (!WIRE_STATES.has(lastTransition.from as string) || !WIRE_STATES.has(lastTransition.to as string)
|
|
392
|
+
|| !validIso(lastTransition.at) || (lastTransition.cause !== null && !CAUSES.has(lastTransition.cause as string))) fail();
|
|
393
|
+
}
|
|
394
|
+
if (lastFailure !== null) {
|
|
395
|
+
if (!isRecord(lastFailure)) return fail();
|
|
396
|
+
assertExactKeys(lastFailure, ["cause", "at"], `link ${index} tunnel failure`);
|
|
397
|
+
if (!CAUSES.has(lastFailure.cause as string) || !validIso(lastFailure.at)) fail();
|
|
398
|
+
}
|
|
399
|
+
return value as unknown as LinkTunnelDiagnostics;
|
|
400
|
+
}
|
|
388
401
|
|
|
389
402
|
function validateParity(candidate: Record<string, unknown>, index: number): LinkChildParity {
|
|
390
403
|
const time = (key: "lastApplyAt" | "runtimeStartedAt" | "reportedAt"): number | null => {
|
package/src/cli/provider.ts
CHANGED
|
@@ -191,7 +191,7 @@ function handleList(args: string[], active: boolean = localProvidersActive()): v
|
|
|
191
191
|
console.log(` ${name}${isDefault}${source} adapter=${prov.adapter}${model}`);
|
|
192
192
|
}
|
|
193
193
|
|
|
194
|
-
const available = PROVIDER_REGISTRY.filter(e => !configured.includes(e.id));
|
|
194
|
+
const available = PROVIDER_REGISTRY.filter(e => !e.deprecated && !configured.includes(e.id));
|
|
195
195
|
if (available.length > 0) {
|
|
196
196
|
console.log(`\nAvailable from registry (${available.length}):\n`);
|
|
197
197
|
for (const entry of available) {
|
|
@@ -265,6 +265,7 @@ async function handleAdd(args: string[], deps: ProviderCommandDeps): Promise<voi
|
|
|
265
265
|
const registryEntry = getProviderRegistryEntry(name);
|
|
266
266
|
|
|
267
267
|
if (registryEntry) {
|
|
268
|
+
if (registryEntry.deprecated) console.warn(`Warning: provider "${name}" is deprecated: ${registryEntry.deprecated}`);
|
|
268
269
|
provConfig = providerConfigSeed(registryEntry);
|
|
269
270
|
if (apiKey) {
|
|
270
271
|
const candidateError = providerCredentialInputError({ apiKey });
|
package/src/cli/usage-compare.ts
CHANGED
|
@@ -8,7 +8,11 @@ import { readClientConnectionState, sameClientConnectionOwner } from "../client/
|
|
|
8
8
|
import { fetchHubUsage } from "../client/hub-client";
|
|
9
9
|
import { readServiceApiTokenState, serviceApiTokenFingerprint } from "../lib/service-secrets";
|
|
10
10
|
import { runningProxyUpdateHeaders } from "../oauth/login-cli";
|
|
11
|
+
import { readRuntimePort, type RuntimePortState } from "../config/process-state";
|
|
12
|
+
import { isLocalAttestationSecret } from "../lib/local-management-attestation";
|
|
11
13
|
import { redactSecretString } from "../lib/redact";
|
|
14
|
+
import { findLiveProxy, probeHostname } from "../server/proxy-liveness";
|
|
15
|
+
import { fetchBoundLocalManagementRead, type LocalManagementReadDeps } from "../server/local-management-read-client";
|
|
12
16
|
import { rangeWindow, USAGE_SURFACES } from "../usage/summary";
|
|
13
17
|
import type { UsageTimeWindow } from "../usage/time-range";
|
|
14
18
|
import {
|
|
@@ -16,8 +20,7 @@ import {
|
|
|
16
20
|
printData,
|
|
17
21
|
rejectArgs,
|
|
18
22
|
RuntimeApiError,
|
|
19
|
-
|
|
20
|
-
runtimeBaseUrl,
|
|
23
|
+
PROXY_NOT_RUNNING_MESSAGE,
|
|
21
24
|
takeFlag,
|
|
22
25
|
takeOption,
|
|
23
26
|
takeOptionWithSyntax,
|
|
@@ -29,7 +32,10 @@ export const USAGE_COMPARE_USAGE = `Usage:
|
|
|
29
32
|
[--provider <name>] [--model <id>] [--api-key-id <id>] [--json]`;
|
|
30
33
|
|
|
31
34
|
export type CompareRange = "today" | "7d" | "30d";
|
|
32
|
-
export type UsageCompareDeps = RuntimeApiDeps &
|
|
35
|
+
export type UsageCompareDeps = RuntimeApiDeps & Pick<LocalManagementReadDeps, "directFetch"> & {
|
|
36
|
+
now?: () => number;
|
|
37
|
+
readRuntime?: typeof readRuntimePort;
|
|
38
|
+
};
|
|
33
39
|
|
|
34
40
|
const METRICS = ["requests", "totalTokens", "inputTokens", "outputTokens", "cachedInputTokens", "estimatedCostUsd"] as const;
|
|
35
41
|
type Metric = typeof METRICS[number];
|
|
@@ -189,10 +195,60 @@ function captureUsageOwner() {
|
|
|
189
195
|
const headers = runningProxyUpdateHeaders();
|
|
190
196
|
const fingerprint = connection.kind === "connected" ? connection.value.tokenFingerprint
|
|
191
197
|
: serviceApiTokenFingerprint(headers.get("X-OpenCodex-API-Key") ?? "");
|
|
192
|
-
return { connection, token,
|
|
198
|
+
return { connection, token, fingerprint };
|
|
193
199
|
}
|
|
194
200
|
type UsageOwner = ReturnType<typeof captureUsageOwner>;
|
|
195
201
|
|
|
202
|
+
function pinUsageRuntime(deps: UsageCompareDeps, owner: UsageOwner): (suffix: string) => Promise<ScopedUsage> {
|
|
203
|
+
const readRuntime = deps.readRuntime ?? readRuntimePort;
|
|
204
|
+
const initial = readRuntimePortForComparison();
|
|
205
|
+
const pinned = { ...initial };
|
|
206
|
+
let liveIdentity: string | undefined;
|
|
207
|
+
function readRuntimePortForComparison(): RuntimePortState {
|
|
208
|
+
const runtime = readRuntime();
|
|
209
|
+
if (!runtime || !isLocalAttestationSecret(runtime.attestationSecret)) {
|
|
210
|
+
throw new Error("Usage runtime unavailable: restart the proxy with an attested runtime record.");
|
|
211
|
+
}
|
|
212
|
+
return runtime;
|
|
213
|
+
}
|
|
214
|
+
function assertRuntime(): RuntimePortState {
|
|
215
|
+
const current = readRuntimePortForComparison();
|
|
216
|
+
if (current.pid !== pinned.pid || current.port !== pinned.port || current.hostname !== pinned.hostname
|
|
217
|
+
|| current.attestationSecret !== pinned.attestationSecret) {
|
|
218
|
+
throw new Error("Usage runtime changed during comparison; no comparison was produced. Retry for the current runtime.");
|
|
219
|
+
}
|
|
220
|
+
return current;
|
|
221
|
+
}
|
|
222
|
+
return async suffix => {
|
|
223
|
+
assertRuntime();
|
|
224
|
+
const live = await (deps.findLiveProxy ?? findLiveProxy)();
|
|
225
|
+
assertRuntime();
|
|
226
|
+
assertUsageOwner(owner);
|
|
227
|
+
if (!live) throw new RuntimeApiError(PROXY_NOT_RUNNING_MESSAGE, 503, null, "proxy_not_running");
|
|
228
|
+
const identity = JSON.stringify([live.pid, live.port, live.hostname, live.source, live.role]);
|
|
229
|
+
if (live.source !== "runtime" || live.pid !== pinned.pid || live.port !== pinned.port
|
|
230
|
+
|| probeHostname(live.hostname) !== probeHostname(pinned.hostname) || live.role === "client"
|
|
231
|
+
|| (liveIdentity !== undefined && liveIdentity !== identity)) {
|
|
232
|
+
throw new Error("Usage runtime changed during comparison; no comparison was produced. Retry for the current runtime.");
|
|
233
|
+
}
|
|
234
|
+
liveIdentity = identity;
|
|
235
|
+
const read = await fetchBoundLocalManagementRead(live, `/api/usage?${suffix.replace(/^\?/, "")}`, {
|
|
236
|
+
fetchImpl: deps.fetchImpl, directFetch: deps.directFetch, readRuntime: assertRuntime,
|
|
237
|
+
timeoutMs: 60_000, requireResponseProof: true,
|
|
238
|
+
});
|
|
239
|
+
assertRuntime();
|
|
240
|
+
if (read.kind === "unavailable") {
|
|
241
|
+
throw new RuntimeApiError(`Local usage read unavailable (${read.reason}). No comparison was produced.`, 503, null);
|
|
242
|
+
}
|
|
243
|
+
if (!read.response.ok) {
|
|
244
|
+
throw new RuntimeApiError(`Usage request failed (HTTP ${read.response.status}). No comparison was produced.`, read.response.status, null);
|
|
245
|
+
}
|
|
246
|
+
const result = await read.response.json() as ScopedUsage;
|
|
247
|
+
assertRuntime();
|
|
248
|
+
return result;
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
196
252
|
function assertUsageOwner(owner: UsageOwner): void {
|
|
197
253
|
const current = readClientConnectionState();
|
|
198
254
|
const expected = owner.connection;
|
|
@@ -216,6 +272,7 @@ async function readUsage(
|
|
|
216
272
|
apiKeyId: string | undefined,
|
|
217
273
|
deps: RuntimeApiDeps,
|
|
218
274
|
owner: UsageOwner,
|
|
275
|
+
localRead?: (suffix: string) => Promise<ScopedUsage>,
|
|
219
276
|
): Promise<ScopedUsage> {
|
|
220
277
|
assertUsageOwner(owner);
|
|
221
278
|
const { connection, token } = owner;
|
|
@@ -233,16 +290,15 @@ async function readUsage(
|
|
|
233
290
|
result = await fetchHubUsage(connection.value.serverUrl, token.token, new URLSearchParams(suffix.replace(/^\?/, "")), {
|
|
234
291
|
fetchImpl: peerFetch, timeoutMs: 60_000, ...(link ? { peer: "Home" as const } : {}),
|
|
235
292
|
});
|
|
236
|
-
} else if (apiKeyId === undefined) {
|
|
237
|
-
result = await runtimeRequest(`/api/usage${suffix}`, { headers: owner.headers }, deps);
|
|
238
293
|
} else {
|
|
239
294
|
try {
|
|
240
|
-
result = await
|
|
295
|
+
result = await localRead!(suffix);
|
|
241
296
|
} catch (error) {
|
|
242
|
-
if (
|
|
297
|
+
if (apiKeyId === undefined) throw error;
|
|
298
|
+
if (error instanceof RuntimeApiError) throw error;
|
|
243
299
|
throw new Error("Key-scoped usage could not be read. Check runtime access and retry.");
|
|
244
300
|
}
|
|
245
|
-
if (result.filter?.apiKeyId !== apiKeyId) {
|
|
301
|
+
if (apiKeyId !== undefined && result.filter?.apiKeyId !== apiKeyId) {
|
|
246
302
|
throw new Error("The server did not confirm the requested API key scope. Upgrade and restart the proxy, then retry.");
|
|
247
303
|
}
|
|
248
304
|
}
|
|
@@ -276,9 +332,9 @@ export async function handleUsageCompareCommand(argv: string[], deps: UsageCompa
|
|
|
276
332
|
const windows = compareUsageWindows(range, now);
|
|
277
333
|
const filters = { range, surface, provider, model, apiKeyId };
|
|
278
334
|
const owner = captureUsageOwner();
|
|
279
|
-
const
|
|
280
|
-
const currentReport = await readUsage(query({ ...filters, since: windows.current.since, until: windows.current.until }), windows.current, apiKeyId,
|
|
281
|
-
const previousReport = await readUsage(query({ ...filters, since: windows.previous.since, until: windows.previous.until }), windows.previous, apiKeyId,
|
|
335
|
+
const localRead = owner.connection.kind === "disconnected" ? pinUsageRuntime(deps, owner) : undefined;
|
|
336
|
+
const currentReport = await readUsage(query({ ...filters, since: windows.current.since, until: windows.current.until }), windows.current, apiKeyId, deps, owner, localRead);
|
|
337
|
+
const previousReport = await readUsage(query({ ...filters, since: windows.previous.since, until: windows.previous.until }), windows.previous, apiKeyId, deps, owner, localRead);
|
|
282
338
|
const current = totalsOf(currentReport);
|
|
283
339
|
const previous = totalsOf(previousReport);
|
|
284
340
|
const deltaTotals: Totals = {};
|
|
@@ -76,7 +76,7 @@ function liftRequestIdleTimer(req: Request, server: Pick<Server<unknown>, "timeo
|
|
|
76
76
|
* fetch, so a rebinding or cross-site browser page cannot reach the Home through this port.
|
|
77
77
|
* - Without the committed link key nothing is fetched, and the caller gets
|
|
78
78
|
* `503 link_credential_unavailable`.
|
|
79
|
-
* - `/readyz` stays local
|
|
79
|
+
* - `/readyz` stays local and is never relayed; the listener answers it from `link-readiness.ts`.
|
|
80
80
|
* - A relayed request is exempt from the listener's idle limit, like a standalone data route.
|
|
81
81
|
*/
|
|
82
82
|
export function handleLinkIngress(
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a Remote Link Child can serve relayed requests right now, for its `GET /readyz`.
|
|
3
|
+
*
|
|
4
|
+
* `/healthz` keeps meaning "this process answers". A Child whose tunnel is down still answers it,
|
|
5
|
+
* yet every relayed turn fails with 503, so readiness is where that shows: not ready, with one
|
|
6
|
+
* fixed reason word. Nothing here carries ssh output, host names, ports or keys.
|
|
7
|
+
*
|
|
8
|
+
* - A Child-initiated link reads its own tunnel supervisor; no network traffic.
|
|
9
|
+
* - A Home-initiated link has no local tunnel to inspect (the Home runs `ssh -R`), so it runs
|
|
10
|
+
* the credential-free relay preflight through the tunnel port. One preflight runs at a time, a
|
|
11
|
+
* verdict is reused for `LINK_READINESS_TTL_MS`, and a request waits at most
|
|
12
|
+
* `LINK_READINESS_WAIT_MS` for a running preflight before it answers `link_unverified`. An
|
|
13
|
+
* unauthenticated caller can therefore cause at most one preflight per TTL.
|
|
14
|
+
*/
|
|
15
|
+
import { LINK_RELAY_AUTH_TIMEOUT_MS } from "../link/relay-auth";
|
|
16
|
+
import { isLinkPort } from "../link/ports";
|
|
17
|
+
import { probeLinkRelayPreflight, type BoundLinkRelayTarget, type LinkRelayPreflightResult } from "./link-relay-transport";
|
|
18
|
+
import type { ClientLinkSupervisorStatus } from "./link-tunnel";
|
|
19
|
+
|
|
20
|
+
export type LinkReadinessReason =
|
|
21
|
+
| "tunnel_idle" | "tunnel_connecting" | "tunnel_failed" | "tunnel_unavailable" | "sidecar_invalid"
|
|
22
|
+
| "home_unreachable" | "home_unauthorized" | "link_auth_failed" | "link_credential_unavailable"
|
|
23
|
+
| "link_unverified";
|
|
24
|
+
|
|
25
|
+
export type LinkReadiness = { ready: true } | { ready: false; reason: LinkReadinessReason };
|
|
26
|
+
|
|
27
|
+
export const LINK_READINESS_TTL_MS = 5_000;
|
|
28
|
+
export const LINK_READINESS_WAIT_MS = 500;
|
|
29
|
+
|
|
30
|
+
const READY: LinkReadiness = { ready: true };
|
|
31
|
+
const notReady = (reason: LinkReadinessReason): LinkReadiness => ({ ready: false, reason });
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A Child-initiated link from its supervisor. The keyed probe the supervisor runs every 30 seconds
|
|
35
|
+
* while connected is display-only for the tunnel, but a Home that stopped answering or refuses the
|
|
36
|
+
* key cannot serve a turn, so readiness follows it. A Home whose own startup readiness is pending
|
|
37
|
+
* still serves relayed requests (the probe's `home_not_ready`), so that reads ready.
|
|
38
|
+
*/
|
|
39
|
+
export function clientInitiatedLinkReadiness(status: ClientLinkSupervisorStatus): LinkReadiness {
|
|
40
|
+
if (status.kind === "failed") return notReady("sidecar_invalid");
|
|
41
|
+
if (status.kind === "stopped") return notReady("tunnel_idle");
|
|
42
|
+
const { state, probe } = status;
|
|
43
|
+
if (state.kind === "connected") {
|
|
44
|
+
if (probe === "home_unreachable") return notReady("home_unreachable");
|
|
45
|
+
if (probe === "unauthorized") return notReady("home_unauthorized");
|
|
46
|
+
return READY;
|
|
47
|
+
}
|
|
48
|
+
if (state.kind === "connecting" || state.kind === "reconnecting") return notReady("tunnel_connecting");
|
|
49
|
+
if (state.kind === "failed") return notReady(state.inFlight ? "tunnel_connecting" : "tunnel_failed");
|
|
50
|
+
return notReady("tunnel_idle");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function fromPreflight(result: LinkRelayPreflightResult): LinkReadiness {
|
|
54
|
+
if (result === "ok") return READY;
|
|
55
|
+
return notReady(result === "unreachable" ? "home_unreachable" : "link_auth_failed");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface HomeInitiatedLinkReadinessDeps {
|
|
59
|
+
target: Omit<BoundLinkRelayTarget, "admissionKey">;
|
|
60
|
+
linkKey: () => string | null;
|
|
61
|
+
probe?: (target: BoundLinkRelayTarget, options: { timeoutMs: number }) => Promise<LinkRelayPreflightResult>;
|
|
62
|
+
now?: () => number;
|
|
63
|
+
/** Resolves after `ms`; the wait for a running preflight. */
|
|
64
|
+
delay?: (ms: number) => Promise<void>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The readiness of a Home-initiated link, from a shared, rate-limited relay preflight. */
|
|
68
|
+
export function createHomeInitiatedLinkReadiness(deps: HomeInitiatedLinkReadinessDeps): () => Promise<LinkReadiness> {
|
|
69
|
+
const probe = deps.probe ?? probeLinkRelayPreflight;
|
|
70
|
+
const now = deps.now ?? Date.now;
|
|
71
|
+
const delay = deps.delay ?? (ms => new Promise<void>(resolve => {
|
|
72
|
+
const timer = setTimeout(resolve, ms);
|
|
73
|
+
(timer as { unref?: () => void }).unref?.();
|
|
74
|
+
}));
|
|
75
|
+
let last: { at: number; verdict: LinkReadiness } | undefined;
|
|
76
|
+
let flight: Promise<LinkReadiness> | undefined;
|
|
77
|
+
const run = (admissionKey: string): Promise<LinkReadiness> => {
|
|
78
|
+
flight ??= probe({ ...deps.target, admissionKey }, { timeoutMs: LINK_RELAY_AUTH_TIMEOUT_MS })
|
|
79
|
+
.then(fromPreflight, () => notReady("home_unreachable"))
|
|
80
|
+
.then(verdict => {
|
|
81
|
+
last = { at: now(), verdict };
|
|
82
|
+
return verdict;
|
|
83
|
+
})
|
|
84
|
+
.finally(() => { flight = undefined; });
|
|
85
|
+
return flight;
|
|
86
|
+
};
|
|
87
|
+
return async () => {
|
|
88
|
+
if (!isLinkPort(deps.target.tunnelPort)) return notReady("tunnel_unavailable");
|
|
89
|
+
let admissionKey: string | null;
|
|
90
|
+
try { admissionKey = deps.linkKey(); } catch { admissionKey = null; }
|
|
91
|
+
if (!admissionKey) return notReady("link_credential_unavailable");
|
|
92
|
+
const at = now();
|
|
93
|
+
if (last && at >= last.at && at - last.at < LINK_READINESS_TTL_MS) return last.verdict;
|
|
94
|
+
const unverified = notReady("link_unverified");
|
|
95
|
+
return await Promise.race([run(admissionKey), delay(LINK_READINESS_WAIT_MS).then(() => unverified)]);
|
|
96
|
+
};
|
|
97
|
+
}
|
|
@@ -9,7 +9,7 @@ import { linkPeerReportHeaders } from "../link/peer-report";
|
|
|
9
9
|
import { buildPeerFeatures } from "../remote/features";
|
|
10
10
|
import {
|
|
11
11
|
LINK_RELAY_AUTH_PATH, LINK_RELAY_AUTH_TIMEOUT_MS, LINK_RELAY_AUTH_VERSION,
|
|
12
|
-
LINK_RELAY_SESSION_HEADER, linkRelayChallenge, linkRelayProofMatches,
|
|
12
|
+
LINK_RELAY_PROBE_HEADER, LINK_RELAY_SESSION_HEADER, linkRelayChallenge, linkRelayProofMatches,
|
|
13
13
|
} from "../link/relay-auth";
|
|
14
14
|
|
|
15
15
|
export interface BoundLinkRelayTarget {
|
|
@@ -27,6 +27,14 @@ export class LinkRelayAuthenticationError extends Error {
|
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/** Only the credential-free preflight deadline expired; no data request has begun. */
|
|
31
|
+
export class LinkRelayAuthTimeoutError extends Error {
|
|
32
|
+
constructor() {
|
|
33
|
+
super("link relay authentication timed out");
|
|
34
|
+
this.name = "LinkRelayAuthTimeoutError";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
30
38
|
/** One physical connection, ever. A closed keep-alive socket cannot trigger a silent reconnect. */
|
|
31
39
|
class SingleConnectionAgent extends Agent {
|
|
32
40
|
socket: Socket | undefined;
|
|
@@ -153,21 +161,76 @@ async function responseOnConnection(incoming: IncomingMessage, agent: SingleConn
|
|
|
153
161
|
|
|
154
162
|
let peerReport: Record<string, string> | undefined;
|
|
155
163
|
|
|
156
|
-
/**
|
|
157
|
-
|
|
164
|
+
/** The credential-free challenge for one connection; the proof binds this nonce to the link key. */
|
|
165
|
+
function relayChallenge(target: BoundLinkRelayTarget, origin: URL) {
|
|
166
|
+
const challenge = linkRelayChallenge(serviceApiTokenFingerprint(target.admissionKey), target.apiKeyId!, target.linkId!);
|
|
167
|
+
const probe = new URL(LINK_RELAY_AUTH_PATH, origin);
|
|
168
|
+
probe.search = new URLSearchParams({ version: LINK_RELAY_AUTH_VERSION, key: target.apiKeyId!,
|
|
169
|
+
link: target.linkId!, nonce: challenge.nonce, proof: challenge.caller }).toString();
|
|
170
|
+
// The challenge also carries this Child's version and features for the Home's link status.
|
|
171
|
+
peerReport ??= linkPeerReportHeaders(packageVersion("0.0.0"), buildPeerFeatures());
|
|
172
|
+
return { challenge, probe, headers: peerReport };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The outcome of the credential-free preflight alone: `ok` when the Home answered with a proof
|
|
177
|
+
* only the holder of the link key can make, `unrecognized` for a peer without the route (404),
|
|
178
|
+
* `auth_failed` for any other answer, and `unreachable` when nothing answered in time.
|
|
179
|
+
*/
|
|
180
|
+
export type LinkRelayPreflightResult = "ok" | "unreachable" | "auth_failed" | "unrecognized";
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Run only the preflight a relayed request starts with, on its own connection, and close it. It
|
|
184
|
+
* sends no link key, no data request and no body; the Child's readiness uses it to learn whether
|
|
185
|
+
* the Home answers through the tunnel right now. It is marked probe-only, so the Home reserves no
|
|
186
|
+
* session for a data request that never comes. The response body is never read.
|
|
187
|
+
*/
|
|
188
|
+
export async function probeLinkRelayPreflight(
|
|
189
|
+
target: BoundLinkRelayTarget, options: { timeoutMs?: number } = {},
|
|
190
|
+
): Promise<LinkRelayPreflightResult> {
|
|
191
|
+
const timeoutMs = options.timeoutMs ?? LINK_RELAY_AUTH_TIMEOUT_MS;
|
|
192
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new RangeError("invalid link relay preflight timeout");
|
|
193
|
+
if (!target.linkId || !target.apiKeyId) return "auth_failed";
|
|
194
|
+
const origin = new URL(`http://127.0.0.1:${target.tunnelPort}`);
|
|
195
|
+
let request: ReturnType<typeof relayChallenge>;
|
|
196
|
+
try { request = relayChallenge(target, origin); } catch { return "auth_failed"; }
|
|
197
|
+
const agent = new SingleConnectionAgent({ keepAlive: false, maxSockets: 1, maxFreeSockets: 0 });
|
|
198
|
+
const deadline = clearableDeadline(timeoutMs);
|
|
199
|
+
try {
|
|
200
|
+
const response = await requestOnConnection(request.probe, {
|
|
201
|
+
method: "GET", signal: deadline.signal, headers: { ...request.headers, [LINK_RELAY_PROBE_HEADER]: "1" },
|
|
202
|
+
}, agent);
|
|
203
|
+
const proof = response.headers["x-opencodex-link-proof"];
|
|
204
|
+
if (response.statusCode === 204 && typeof proof === "string" && linkRelayProofMatches(proof, request.challenge.expected)) return "ok";
|
|
205
|
+
return response.statusCode === 404 ? "unrecognized" : "auth_failed";
|
|
206
|
+
} catch {
|
|
207
|
+
return "unreachable";
|
|
208
|
+
} finally {
|
|
209
|
+
deadline.clear();
|
|
210
|
+
await agent.close();
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Authenticate and stream one request on the SAME socket; never fall back to an ordinary fetch.
|
|
216
|
+
* LinkRelayAuthTimeoutError certifies that only the credential-free preflight expired before
|
|
217
|
+
* data-request construction. Caller aborts and failures after that boundary keep their own errors.
|
|
218
|
+
*/
|
|
219
|
+
export async function fetchBoundLinkRelay(
|
|
220
|
+
target: BoundLinkRelayTarget, destination: string, init: RequestInit,
|
|
221
|
+
options: { authTimeoutMs?: number } = {},
|
|
222
|
+
): Promise<Response> {
|
|
223
|
+
const authTimeoutMs = options.authTimeoutMs ?? LINK_RELAY_AUTH_TIMEOUT_MS;
|
|
224
|
+
if (!Number.isFinite(authTimeoutMs) || authTimeoutMs <= 0) throw new RangeError("invalid link relay auth timeout");
|
|
158
225
|
if (!target.linkId || !target.apiKeyId) throw new LinkRelayAuthenticationError();
|
|
159
226
|
const url = new URL(destination);
|
|
160
227
|
if (url.origin !== `http://127.0.0.1:${target.tunnelPort}`) throw new LinkRelayAuthenticationError();
|
|
161
|
-
const challenge =
|
|
162
|
-
const probe = new URL(LINK_RELAY_AUTH_PATH, url);
|
|
163
|
-
probe.search = new URLSearchParams({ version: LINK_RELAY_AUTH_VERSION, key: target.apiKeyId,
|
|
164
|
-
link: target.linkId, nonce: challenge.nonce, proof: challenge.caller }).toString();
|
|
228
|
+
const { challenge, probe, headers: preflightHeaders } = relayChallenge(target, url);
|
|
165
229
|
const agent = new SingleConnectionAgent({ keepAlive: true, maxSockets: 1, maxFreeSockets: 1 });
|
|
166
|
-
const deadline = clearableDeadline(
|
|
230
|
+
const deadline = clearableDeadline(authTimeoutMs, init.signal ?? undefined);
|
|
231
|
+
let dataRequestStarted = false;
|
|
167
232
|
try {
|
|
168
|
-
|
|
169
|
-
peerReport ??= linkPeerReportHeaders(packageVersion("0.0.0"), buildPeerFeatures());
|
|
170
|
-
const response = await requestOnConnection(probe, { method: "GET", signal: deadline.signal, headers: peerReport }, agent);
|
|
233
|
+
const response = await requestOnConnection(probe, { method: "GET", signal: deadline.signal, headers: preflightHeaders }, agent);
|
|
171
234
|
const socket = agent.socket;
|
|
172
235
|
const proof = response.headers["x-opencodex-link-proof"];
|
|
173
236
|
if (response.statusCode !== 204 || typeof proof !== "string" || !linkRelayProofMatches(proof, challenge.expected)) {
|
|
@@ -177,15 +240,20 @@ export async function fetchBoundLinkRelay(target: BoundLinkRelayTarget, destinat
|
|
|
177
240
|
for await (const chunk of response) {
|
|
178
241
|
if (chunk.length) throw new LinkRelayAuthenticationError();
|
|
179
242
|
}
|
|
243
|
+
deadline.signal.throwIfAborted();
|
|
180
244
|
deadline.clear();
|
|
181
245
|
init.signal?.throwIfAborted();
|
|
182
246
|
if (!socket || socket.destroyed || socket !== agent.socket) throw new LinkRelayAuthenticationError();
|
|
183
247
|
const headers = new Headers(init.headers);
|
|
184
248
|
headers.set(LINK_RELAY_SESSION_HEADER, challenge.nonce);
|
|
249
|
+
dataRequestStarted = true;
|
|
185
250
|
const incoming = await requestOnConnection(url, { ...init, headers }, agent, socket);
|
|
186
251
|
return await responseOnConnection(incoming, agent, init.method ?? "GET");
|
|
187
252
|
} catch (error) {
|
|
253
|
+
const authDeadlineExpired = !dataRequestStarted && deadline.didExpire()
|
|
254
|
+
&& !(error instanceof LinkRelayAuthenticationError);
|
|
188
255
|
await agent.close();
|
|
256
|
+
if (authDeadlineExpired && !init.signal?.aborted) throw new LinkRelayAuthTimeoutError();
|
|
189
257
|
throw error;
|
|
190
258
|
} finally { deadline.clear(); }
|
|
191
259
|
}
|