@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
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
startMemoryWatchdog,
|
|
33
33
|
type MemoryWatchdog,
|
|
34
34
|
} from "./memory-watchdog";
|
|
35
|
+
import { ensureQuotaPoolIdentity } from "../quota-pool/identity";
|
|
35
36
|
|
|
36
37
|
type PolicyApply = (policy: StorageCleanupPolicy) => void;
|
|
37
38
|
|
|
@@ -204,6 +205,9 @@ export function acquireServerBackgroundLifecycle(
|
|
|
204
205
|
if (cleanupInProgress) {
|
|
205
206
|
throw new Error("server background lifecycle cleanup is still in progress");
|
|
206
207
|
}
|
|
208
|
+
// The quota-pool identity (homeId and the accountRef key) is minted here, per server and off
|
|
209
|
+
// the request path, so GET /api/quota-pool only ever reads it.
|
|
210
|
+
try { ensureQuotaPoolIdentity(); } catch { /* the read reports quota_pool_identity_unavailable */ }
|
|
207
211
|
|
|
208
212
|
const owner: LeaseOwner = {
|
|
209
213
|
token: Symbol("server-background-lifecycle"),
|
|
@@ -81,6 +81,7 @@ import { createNativeChatComboSource, handleNativeChatCompletions, nativeChatDec
|
|
|
81
81
|
import { upstreamWireForAdapter, type ProtocolReasonCode } from "../protocols/contract";
|
|
82
82
|
import { createProtocolEnvelope } from "../protocols/envelope";
|
|
83
83
|
import { featuresFromChatBody } from "../protocols/features";
|
|
84
|
+
import { credentialDomainFor } from "../protocols/opaque-state";
|
|
84
85
|
import { checkRepresentable, unrepresentableMessage } from "../protocols/guard";
|
|
85
86
|
import { requestPathForLane } from "../protocols/path";
|
|
86
87
|
import { resolveProtocolSettings } from "../protocols/settings";
|
|
@@ -344,7 +345,13 @@ async function handleChatCompletionsWithBudget(
|
|
|
344
345
|
directConversation = chatConversationToIr(chatBody, chatBody.model as string);
|
|
345
346
|
internalBody = chatSettingsBody(chatBody);
|
|
346
347
|
} else {
|
|
347
|
-
internalBody = chatToResponsesBody(chatBody
|
|
348
|
+
internalBody = chatToResponsesBody(chatBody, {
|
|
349
|
+
// #7429: only a route that is and stays first-party Anthropic may receive signed thinking
|
|
350
|
+
// a Chat client returns; a combo, policy or rewritten route resolves its target later.
|
|
351
|
+
acceptAnthropicReasoningSignatures: settledRoute !== null && !routeMayChangeCredentialDomain
|
|
352
|
+
&& settledRoute.provider.adapter === "anthropic"
|
|
353
|
+
&& credentialDomainFor(settledRoute.provider)?.firstPartyAnthropic === true,
|
|
354
|
+
});
|
|
348
355
|
}
|
|
349
356
|
if (effortRow) {
|
|
350
357
|
internalBody.reasoning = {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
2
|
import type { OcxConfig } from "../types";
|
|
3
3
|
import { canonicalGuiBrowserOrigin, standaloneGuiPairingOrigin } from "../lib/gui-pair-capability";
|
|
4
|
+
import { authorizationCredential } from "../lib/authorization-scheme";
|
|
4
5
|
import {
|
|
5
6
|
isAllowedManagementOrigin,
|
|
6
7
|
isApiAuthRequired,
|
|
@@ -409,7 +410,7 @@ function isPairingTransportPermitted(origin: string): boolean {
|
|
|
409
410
|
|
|
410
411
|
function requestCredential(req: Request): string | null {
|
|
411
412
|
return req.headers.get("x-opencodex-api-key")?.trim()
|
|
412
|
-
|| req.headers
|
|
413
|
+
|| authorizationCredential(req.headers)
|
|
413
414
|
|| null;
|
|
414
415
|
}
|
|
415
416
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createLinkRelaySessions } from "./link-relay-sessions";
|
|
2
|
+
import { createLinkPeerObservations, type LinkPeerEntry } from "../../link/peer-report";
|
|
2
3
|
import type { Server } from "bun";
|
|
3
4
|
import {
|
|
4
5
|
emptyLinkStore,
|
|
@@ -9,6 +10,7 @@ import {
|
|
|
9
10
|
import { linkStorePath } from "../../link/paths";
|
|
10
11
|
import { linkRouteAllowed } from "../../link/routes";
|
|
11
12
|
import { withConfigMutationLockSync } from "../../config/mutation-lock";
|
|
13
|
+
import { registerFallbackLinkResolver } from "../../routing/fallback-policy";
|
|
12
14
|
|
|
13
15
|
export const LINK_INGRESS_HOSTNAME = "opencodex-link.invalid";
|
|
14
16
|
|
|
@@ -51,6 +53,8 @@ export interface LinkListenerLifecycle<T> {
|
|
|
51
53
|
linkAdmissionKeyIds(): ReadonlySet<string>;
|
|
52
54
|
onAuthenticatedCatalog(listener: (apiKeyId: string) => void): () => void;
|
|
53
55
|
notifyAuthenticatedCatalog(apiKeyId: string): void;
|
|
56
|
+
/** What this Home last learned of a Child over the link; empty until it relays a request. */
|
|
57
|
+
peerObservation(linkId: string): LinkPeerEntry;
|
|
54
58
|
status(): LinkListenerStatus;
|
|
55
59
|
close(): Promise<void>;
|
|
56
60
|
stop(): Promise<void>;
|
|
@@ -76,6 +80,7 @@ export function createLinkListenerLifecycle<T>(deps: LinkListenerDeps = {}): Lin
|
|
|
76
80
|
let stopped = false;
|
|
77
81
|
const authenticatedCatalogListeners = new Set<(apiKeyId: string) => void>();
|
|
78
82
|
let lifecycleStatus: LinkListenerStatus = { state: "off", port: null, reason: null };
|
|
83
|
+
const peers = createLinkPeerObservations();
|
|
79
84
|
|
|
80
85
|
const setStatus = (state: LinkListenerStatus["state"], port: number | null, reason: string | null): void => {
|
|
81
86
|
lifecycleStatus = { state, port, reason };
|
|
@@ -89,6 +94,9 @@ export function createLinkListenerLifecycle<T>(deps: LinkListenerDeps = {}): Lin
|
|
|
89
94
|
const readStoreForAdmission = (): LinkStore => {
|
|
90
95
|
try { return readStore(storePath); } catch { return emptyLinkStore(); }
|
|
91
96
|
};
|
|
97
|
+
// OCX-11: the fallback policy resolves a link data key to its link id through this slot.
|
|
98
|
+
const releaseFallbackLinkResolver = registerFallbackLinkResolver(keyId =>
|
|
99
|
+
readStoreForAdmission().links.find(link => link.apiKeyId === keyId)?.id);
|
|
92
100
|
|
|
93
101
|
const bindIfNeeded = (): void => {
|
|
94
102
|
if (stopped || listener || !startContext) return;
|
|
@@ -104,7 +112,7 @@ export function createLinkListenerLifecycle<T>(deps: LinkListenerDeps = {}): Lin
|
|
|
104
112
|
return;
|
|
105
113
|
}
|
|
106
114
|
const requestedPort = store.listenerPort ?? 0;
|
|
107
|
-
const sessions = createLinkRelaySessions({ fingerprints: (keyId, linkId) => {
|
|
115
|
+
const sessions = createLinkRelaySessions({ peers, fingerprints: (keyId, linkId) => {
|
|
108
116
|
if (!readStoreForAdmission().links.some(link => link.id === linkId && link.apiKeyId === keyId)) return [];
|
|
109
117
|
return startContext?.keyFingerprints?.(keyId) ?? [];
|
|
110
118
|
} });
|
|
@@ -214,12 +222,14 @@ export function createLinkListenerLifecycle<T>(deps: LinkListenerDeps = {}): Lin
|
|
|
214
222
|
notifyAuthenticatedCatalog(apiKeyId) {
|
|
215
223
|
for (const listener of authenticatedCatalogListeners) listener(apiKeyId);
|
|
216
224
|
},
|
|
225
|
+
peerObservation: linkId => peers.get(linkId),
|
|
217
226
|
status() {
|
|
218
227
|
return { ...lifecycleStatus };
|
|
219
228
|
},
|
|
220
229
|
close,
|
|
221
230
|
async stop() {
|
|
222
231
|
stopped = true;
|
|
232
|
+
releaseFallbackLinkResolver();
|
|
223
233
|
// Process shutdown is deliberately forceful, unlike last-link removal. Every
|
|
224
234
|
// authenticated socket is closed, and the client transport cannot reconnect it.
|
|
225
235
|
relaySessions?.abortReservations();
|
|
@@ -14,22 +14,69 @@ import type { ManagementSessionControl } from "../management-auth";
|
|
|
14
14
|
import { runPassiveRead } from "../../lib/passive-read";
|
|
15
15
|
import { LINK_CODEX_SETTINGS_PATH, linkManagementRouteAllowed, linkManagementTarget } from "../../link/management-routes";
|
|
16
16
|
import { projectHomeCodexSettings } from "../../link/codex-settings";
|
|
17
|
+
import { homeLinkChildParity, LINK_CHILD_PARITY_MAX_BYTES, LINK_CHILD_PARITY_PATH, parseLinkChildParityReport } from "../../link/child-parity";
|
|
18
|
+
import { linkStorePath } from "../../link/paths";
|
|
19
|
+
import { readLinkStore } from "../../link/store";
|
|
20
|
+
import { readBoundedResponseBytes } from "../../lib/bounded-body";
|
|
17
21
|
import { packageVersion } from "../../lib/package-version";
|
|
18
22
|
|
|
19
23
|
const VERSION = packageVersion("0.0.0");
|
|
20
24
|
const KEPT_RESPONSE_HEADERS = ["content-type", "content-language", "etag", "last-modified"];
|
|
25
|
+
const REPORT_READ_TIMEOUT_MS = 5_000;
|
|
21
26
|
|
|
22
27
|
function notFound(): Response {
|
|
23
28
|
return Response.json({ error: "not_found" }, { status: 404, headers: { "cache-control": "no-store" } });
|
|
24
29
|
}
|
|
25
30
|
|
|
31
|
+
function refused(status: number, code: string): Response {
|
|
32
|
+
return Response.json({ error: code }, { status, headers: { "cache-control": "no-store" } });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* `POST` {@link LINK_CHILD_PARITY_PATH}: the Child's apply report (OCX-6). The one write the link
|
|
37
|
+
* accepts, handled here, before and apart from the read-only table, so it never widens what a
|
|
38
|
+
* read may do. It is admitted only for the link the admitting key belongs to, reads at most
|
|
39
|
+
* {@link LINK_CHILD_PARITY_MAX_BYTES} and changes nothing but the in-memory report.
|
|
40
|
+
*/
|
|
41
|
+
async function handleChildParityReport(req: Request, url: URL, admittedKeyId: string | undefined): Promise<Response> {
|
|
42
|
+
if (req.method !== "POST" || url.search !== "" || req.headers.has("upgrade")) return notFound();
|
|
43
|
+
if (!admittedKeyId) return refused(403, "link_key_required");
|
|
44
|
+
const declared = Number(req.headers.get("content-length") ?? "0");
|
|
45
|
+
if (Number.isFinite(declared) && declared > LINK_CHILD_PARITY_MAX_BYTES) return refused(413, "report_too_large");
|
|
46
|
+
let bytes: Uint8Array;
|
|
47
|
+
try {
|
|
48
|
+
const read = await readBoundedResponseBytes(new Response(req.body), {
|
|
49
|
+
maxBytes: LINK_CHILD_PARITY_MAX_BYTES,
|
|
50
|
+
signal: AbortSignal.timeout(REPORT_READ_TIMEOUT_MS),
|
|
51
|
+
});
|
|
52
|
+
if (read.oversized) return refused(413, "report_too_large");
|
|
53
|
+
bytes = read.bytes;
|
|
54
|
+
} catch {
|
|
55
|
+
return refused(400, "invalid_report");
|
|
56
|
+
}
|
|
57
|
+
let report: ReturnType<typeof parseLinkChildParityReport>;
|
|
58
|
+
try {
|
|
59
|
+
report = parseLinkChildParityReport(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)));
|
|
60
|
+
} catch {
|
|
61
|
+
report = null;
|
|
62
|
+
}
|
|
63
|
+
if (!report) return refused(400, "invalid_report");
|
|
64
|
+
let links: ReturnType<typeof readLinkStore>["links"];
|
|
65
|
+
try { links = readLinkStore(linkStorePath()).links; } catch { return refused(503, "link_store_unavailable"); }
|
|
66
|
+
if (!links.some(link => link.id === report.linkId && link.apiKeyId === admittedKeyId)) return refused(403, "link_mismatch");
|
|
67
|
+
homeLinkChildParity.record(report, new Set(links.map(link => link.id)));
|
|
68
|
+
return new Response(null, { status: 204, headers: { "cache-control": "no-store" } });
|
|
69
|
+
}
|
|
70
|
+
|
|
26
71
|
export async function handleLinkManagementRequest(
|
|
27
72
|
req: Request,
|
|
28
73
|
url: URL,
|
|
29
74
|
config: OcxConfig,
|
|
30
75
|
deps: ManagementApiDeps,
|
|
31
76
|
sessionControl?: ManagementSessionControl,
|
|
77
|
+
admittedKeyId?: string,
|
|
32
78
|
): Promise<Response> {
|
|
79
|
+
if (url.pathname === LINK_CHILD_PARITY_PATH) return handleChildParityReport(req, url, admittedKeyId);
|
|
33
80
|
if (!linkManagementRouteAllowed(url, req)) return notFound();
|
|
34
81
|
if (url.pathname === LINK_CODEX_SETTINGS_PATH) {
|
|
35
82
|
return Response.json(projectHomeCodexSettings(config, VERSION), { headers: { "cache-control": "no-store" } });
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
LINK_RELAY_AUTH_PATH, LINK_RELAY_AUTH_TIMEOUT_MS, LINK_RELAY_AUTH_VERSION,
|
|
4
4
|
LINK_RELAY_SESSION_HEADER, linkRelayProof, linkRelayProofMatches,
|
|
5
5
|
} from "../../link/relay-auth";
|
|
6
|
+
import { parseLinkPeerReport, type LinkPeerObservations } from "../../link/peer-report";
|
|
6
7
|
|
|
7
8
|
interface Session {
|
|
8
9
|
peer: string;
|
|
@@ -18,6 +19,8 @@ export interface LinkRelaySessionOptions {
|
|
|
18
19
|
fingerprints: (keyId: string, linkId: string) => readonly string[];
|
|
19
20
|
/** Tests can use a bounded shorter reservation; production uses five seconds. */
|
|
20
21
|
reservationMs?: number;
|
|
22
|
+
/** Where a verified challenge records the Child's report, and a known link's version mismatch. */
|
|
23
|
+
peers?: LinkPeerObservations;
|
|
21
24
|
}
|
|
22
25
|
|
|
23
26
|
/** One book per bound listener. A pending proof reserves the bind before the data request arrives. */
|
|
@@ -64,14 +67,20 @@ export function createLinkRelaySessions(options: LinkRelaySessionOptions) {
|
|
|
64
67
|
if (closing || req.method !== "GET" || sessions.size >= 256 || req.headers.has("upgrade")
|
|
65
68
|
|| req.headers.has("authorization") || req.headers.has("x-opencodex-api-key")) return refuse();
|
|
66
69
|
const q = url.searchParams;
|
|
67
|
-
if (q.
|
|
68
|
-
|| ["version", "key", "link", "nonce", "proof"].some(name => q.getAll(name).length !== 1)) return refuse();
|
|
70
|
+
if ([...q.keys()].length !== 5 || ["version", "key", "link", "nonce", "proof"].some(name => q.getAll(name).length !== 1)) return refuse();
|
|
69
71
|
const keyId = q.get("key")!, linkId = q.get("link")!, nonce = q.get("nonce")!;
|
|
72
|
+
if (q.get("version") !== LINK_RELAY_AUTH_VERSION) {
|
|
73
|
+
// Unverifiable, but only for a link and key this Home issued: status names the mismatch.
|
|
74
|
+
const version = q.get("version")!;
|
|
75
|
+
if (/^[1-9]\d{0,3}$/.test(version) && options.fingerprints(keyId, linkId).length > 0) options.peers?.refuse(linkId, version);
|
|
76
|
+
return refuse();
|
|
77
|
+
}
|
|
70
78
|
const peer = peerOf(req, server as Server<unknown>);
|
|
71
79
|
if (!peer || sessions.has(nonce)) return refuse();
|
|
72
80
|
const fingerprint = options.fingerprints(keyId, linkId).find(candidate =>
|
|
73
81
|
linkRelayProofMatches(q.get("proof"), linkRelayProof(candidate, "caller", keyId, linkId, nonce)));
|
|
74
82
|
if (!fingerprint) return refuse();
|
|
83
|
+
options.peers?.observe(linkId, LINK_RELAY_AUTH_VERSION, parseLinkPeerReport(req.headers));
|
|
75
84
|
const release = acquire();
|
|
76
85
|
const timer = setTimeout(() => { consume(nonce)?.release(); }, reservationMs);
|
|
77
86
|
sessions.set(nonce, { peer, keyId, linkId, fingerprint, release, timer, expiresAt: Date.now() + reservationMs });
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
} from "./link-listener";
|
|
16
16
|
import { createLinkSupervisor, type LinkSupervisor } from "../../link/supervisor";
|
|
17
17
|
import { linkManagementRouteAllowed } from "../../link/management-routes";
|
|
18
|
+
import { linkChildParityRouteAllowed } from "../../link/child-parity";
|
|
18
19
|
import { saveConfigPreservingClaudeCode } from "../../config/live-reconcile";
|
|
19
20
|
import { reconcileLiveStateStores } from "../../lib/state-store-registrations";
|
|
20
21
|
import { startJevQuotaActivation } from "../../combos/jev-quota-activation";
|
|
@@ -38,6 +39,7 @@ export interface OptionalListenerSet<T> {
|
|
|
38
39
|
linkAdmissionKeyIds(): ReadonlySet<string>;
|
|
39
40
|
onAuthenticatedCatalog(listener: (apiKeyId: string) => void): () => void;
|
|
40
41
|
notifyAuthenticatedCatalog(apiKeyId: string): void;
|
|
42
|
+
peerObservation(linkId: string): import("../../link/peer-report").LinkPeerEntry;
|
|
41
43
|
status(): LinkListenerStatus;
|
|
42
44
|
linkStatus(): LinkListenerStatus;
|
|
43
45
|
linkSupervisor(): LinkSupervisor;
|
|
@@ -83,11 +85,14 @@ export function createOptionalListenerSet<T>(linkDeps: LinkListenerDeps = {}): O
|
|
|
83
85
|
},
|
|
84
86
|
// The Home's link listener admits the data plane plus the read-only management relay
|
|
85
87
|
// (#6957). The Child's own ingress keeps the data-only table, so a local caller on the Child
|
|
86
|
-
// cannot reach the Home's management reads without the Child's dashboard session.
|
|
87
|
-
|
|
88
|
+
// cannot reach the Home's management reads without the Child's dashboard session. The one
|
|
89
|
+
// write is the Child's apply report (OCX-6), which changes only an in-memory status field.
|
|
90
|
+
linkRouteAllowed: (url, req) => linkRouteAllowed(url, req) || linkManagementRouteAllowed(url, req)
|
|
91
|
+
|| linkChildParityRouteAllowed(url, req),
|
|
88
92
|
linkAdmissionKeyIds: () => linkListener.linkAdmissionKeyIds(),
|
|
89
93
|
onAuthenticatedCatalog: listener => linkListener.onAuthenticatedCatalog(listener),
|
|
90
94
|
notifyAuthenticatedCatalog: apiKeyId => linkListener.notifyAuthenticatedCatalog(apiKeyId),
|
|
95
|
+
peerObservation: linkId => linkListener.peerObservation(linkId),
|
|
91
96
|
status: () => linkListener.status(),
|
|
92
97
|
linkStatus: () => linkListener.status(),
|
|
93
98
|
linkSupervisor: () => supervisor,
|
|
@@ -204,11 +204,13 @@ import type { ReadinessGate } from "../readiness";
|
|
|
204
204
|
import type { WorkflowRefusalLog } from "../workflow-refusal";
|
|
205
205
|
|
|
206
206
|
import { readyProtocolMetadata } from "../../remote/protocol";
|
|
207
|
+
import { homeInstanceId, stampServedBy } from "../../remote/served-by";
|
|
207
208
|
import { modelCapabilityFields } from "../models-capabilities";
|
|
208
209
|
import { createWebsocketHandler } from "./websocket-handler";
|
|
209
210
|
import { reasoningArchiveErrorResponse } from "../responses/reasoning-archive-error";
|
|
210
211
|
import { withGrokSessionIdentity } from "../../grok/session-identity";
|
|
211
212
|
import { withCallerSessionIdentity } from "../caller-session-identity";
|
|
213
|
+
import { agentAssertionUnsupportedResponse, hasAgentAssertionAuthorization } from "../../lib/authorization-scheme";
|
|
212
214
|
|
|
213
215
|
export type ServerIngress = "public" | "unauthenticated-loopback" | "hub-management" | "claude-intercept" | "hub-link";
|
|
214
216
|
|
|
@@ -370,6 +372,10 @@ export function createServeOptions(ctx: ServeOptionsContext) {
|
|
|
370
372
|
: ingress === "unauthenticated-loopback" || ingress === "claude-intercept"
|
|
371
373
|
? loopbackPolicy()
|
|
372
374
|
: config;
|
|
375
|
+
// Codex runtime agent assertion_only (Authorization: AgentAssertion) is refused before
|
|
376
|
+
// admission or routing: it is never a bearer, never an admission secret, and no pool or
|
|
377
|
+
// routed credential may stand in for it.
|
|
378
|
+
if (hasAgentAssertionAuthorization(req.headers)) return withCors(agentAssertionUnsupportedResponse(), req, policy);
|
|
373
379
|
if (ingress === "hub-link") {
|
|
374
380
|
const admission = resolveApiAuth(req, policy);
|
|
375
381
|
if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy);
|
|
@@ -380,7 +386,10 @@ export function createServeOptions(ctx: ServeOptionsContext) {
|
|
|
380
386
|
// the shared table in src/link/management-routes.ts; never reachable on another ingress.
|
|
381
387
|
if (isLinkManagementPath(requestUrl.pathname)) {
|
|
382
388
|
const { handleLinkManagementRequest } = await import("./link-management");
|
|
383
|
-
return withCors(await handleLinkManagementRequest(
|
|
389
|
+
return withCors(await handleLinkManagementRequest(
|
|
390
|
+
req, requestUrl, config, requestManagementApiDeps, managementSessionControl,
|
|
391
|
+
admission.kind === "configured" ? admission.keyId : undefined,
|
|
392
|
+
), req, policy);
|
|
384
393
|
}
|
|
385
394
|
}
|
|
386
395
|
const url = requestUrl;
|
|
@@ -408,11 +417,13 @@ export function createServeOptions(ctx: ServeOptionsContext) {
|
|
|
408
417
|
const message = "OpenCodex package files changed while this proxy was running; it restarts on its own, or run 'ocx restart' ('ocx service restart' for a background service).";
|
|
409
418
|
const fencedPort = ctx.boundPort ?? requestServer.port ?? listenPort;
|
|
410
419
|
const fencedHealth = url.pathname === "/healthz";
|
|
420
|
+
const fencedInstanceId = fencedHealth ? homeInstanceId() : undefined;
|
|
411
421
|
const response = fencedHealth || readyzPath !== undefined
|
|
412
422
|
? jsonResponse({
|
|
413
423
|
status: "restart_required",
|
|
414
424
|
service: "opencodex",
|
|
415
425
|
version: VERSION,
|
|
426
|
+
...(fencedInstanceId ? { instanceId: fencedInstanceId } : {}),
|
|
416
427
|
uptime: process.uptime(),
|
|
417
428
|
pid: process.pid,
|
|
418
429
|
port: fencedPort,
|
|
@@ -623,10 +634,13 @@ export function createServeOptions(ctx: ServeOptionsContext) {
|
|
|
623
634
|
if (url.pathname === "/healthz" && req.method === "GET") {
|
|
624
635
|
// service/pid/port let CLI liveness reject foreign 200s and verify pid identity.
|
|
625
636
|
const healthPort = ctx.server.port ?? listenPort;
|
|
637
|
+
// Opaque Home instance id (src/remote/served-by.ts); omitted until the identity exists.
|
|
638
|
+
const instanceId = homeInstanceId();
|
|
626
639
|
const response = jsonResponse({
|
|
627
640
|
status: "ok",
|
|
628
641
|
service: "opencodex",
|
|
629
642
|
version: VERSION,
|
|
643
|
+
...(instanceId ? { instanceId } : {}),
|
|
630
644
|
uptime: process.uptime(),
|
|
631
645
|
pid: process.pid,
|
|
632
646
|
port: healthPort,
|
|
@@ -1977,5 +1991,8 @@ export function createServeOptions(ctx: ServeOptionsContext) {
|
|
|
1977
1991
|
},
|
|
1978
1992
|
websocket: createWebsocketHandler(ctx, requestMetrics),
|
|
1979
1993
|
} as const;
|
|
1980
|
-
|
|
1994
|
+
const routeFetch = serveOptions.fetch;
|
|
1995
|
+
// x-opencodex-served-by on GET /v1/models, whichever branch answered (src/remote/served-by.ts).
|
|
1996
|
+
return { ...serveOptions, fetch: async (req: Request, requestServer: Server<WsData>): Promise<Response> =>
|
|
1997
|
+
stampServedBy(req.method, codexCompatibleUrl(req.url).pathname, await routeFetch(req, requestServer)) } as const;
|
|
1981
1998
|
}
|
package/src/server/live.ts
CHANGED
|
@@ -88,6 +88,10 @@ export const LIVE_SIDEBAND_API_ROOT = "https://api.openai.com/v1";
|
|
|
88
88
|
* upstream the caller was addressing, so there is nothing to scope it away from. That is not
|
|
89
89
|
* true of the images sidecar, which strips it deliberately and keeps doing so.
|
|
90
90
|
*
|
|
91
|
+
* `voice-session-id` is the client-generated voice session id Codex 0.164 forwards on WebSocket,
|
|
92
|
+
* WebRTC and existing-call transports (openai/codex#53784). It is a separate identity from the
|
|
93
|
+
* realtime session id carried in `x-session-id`, so neither may stand in for the other.
|
|
94
|
+
*
|
|
91
95
|
* Every name here is relayed only when the caller sent it. Nothing on this list is invented,
|
|
92
96
|
* which is what keeps a caller that omits one byte-identical upstream. Auth headers
|
|
93
97
|
* (`authorization`, `chatgpt-account-id`) stay proxy-owned and are never taken from this list.
|
|
@@ -100,6 +104,7 @@ export const LIVE_CLIENT_PROTOCOL_HEADERS = [
|
|
|
100
104
|
"originator",
|
|
101
105
|
"x-oai-attestation",
|
|
102
106
|
"x-codex-turn-metadata",
|
|
107
|
+
"voice-session-id",
|
|
103
108
|
] as const;
|
|
104
109
|
|
|
105
110
|
/**
|
|
@@ -156,7 +156,7 @@ export interface ManagementApiDeps {
|
|
|
156
156
|
codexPromptPaths?: CodexPromptPaths;
|
|
157
157
|
/** Link seams are getters so the optional listener and supervisor are singletons. */
|
|
158
158
|
linkSupervisor?: () => LinkSupervisor;
|
|
159
|
-
linkListener?: () => Pick<LinkListenerLifecycle<unknown>, "ensureStarted" | "status" | "close" | "onAuthenticatedCatalog"> & Partial<Pick<import("../index/optional-listeners").OptionalListenerSet<unknown>, "ensureClaudeIntercept" | "claudeInterceptOutcome">>;
|
|
159
|
+
linkListener?: () => Pick<LinkListenerLifecycle<unknown>, "ensureStarted" | "status" | "close" | "onAuthenticatedCatalog"> & Partial<Pick<LinkListenerLifecycle<unknown>, "peerObservation">> & Partial<Pick<import("../index/optional-listeners").OptionalListenerSet<unknown>, "ensureClaudeIntercept" | "claudeInterceptOutcome">>;
|
|
160
160
|
readLinkStore?: () => LinkStore;
|
|
161
161
|
writeLinkStore?: (store: LinkStore) => void;
|
|
162
162
|
linkKnownHostsPath?: () => string;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `GET|PUT /api/fallback`: the OCX-11 fallback state (`ocx.fallback/1`) and its one scoped write.
|
|
3
|
+
*
|
|
4
|
+
* GET is a passive read. A Remote Link Child reaches it through the read-only link relay
|
|
5
|
+
* (`LINK_MANAGEMENT_READ_PATHS`); there it drops link aliases (`buildFallbackState` passive). PUT
|
|
6
|
+
* runs on the Home only: a Child's relay and CLI refuse writes before sending.
|
|
7
|
+
*/
|
|
8
|
+
import type { ManagementContext } from "./context";
|
|
9
|
+
import { jsonResponse } from "../auth-cors";
|
|
10
|
+
import { mutatePersistedConfig } from "../../config";
|
|
11
|
+
import { isPassiveRead } from "../../lib/passive-read";
|
|
12
|
+
import { readLinkStore, type LinkRecord } from "../../link/store";
|
|
13
|
+
import { linkStorePath } from "../../link/paths";
|
|
14
|
+
import { applyFallbackWrite, buildFallbackState, parseFallbackWrites } from "../../routing/fallback-state";
|
|
15
|
+
import { managementBodyTooLargeResponse, readManagementJsonBody } from "./body";
|
|
16
|
+
|
|
17
|
+
function links(ctx: ManagementContext): LinkRecord[] {
|
|
18
|
+
try {
|
|
19
|
+
return (ctx.deps.readLinkStore?.() ?? readLinkStore(linkStorePath())).links;
|
|
20
|
+
} catch {
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function view(ctx: ManagementContext): Response {
|
|
26
|
+
const response = jsonResponse(buildFallbackState(ctx.config, { links: links(ctx), passive: isPassiveRead() }), 200, ctx.req, ctx.config);
|
|
27
|
+
response.headers.set("cache-control", "no-store");
|
|
28
|
+
return response;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function handleFallbackRoutes(ctx: ManagementContext): Promise<Response | null> {
|
|
32
|
+
const { req, url, config } = ctx;
|
|
33
|
+
if (url.pathname === "/api/fallback" && req.method === "GET") return view(ctx);
|
|
34
|
+
if (url.pathname === "/api/fallback" && req.method === "PUT") return await writeFallback(ctx);
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function writeFallback(ctx: ManagementContext): Promise<Response> {
|
|
39
|
+
const { req, config } = ctx;
|
|
40
|
+
let body: unknown;
|
|
41
|
+
try {
|
|
42
|
+
body = await readManagementJsonBody(req);
|
|
43
|
+
} catch (error) {
|
|
44
|
+
return managementBodyTooLargeResponse(error, req, config)
|
|
45
|
+
?? jsonResponse({ error: "invalid JSON body", code: "invalid_request" }, 400, req, config);
|
|
46
|
+
}
|
|
47
|
+
const parsed = parseFallbackWrites(body);
|
|
48
|
+
if (!parsed.ok) return jsonResponse({ error: parsed.error, code: "invalid_request" }, 400, req, config);
|
|
49
|
+
const known = links(ctx);
|
|
50
|
+
for (const write of parsed.writes) {
|
|
51
|
+
if (write.scope !== "device") continue;
|
|
52
|
+
// A link alias names its link; otherwise the key must be a known link id or data key id.
|
|
53
|
+
const byAlias = known.find(link => link.alias === write.key);
|
|
54
|
+
if (byAlias) write.key = byAlias.id;
|
|
55
|
+
// Clearing stays possible for a device that is gone (a revoked link, a deleted data key):
|
|
56
|
+
// its entry still lists in the state, and clearing a missing entry is a no-op.
|
|
57
|
+
if (write.value === null) continue;
|
|
58
|
+
const exists = known.some(link => link.id === write.key)
|
|
59
|
+
|| (config.apiKeys ?? []).some(entry => entry.id === write.key);
|
|
60
|
+
if (!exists) {
|
|
61
|
+
return jsonResponse({ error: "unknown device: use a link id or alias from ocx link status, or a Hub data key id", code: "unknown_device" }, 404, req, config);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const outcome = mutatePersistedConfig(persisted => {
|
|
65
|
+
let changed = false;
|
|
66
|
+
for (const write of parsed.writes) changed = applyFallbackWrite(persisted, write) || changed;
|
|
67
|
+
return { changed, value: persisted.fallback === undefined ? undefined : structuredClone(persisted.fallback) };
|
|
68
|
+
});
|
|
69
|
+
if (outcome.status === "unavailable") {
|
|
70
|
+
return jsonResponse({ error: "config.json is " + outcome.reason + "; fallback settings were not saved", code: "config_unavailable" }, 409, req, config);
|
|
71
|
+
}
|
|
72
|
+
if (outcome.value === undefined) delete config.fallback;
|
|
73
|
+
else config.fallback = outcome.value;
|
|
74
|
+
return view(ctx);
|
|
75
|
+
}
|
|
@@ -3,6 +3,8 @@ import { tmpdir } from "node:os";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { assertSshAlias, buildExecArgv, buildFingerprintArgv, buildProbeArgv, REMOTE_COMMAND_NOT_FOUND, REMOTE_PLATFORM_UNSUPPORTED, remoteOcxArgv } from "../../link/ssh-argv";
|
|
5
5
|
import { parseFingerprintLine } from "../../link/fingerprint";
|
|
6
|
+
import { detectRemotePlatform, isWindowsShellRefusal, WINDOWS_CHILD_REFUSAL } from "../../link/remote-platform";
|
|
7
|
+
import type { SshRunner } from "../../link/ssh-runner";
|
|
6
8
|
import { LINK_CATALOG_MAX_BYTES, LINK_RELAY_VERIFY_MS, isChildHealth, linkCommandBudget, linkRestartBudget, parseChildHttp, remoteChildHttpArgv, REMOTE_CURL_PREFLIGHT } from "../../link/remote-probe";
|
|
7
9
|
import { newLinkId, type LinkStore } from "../../link/store";
|
|
8
10
|
import { SshRunnerError, boundHint, sshFailureHint, sshRunnerErrorHint, type SshRunResult } from "../../link/ssh-runner";
|
|
@@ -24,11 +26,23 @@ async function serialized(state: LinkRouteState, alias: string, action: () => Pr
|
|
|
24
26
|
}
|
|
25
27
|
|
|
26
28
|
function remoteFailure(result: SshRunResult, code: string, message: string): Response | null {
|
|
27
|
-
if (result.code === REMOTE_PLATFORM_UNSUPPORTED
|
|
29
|
+
if (result.code === REMOTE_PLATFORM_UNSUPPORTED || (result.code !== 0 && isWindowsShellRefusal(result.stderr))) return windowsRefusal();
|
|
28
30
|
if (result.code === REMOTE_COMMAND_NOT_FOUND) return fail("remote_ocx_missing", "ocx was not found on the remote host.", 502, sshFailureHint(result.stderr));
|
|
29
31
|
return result.code === 0 ? null : fail(code, message, 502, sshFailureHint(result.stderr));
|
|
30
32
|
}
|
|
31
33
|
|
|
34
|
+
function windowsRefusal(): Response {
|
|
35
|
+
return fail("remote_platform_unsupported", WINDOWS_CHILD_REFUSAL, 409);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Pre-issuance Windows gate for a failed remote command: exit 125, the English cmd/PowerShell
|
|
40
|
+
* refusal, or (for localized shells) the fixed %OS% probe against the same host key.
|
|
41
|
+
*/
|
|
42
|
+
async function windowsChild(runner: SshRunner, alias: string, knownHostsFile: string, result: SshRunResult): Promise<boolean> {
|
|
43
|
+
return result.code !== 0 && await detectRemotePlatform(runner, alias, knownHostsFile, result) === "windows";
|
|
44
|
+
}
|
|
45
|
+
|
|
32
46
|
export async function probe(ctx: ManagementContext, state: LinkRouteState): Promise<Response> {
|
|
33
47
|
const value = await readManagementJsonBodyOr(ctx.req, null);
|
|
34
48
|
const body = exactBody(value, ["alias"]) ?? exactBody(value, ["alias", "includeVersion"]);
|
|
@@ -44,7 +58,8 @@ export async function probe(ctx: ManagementContext, state: LinkRouteState): Prom
|
|
|
44
58
|
const started = now();
|
|
45
59
|
const result = await runner.run(buildProbeArgv({ alias, tempKnownHostsFile: path }), { timeoutMs: 30_000 });
|
|
46
60
|
const latency = Math.max(0, now() - started);
|
|
47
|
-
|
|
61
|
+
// cmd.exe and PowerShell refuse the POSIX `true` the probe runs; say Windows, not "probe failed".
|
|
62
|
+
if (result.code !== 0) return await windowsChild(runner, alias, path, result) ? windowsRefusal() : fail("probe_failed", "SSH host probing failed.", 502, sshFailureHint(result.stderr));
|
|
48
63
|
const fp = await runner.run(buildFingerprintArgv(path), { timeoutMs: 10_000 });
|
|
49
64
|
if (fp.code !== 0) return fail("fingerprint_failed", "The SSH host fingerprint could not be read.", 502, sshFailureHint(fp.stderr));
|
|
50
65
|
const parsed = parseFingerprintLine(fp.stdout.trim());
|
|
@@ -53,6 +68,7 @@ export async function probe(ctx: ManagementContext, state: LinkRouteState): Prom
|
|
|
53
68
|
let ocxVersion: string | undefined;
|
|
54
69
|
if (body.includeVersion === true) {
|
|
55
70
|
const versionResult = await runner.run(buildExecArgv({ alias, argv: remoteOcxArgv(["--version"]), knownHostsFile: path }), { timeoutMs: 30_000 });
|
|
71
|
+
if (await windowsChild(runner, alias, path, versionResult)) return windowsRefusal();
|
|
56
72
|
const denied = remoteFailure(versionResult, "version_probe_failed", "The remote ocx version could not be read.");
|
|
57
73
|
if (denied) return denied;
|
|
58
74
|
const parsedVersion = parseVersion(versionResult.stdout);
|
|
@@ -84,7 +100,9 @@ export async function confirmHost(ctx: ManagementContext, state: LinkRouteState)
|
|
|
84
100
|
writeFileSync(path, pending.knownHostLine + "\n", { mode: 0o600 });
|
|
85
101
|
try {
|
|
86
102
|
const started = now();
|
|
87
|
-
const
|
|
103
|
+
const runner = runnerFor(ctx);
|
|
104
|
+
const result = await runner.run(buildExecArgv({ alias, argv: remoteOcxArgv(["--version"]), knownHostsFile: path }), { timeoutMs: 30_000 });
|
|
105
|
+
if (await windowsChild(runner, alias, path, result)) return windowsRefusal();
|
|
88
106
|
const denied = remoteFailure(result, "version_probe_failed", "The remote ocx version could not be confirmed.");
|
|
89
107
|
if (denied) return denied;
|
|
90
108
|
const version = parseVersion(result.stdout);
|
|
@@ -27,6 +27,8 @@ export interface LinkRouteListener {
|
|
|
27
27
|
status(): { state: "off" | "listening" | "failed"; port: number | null; reason: string | null };
|
|
28
28
|
close(): Promise<void>;
|
|
29
29
|
onAuthenticatedCatalog(listener: (apiKeyId: string) => void): () => void;
|
|
30
|
+
/** Optional so route fakes stay small; without it every link reports an unknown peer. */
|
|
31
|
+
peerObservation?(linkId: string): import("../../link/peer-report").LinkPeerEntry;
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
export interface LinkRouteState {
|
|
@@ -6,7 +6,11 @@ import { clearCompensationFailed, compensationPath, readCompensation } from "../
|
|
|
6
6
|
import { loadHostCandidates } from "../../link/ssh-config";
|
|
7
7
|
import { newLinkId, type LinkStore } from "../../link/store";
|
|
8
8
|
import { sshFailureHint, sshRunnerErrorHint } from "../../link/ssh-runner";
|
|
9
|
-
import { projectLinkStatus, type LinkStatusDto } from "../../link/status-projection";
|
|
9
|
+
import { attachLinkPeers, projectLinkStatus, type LinkStatusDto } from "../../link/status-projection";
|
|
10
|
+
import { homeLinkChildParity } from "../../link/child-parity";
|
|
11
|
+
import { localLinkPeer } from "../../link/compatibility";
|
|
12
|
+
import { packageVersion } from "../../lib/package-version";
|
|
13
|
+
import { buildPeerFeatures } from "../../remote/features";
|
|
10
14
|
import { isLinkPort } from "../../link/ports";
|
|
11
15
|
import { joinHome, type ClientLinkJoinDeps } from "../../client/link-join";
|
|
12
16
|
import { clientLinkStatusIdentity } from "../../client/link-status";
|
|
@@ -268,6 +272,7 @@ export async function handleLinkRoutes(ctx: ManagementContext, suppliedState?: L
|
|
|
268
272
|
store, state.supervisor.status(), listenerStatus, ctx.config, readCompensation(),
|
|
269
273
|
ctx.config.runtimeRole === "client" ? clientLinkTunnelStatus() : null,
|
|
270
274
|
ctx.config.runtimeRole === "client" && ctx.config.client?.link ? clientLinkStatusIdentity(ctx.config.client.link.linkId) : null,
|
|
275
|
+
linkId => homeLinkChildParity.get(linkId),
|
|
271
276
|
);
|
|
272
277
|
for (const link of dto.links) {
|
|
273
278
|
const failure = state.compensationFailures?.get(link.id);
|
|
@@ -277,6 +282,12 @@ export async function handleLinkRoutes(ctx: ManagementContext, suppliedState?: L
|
|
|
277
282
|
const failure = state.compensationFailures?.get(store.links[0].id);
|
|
278
283
|
if (failure) Object.assign(dto.child, { state: "failed" as const, since: failure.since, reason: failure.reason });
|
|
279
284
|
}
|
|
285
|
+
// Opt-in: an older `ocx link status` validates the document key by key and would reject the
|
|
286
|
+
// added fields, so only a caller that asks for compatibility gets `local` and `peer`.
|
|
287
|
+
if (url.searchParams.get("compatibility") === "1") {
|
|
288
|
+
attachLinkPeers(dto, localLinkPeer(packageVersion("0.0.0"), buildPeerFeatures()),
|
|
289
|
+
linkId => state.listener.peerObservation?.(linkId) ?? { observed: null, refused: null });
|
|
290
|
+
}
|
|
280
291
|
// A dashboard session also learns why it cannot join as a Child. The admin-token answer stays
|
|
281
292
|
// the exact K16 document that `ocx link status` validates key by key.
|
|
282
293
|
const denial = ctx.principal === "gui-session" ? joinDenied(ctx) : null;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `GET /api/quota-pool`: the `ocx.quota-pool/1` read GOAT consumes (see src/quota-pool/).
|
|
3
|
+
*
|
|
4
|
+
* Same management authorization as the neighbouring reads; a Remote Link Child reaches it through
|
|
5
|
+
* the read-only link relay (`LINK_MANAGEMENT_READ_PATHS`) and a Hub client through its hub relay.
|
|
6
|
+
* The read is always passive: there is no refresh form, so nothing here can make the Home probe.
|
|
7
|
+
*/
|
|
8
|
+
import type { ManagementContext } from "./context";
|
|
9
|
+
import { jsonResponse } from "../auth-cors";
|
|
10
|
+
|
|
11
|
+
export async function handleQuotaPoolRoutes(ctx: ManagementContext): Promise<Response | null> {
|
|
12
|
+
const { req, url, config } = ctx;
|
|
13
|
+
// Any query (including a stray refresh=1) is ignored: the read has no active form.
|
|
14
|
+
if (url.pathname === "/api/quota-pool" && req.method === "GET") {
|
|
15
|
+
const { readQuotaPool } = await import("../../quota-pool/assemble");
|
|
16
|
+
const result = await readQuotaPool(config);
|
|
17
|
+
const response = result.ok
|
|
18
|
+
? jsonResponse(result.pool, 200, req, config)
|
|
19
|
+
: jsonResponse({ error: result.error }, 503, req, config);
|
|
20
|
+
response.headers.set("cache-control", "no-store");
|
|
21
|
+
return response;
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
@@ -370,6 +370,9 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [
|
|
|
370
370
|
{ method: "GET", path: "/api/provider-context-caps", module: "server/management/provider-routes", mutates: false },
|
|
371
371
|
{ method: "GET", path: "/api/provider-presets", module: "server/management/provider-routes", mutates: false },
|
|
372
372
|
{ method: "GET", path: "/api/provider-quotas", module: "server/management/provider-routes", mutates: false },
|
|
373
|
+
{ method: "GET", path: "/api/quota-pool", module: "server/management/quota-pool-routes", mutates: false, exempt: { reason: "deferred-verb", why: "The ocx.quota-pool/1 read is a machine contract GOAT consumes over management and the link relay; the CLI shows the same caches through ocx provider quota and ocx account.", owner: "ocx-1 quota pool", ownerDoc: "structure/quota-pool.md" } },
|
|
374
|
+
{ method: "GET", path: "/api/fallback", module: "server/management/fallback-routes", mutates: false },
|
|
375
|
+
{ method: "PUT", path: "/api/fallback", module: "server/management/fallback-routes", mutates: true },
|
|
373
376
|
{ method: "GET", path: "/api/provider-request-pacing", module: "server/management/provider-key-pacing-routes", mutates: false },
|
|
374
377
|
{ method: "GET", path: "/api/providers", module: "server/management/provider-routes", mutates: false },
|
|
375
378
|
{ method: "PATCH", path: "/api/providers", module: "server/management/provider-routes", mutates: true },
|
|
@@ -161,6 +161,20 @@ async function handleLowQuotaRoutesOnDemand(ctx: ManagementContext): Promise<Res
|
|
|
161
161
|
return handleLowQuotaRoutes(ctx);
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
+
/** Lazy for the same reason: the quota-pool assembler pulls every quota cache reader. */
|
|
165
|
+
async function handleQuotaPoolRoutesOnDemand(ctx: ManagementContext): Promise<Response | null> {
|
|
166
|
+
if (!pathInManagementNamespace(ctx.url.pathname, "/api/quota-pool", false)) return null;
|
|
167
|
+
const { handleQuotaPoolRoutes } = await import("./management/quota-pool-routes");
|
|
168
|
+
return handleQuotaPoolRoutes(ctx);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Lazy: the OCX-11 fallback state view and its write live off the dashboard's common path. */
|
|
172
|
+
async function handleFallbackRoutesOnDemand(ctx: ManagementContext): Promise<Response | null> {
|
|
173
|
+
if (!pathInManagementNamespace(ctx.url.pathname, "/api/fallback", false)) return null;
|
|
174
|
+
const { handleFallbackRoutes } = await import("./management/fallback-routes");
|
|
175
|
+
return handleFallbackRoutes(ctx);
|
|
176
|
+
}
|
|
177
|
+
|
|
164
178
|
/**
|
|
165
179
|
* Lazy like the Lab and routing-profile handlers, and for the same recorded reason: this file is
|
|
166
180
|
* mounted for every dashboard request, so a static import would put the workflow-budget ledger
|
|
@@ -351,6 +365,8 @@ export async function handleManagementAPI(
|
|
|
351
365
|
?? (await handleRequestHistoryRoutes(ctx))
|
|
352
366
|
?? (await handleQuotaResetRoutesOnDemand(ctx))
|
|
353
367
|
?? (await handleLowQuotaRoutesOnDemand(ctx))
|
|
368
|
+
?? (await handleQuotaPoolRoutesOnDemand(ctx))
|
|
369
|
+
?? (await handleFallbackRoutesOnDemand(ctx))
|
|
354
370
|
?? (await handleWorkflowBudgetRoutesOnDemand(ctx))
|
|
355
371
|
?? (await handleCodexAgentRoleRoutesOnDemand(ctx))
|
|
356
372
|
?? (await handleProtocolRoutesOnDemand(ctx))
|