@bitkyc08/opencodex 2.90.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-DMps4OLk.css +1 -0
- package/gui/dist/assets/{App-DgxkE6Md.js → App-sUf_8afJ.js} +18 -18
- package/gui/dist/assets/{Tray-DbTNc_88.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/anthropic.ts +2 -5
- package/src/adapters/claude-cli/usage-admission.ts +14 -0
- 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/passthrough.ts +2 -1
- package/src/adapters/openai-responses/request-strips.ts +92 -0
- package/src/adapters/openai-responses/transport.ts +6 -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/inbound.ts +20 -2
- package/src/chat/outbound.ts +47 -8
- package/src/chat/reasoning-details.ts +137 -0
- package/src/cli/capabilities-agents-routing.ts +53 -3
- package/src/cli/capabilities-base.ts +15 -2
- package/src/cli/capabilities-observe-system.ts +4 -4
- package/src/cli/codex-cli-update.ts +8 -0
- package/src/cli/codex-shim-autorestore.ts +5 -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/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/launcher-context.ts +49 -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 +123 -35
- package/src/cli/provider.ts +46 -3
- 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/cli/usage-compare.ts +68 -12
- 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-ingress.ts +1 -1
- package/src/client/link-parity-reporter.ts +107 -0
- package/src/client/link-readiness.ts +97 -0
- package/src/client/link-relay-transport.ts +84 -9
- package/src/client/link-relay.ts +98 -15
- package/src/client/link-tunnel.ts +45 -18
- package/src/client/machine-listener.ts +103 -3
- package/src/client/runtime-restart.ts +127 -0
- package/src/client/runtime.ts +155 -25
- package/src/codex/account-store.ts +17 -3
- package/src/codex/app-server-restart-service.ts +6 -5
- package/src/codex/auth-context.ts +10 -6
- package/src/codex/catalog/effort.ts +9 -2
- package/src/codex/catalog/gather-capture.ts +4 -2
- package/src/codex/catalog/parsing.ts +4 -0
- 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/desktop-compatibility/windows-key-protection.ts +18 -6
- package/src/codex/pool-refresh-backoff.ts +3 -1
- 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/codex/user-identity.ts +35 -6
- package/src/codex/windows-current-sid.ts +162 -0
- package/src/combos/index.ts +1 -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/resolve.ts +12 -0
- package/src/combos/types.ts +19 -0
- package/src/config/diagnostics.ts +5 -0
- package/src/config/load-degrade.ts +2 -0
- package/src/config/provider-validation.ts +7 -0
- package/src/config/schema/config-schema.ts +3 -0
- package/src/config/schema/fallback-policy.ts +40 -0
- package/src/config/schema/leaf-validators.ts +1 -0
- package/src/generated/compatibility-version.json +375 -195
- package/src/lib/authorization-scheme.ts +95 -0
- package/src/lib/decision-quota-sink.ts +10 -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/lib/translator-budget.ts +4 -4
- package/src/lib/upstream-retry.ts +4 -1
- 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/diagnostics.ts +193 -0
- package/src/link/management-routes.ts +2 -0
- package/src/link/peer-report.ts +100 -0
- package/src/link/relay-auth.ts +6 -0
- package/src/link/remote-platform.ts +78 -0
- package/src/link/ssh-argv.ts +19 -0
- package/src/link/status-projection.ts +63 -2
- 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 +16 -1
- package/src/oauth/kimi.ts +2 -0
- package/src/oauth/model-discovery-auth.ts +5 -0
- package/src/oauth/nous.ts +4 -1
- package/src/oauth/store.ts +6 -2
- package/src/oauth/token-guardian.ts +7 -0
- package/src/oauth/types.ts +9 -0
- package/src/oauth/workbuddy.ts +139 -0
- 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/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/mistral-transport.ts +37 -4
- 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 +27 -22
- 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/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/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 +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 +7 -5
- package/src/server/background-lifecycle.ts +4 -0
- package/src/server/chat-completions.ts +22 -2
- 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/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 +20 -7
- package/src/server/index/optional-listeners.ts +11 -2
- package/src/server/index/serve-options.ts +19 -2
- package/src/server/live.ts +5 -0
- package/src/server/local-management-read-client.ts +3 -1
- package/src/server/management/combo-routes.ts +10 -4
- 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 +16 -2
- package/src/server/management/provider-routes.ts +1 -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/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/agent-task-recovery.ts +1 -1
- package/src/server/responses/anthropic-thinking-recovery.ts +1 -0
- package/src/server/responses/compact.ts +12 -4
- package/src/server/responses/core-auth.ts +11 -1
- package/src/server/responses/core-combo.ts +125 -32
- 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/encrypted-payload.ts +3 -3
- package/src/server/responses/jev-combo-choice.ts +39 -0
- package/src/server/responses/jev-session-pin.ts +203 -0
- package/src/server/responses/main-hard-lock-routing.ts +54 -2
- package/src/server/responses/passthrough-delivery.ts +4 -0
- package/src/server/responses/policy-fallback.ts +44 -1
- package/src/server/responses/request-prepare.ts +19 -6
- 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/restart-replacement.ts +50 -2
- package/src/server/sse-payload-rewrite.ts +14 -1
- package/src/server/ws-bridge.ts +5 -0
- package/src/service/cli.ts +25 -2
- package/src/service/definition.ts +20 -8
- package/src/service/diagnostics.ts +20 -6
- package/src/service/status-json.ts +116 -0
- package/src/types/config.ts +13 -0
- package/src/types/fallback.ts +38 -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/App-dnkJ85te.css +0 -1
- package/gui/dist/assets/index-TmaHa54u.js +0 -94
- package/gui/dist/assets/quota-summary-B16x5DZZ.js +0 -1
|
@@ -40,6 +40,9 @@ import { evidenceFromBody } from "../routing/request-evidence";
|
|
|
40
40
|
import { resolveWireProtocolOverride } from "./adapter-resolve";
|
|
41
41
|
import { resolveOpenCodeGoTransport } from "../providers/opencode-go-transport";
|
|
42
42
|
import {
|
|
43
|
+
assignNativeLogConversationId,
|
|
44
|
+
fillHermesLogConversationFallback,
|
|
45
|
+
nativeLogConversationIdFromHeaders,
|
|
43
46
|
getOrAllocateRequestSessionLane,
|
|
44
47
|
linkRequestSessionLane,
|
|
45
48
|
} from "./request-log-conversation";
|
|
@@ -69,7 +72,7 @@ import {
|
|
|
69
72
|
assertRouteAllowedByScope,
|
|
70
73
|
resolveAdmissionModelScope,
|
|
71
74
|
} from "./admission-model-scope";
|
|
72
|
-
import type
|
|
75
|
+
import { resolveContextPrincipal, type DataPlaneAdmission } from "./auth-cors";
|
|
73
76
|
import { tryClaimNativeMainProfileForTurn } from "../codex/native-main-admission";
|
|
74
77
|
import {
|
|
75
78
|
createTranslatorBudget,
|
|
@@ -81,6 +84,7 @@ import { createNativeChatComboSource, handleNativeChatCompletions, nativeChatDec
|
|
|
81
84
|
import { upstreamWireForAdapter, type ProtocolReasonCode } from "../protocols/contract";
|
|
82
85
|
import { createProtocolEnvelope } from "../protocols/envelope";
|
|
83
86
|
import { featuresFromChatBody } from "../protocols/features";
|
|
87
|
+
import { credentialDomainFor } from "../protocols/opaque-state";
|
|
84
88
|
import { checkRepresentable, unrepresentableMessage } from "../protocols/guard";
|
|
85
89
|
import { requestPathForLane } from "../protocols/path";
|
|
86
90
|
import { resolveProtocolSettings } from "../protocols/settings";
|
|
@@ -113,6 +117,10 @@ export async function handleChatCompletions(
|
|
|
113
117
|
logCtx: RequestLogContext,
|
|
114
118
|
logIds?: { requestId: string; start: number; turnAdmissionLease?: AdmissionLease; admission?: DataPlaneAdmission },
|
|
115
119
|
): Promise<Response> {
|
|
120
|
+
// Set before routing so pre-dispatch refusals stay correlated. A Hermes-only id is marked as a
|
|
121
|
+
// fallback, so a native id resolved later on the Responses bridge still replaces it.
|
|
122
|
+
assignNativeLogConversationId(logCtx, nativeLogConversationIdFromHeaders(req.headers));
|
|
123
|
+
fillHermesLogConversationFallback(logCtx, req.headers);
|
|
116
124
|
const translatorBudget = createTranslatorBudget();
|
|
117
125
|
try {
|
|
118
126
|
let response = await handleChatCompletionsWithBudget(req, config, logCtx, translatorBudget, logIds);
|
|
@@ -344,7 +352,13 @@ async function handleChatCompletionsWithBudget(
|
|
|
344
352
|
directConversation = chatConversationToIr(chatBody, chatBody.model as string);
|
|
345
353
|
internalBody = chatSettingsBody(chatBody);
|
|
346
354
|
} else {
|
|
347
|
-
internalBody = chatToResponsesBody(chatBody
|
|
355
|
+
internalBody = chatToResponsesBody(chatBody, {
|
|
356
|
+
// #7429: only a route that is and stays first-party Anthropic may receive signed thinking
|
|
357
|
+
// a Chat client returns; a combo, policy or rewritten route resolves its target later.
|
|
358
|
+
acceptAnthropicReasoningSignatures: settledRoute !== null && !routeMayChangeCredentialDomain
|
|
359
|
+
&& settledRoute.provider.adapter === "anthropic"
|
|
360
|
+
&& credentialDomainFor(settledRoute.provider)?.firstPartyAnthropic === true,
|
|
361
|
+
});
|
|
348
362
|
}
|
|
349
363
|
if (effortRow) {
|
|
350
364
|
internalBody.reasoning = {
|
|
@@ -413,6 +427,7 @@ async function handleChatCompletionsWithBudget(
|
|
|
413
427
|
// Unresolved combos are checked after their concrete child route is selected in Responses.
|
|
414
428
|
if (settledRoute && !settledRoute.combo && isCanonicalOpenAiForwardProvider(settledRoute.provider)
|
|
415
429
|
&& isCodexReserveHelperUnsupported(config, settledRoute.modelId, logIds?.admission, visionDescribeTerminal)) {
|
|
430
|
+
if (logIds) addFinalRequestLog(logIds.requestId, logIds.start, logCtx, 400, { closeReason: "non_stream" });
|
|
416
431
|
return chatCompletionsErrorResponse(400, CODEX_RESERVE_HELPER_UNSUPPORTED_MESSAGE, "invalid_request_error");
|
|
417
432
|
}
|
|
418
433
|
const nativeCallerAuth = captureExplicitOpenAiCallerAuth(req.headers, config);
|
|
@@ -491,6 +506,8 @@ async function handleChatCompletionsWithBudget(
|
|
|
491
506
|
// #1686: the Chat surface translates its body and replays here, so the admission fact has
|
|
492
507
|
// to ride along or a bearer-admitted Chat caller would still be refused by Direct.
|
|
493
508
|
...(logIds?.admission ? { admission: logIds.admission } : {}),
|
|
509
|
+
// The bridge request carries no caller credential, so resolve the replay principal here.
|
|
510
|
+
replayPrincipal: { clientPrincipalId: resolveContextPrincipal(req, config, logIds?.admission) },
|
|
494
511
|
abortSignal: req.signal,
|
|
495
512
|
// Body is Responses-shaped by now, but the client spoke Chat Completions.
|
|
496
513
|
inboundWire: "chat",
|
|
@@ -505,6 +522,9 @@ async function handleChatCompletionsWithBudget(
|
|
|
505
522
|
...(envelope && nativeChatCombos ? {
|
|
506
523
|
protocolSource: createNativeChatComboSource({
|
|
507
524
|
req, config, envelope, requestedModel, requestedStream: stream, translatorBudget,
|
|
525
|
+
// Children run without logIds; the request's admission rides the source so their
|
|
526
|
+
// replay scope resolves the same caller principal as every other surface.
|
|
527
|
+
...(logIds?.admission ? { admission: logIds.admission } : {}),
|
|
508
528
|
}),
|
|
509
529
|
} : {}),
|
|
510
530
|
// Terminal vision-describe marker (roadmap 180): the bridge rebuilds
|
|
@@ -7,11 +7,17 @@ import { reasoningReplayConversationIdFromResponsesRequest, sessionIdHeaderFromR
|
|
|
7
7
|
|
|
8
8
|
type Rec = Record<string, unknown>;
|
|
9
9
|
|
|
10
|
-
export function nativeChatReplayScope(
|
|
10
|
+
export function nativeChatReplayScope(
|
|
11
|
+
headers: Headers,
|
|
12
|
+
clientPrincipalId: string | undefined,
|
|
13
|
+
): OcxReasoningReplayScopeRef | undefined {
|
|
11
14
|
const clientThreadId = reasoningReplayConversationIdFromResponsesRequest({
|
|
12
15
|
threadIdHeader: headers.get("thread-id"), sessionIdHeader: sessionIdHeaderFromRequest(headers),
|
|
13
16
|
});
|
|
14
|
-
return clientThreadId ? {
|
|
17
|
+
return clientThreadId ? {
|
|
18
|
+
clientThreadId,
|
|
19
|
+
...(clientPrincipalId ? { clientPrincipalId } : {}),
|
|
20
|
+
} : undefined;
|
|
15
21
|
}
|
|
16
22
|
|
|
17
23
|
/** Rebind before EVERY build: a key rotation must not retrieve another credential's reasoning. */
|
|
@@ -42,7 +48,8 @@ export function nativeChatReasoningCapture(
|
|
|
42
48
|
const maxBytes = 256 * 1024;
|
|
43
49
|
let bytes = 0;
|
|
44
50
|
let callCount = 0;
|
|
45
|
-
let disabled = !scope?.
|
|
51
|
+
let disabled = !scope?.clientPrincipalId || !scope.current
|
|
52
|
+
|| !chatReasoningReplayPolicy(provider, modelId).preserve;
|
|
46
53
|
const discard = () => {
|
|
47
54
|
budget.releaseRetained(bytes, accounting);
|
|
48
55
|
bytes = 0;
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
} from "../lib/errors";
|
|
25
25
|
import { reportDispatchSends, type RequestExecutionBudget, type SingleUseDispatchPermit } from "../lib/request-execution-budget";
|
|
26
26
|
import type { AdmissionLease } from "../lib/admission";
|
|
27
|
+
import { resolveContextPrincipal, type DataPlaneAdmission } from "./auth-cors";
|
|
27
28
|
import { readBoundedResponseBody } from "../lib/bounded-body";
|
|
28
29
|
import { redactSecretString } from "../lib/redact";
|
|
29
30
|
import { resolveClientRetryAfter } from "../lib/retry-after";
|
|
@@ -179,7 +180,7 @@ export interface HandleNativeChatOptions {
|
|
|
179
180
|
req: Request;
|
|
180
181
|
config: OcxConfig;
|
|
181
182
|
logCtx: RequestLogContext;
|
|
182
|
-
logIds?: { requestId: string; start: number; turnAdmissionLease?: AdmissionLease };
|
|
183
|
+
logIds?: { requestId: string; start: number; turnAdmissionLease?: AdmissionLease; admission?: DataPlaneAdmission };
|
|
183
184
|
route: RouteResult;
|
|
184
185
|
chatBody: Rec;
|
|
185
186
|
requestedModel: string;
|
|
@@ -209,6 +210,12 @@ export interface NativeChatExecution extends HandleNativeChatOptions {
|
|
|
209
210
|
onFirstOutput?: () => void;
|
|
210
211
|
/** The lease a streamed body holds; defaults to `logIds.turnAdmissionLease`. */
|
|
211
212
|
turnAdmissionLease?: AdmissionLease;
|
|
213
|
+
/**
|
|
214
|
+
* The request's resolved data-plane admission, when the caller carries it without `logIds`.
|
|
215
|
+
* A combo child runs on the parent's log row, so it receives the admitted identity here or
|
|
216
|
+
* its reasoning replay scope can never establish a caller principal (PF-07).
|
|
217
|
+
*/
|
|
218
|
+
admission?: DataPlaneAdmission;
|
|
212
219
|
}
|
|
213
220
|
|
|
214
221
|
/**
|
|
@@ -246,6 +253,8 @@ export function createNativeChatComboSource(input: {
|
|
|
246
253
|
requestedModel: string;
|
|
247
254
|
requestedStream: boolean;
|
|
248
255
|
translatorBudget: TranslatorBudget;
|
|
256
|
+
/** The request's data-plane admission; native children inherit it for replay scope. */
|
|
257
|
+
admission?: DataPlaneAdmission;
|
|
249
258
|
}): ComboProtocolSource {
|
|
250
259
|
return {
|
|
251
260
|
inbound: "chat",
|
|
@@ -264,6 +273,7 @@ export function createNativeChatComboSource(input: {
|
|
|
264
273
|
comboDispatchPermit: child.comboDispatchPermit,
|
|
265
274
|
onFirstOutput: child.onFirstOutput,
|
|
266
275
|
...(child.turnAdmissionLease ? { turnAdmissionLease: child.turnAdmissionLease } : {}),
|
|
276
|
+
...(input.admission ? { admission: input.admission } : {}),
|
|
267
277
|
}, child.attemptHandle),
|
|
268
278
|
};
|
|
269
279
|
}
|
|
@@ -340,7 +350,8 @@ export async function runNativeChatAttempt(
|
|
|
340
350
|
const physicalBudget = sendBudget ?? createInferenceSendBudget(req, logCtx);
|
|
341
351
|
let activeAdapter: ProviderAdapter = createOpenAIChatAdapter(activeProvider);
|
|
342
352
|
let activeRequest: AdapterRequest;
|
|
343
|
-
const replayScope = nativeChatReplayScope(req.headers
|
|
353
|
+
const replayScope = nativeChatReplayScope(req.headers,
|
|
354
|
+
resolveContextPrincipal(req, config, execution.admission ?? logIds?.admission));
|
|
344
355
|
let retainedRequestBytes = 0;
|
|
345
356
|
const releaseRetainedRequest = () => {
|
|
346
357
|
if (retainedRequestBytes === 0) return;
|
|
@@ -100,6 +100,7 @@ import {
|
|
|
100
100
|
isApiAuthRequired,
|
|
101
101
|
isDataPlaneAdmissionSecret,
|
|
102
102
|
isProxyAdmissionSecret,
|
|
103
|
+
resolveContextPrincipal,
|
|
103
104
|
type RequestPolicyView,
|
|
104
105
|
type DataPlaneAdmission,
|
|
105
106
|
} from "./auth-cors";
|
|
@@ -1117,6 +1118,8 @@ async function handleClaudeMessagesWithBudget(
|
|
|
1117
1118
|
// Routing keeps Claude-only sidecar overrides; admission policy must follow the live owner.
|
|
1118
1119
|
codexAuthPolicy: config,
|
|
1119
1120
|
...(logIds?.admission ? { admission: logIds.admission } : {}),
|
|
1121
|
+
// The translated request never forwards the caller credential; resolve the principal first.
|
|
1122
|
+
replayPrincipal: { clientPrincipalId: resolveContextPrincipal(req, config, logIds?.admission) },
|
|
1120
1123
|
...(logIds?.turnAdmissionLease ? { turnAdmissionLease: logIds.turnAdmissionLease } : {}),
|
|
1121
1124
|
abortSignal: req.signal,
|
|
1122
1125
|
promptCacheKeyIsSharedCohort: cacheKeySource === "system",
|
|
@@ -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" } });
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { Server } from "bun";
|
|
2
2
|
import {
|
|
3
3
|
LINK_RELAY_AUTH_PATH, LINK_RELAY_AUTH_TIMEOUT_MS, LINK_RELAY_AUTH_VERSION,
|
|
4
|
-
LINK_RELAY_SESSION_HEADER, linkRelayProof, linkRelayProofMatches,
|
|
4
|
+
LINK_RELAY_PROBE_HEADER, 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,21 +67,31 @@ 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();
|
|
75
|
-
|
|
76
|
-
const
|
|
77
|
-
sessions.set(nonce, { peer, keyId, linkId, fingerprint, release, timer, expiresAt: Date.now() + reservationMs });
|
|
78
|
-
return new Response(null, { status: 204, headers: {
|
|
83
|
+
options.peers?.observe(linkId, LINK_RELAY_AUTH_VERSION, parseLinkPeerReport(req.headers));
|
|
84
|
+
const answer = () => new Response(null, { status: 204, headers: {
|
|
79
85
|
"x-opencodex-link-proof": linkRelayProof(fingerprint, "listener", keyId, linkId, nonce)!,
|
|
80
86
|
"cache-control": "no-store",
|
|
81
87
|
} });
|
|
88
|
+
// A readiness probe closes after this answer and sends no data request, so it reserves
|
|
89
|
+
// nothing: no session to consume, no capacity and no drain lease held until expiry.
|
|
90
|
+
if (req.headers.get(LINK_RELAY_PROBE_HEADER) === "1") return answer();
|
|
91
|
+
const release = acquire();
|
|
92
|
+
const timer = setTimeout(() => { consume(nonce)?.release(); }, reservationMs);
|
|
93
|
+
sessions.set(nonce, { peer, keyId, linkId, fingerprint, release, timer, expiresAt: Date.now() + reservationMs });
|
|
94
|
+
return answer();
|
|
82
95
|
}
|
|
83
96
|
const nonce = req.headers.get(LINK_RELAY_SESSION_HEADER);
|
|
84
97
|
let release: () => void;
|
|
@@ -14,7 +14,9 @@ import {
|
|
|
14
14
|
type LinkListenerStatus,
|
|
15
15
|
} from "./link-listener";
|
|
16
16
|
import { createLinkSupervisor, type LinkSupervisor } from "../../link/supervisor";
|
|
17
|
+
import { flushLinkDiagnostics, recordLinkDiagnostic } from "../../link/diagnostics";
|
|
17
18
|
import { linkManagementRouteAllowed } from "../../link/management-routes";
|
|
19
|
+
import { linkChildParityRouteAllowed } from "../../link/child-parity";
|
|
18
20
|
import { saveConfigPreservingClaudeCode } from "../../config/live-reconcile";
|
|
19
21
|
import { reconcileLiveStateStores } from "../../lib/state-store-registrations";
|
|
20
22
|
import { startJevQuotaActivation } from "../../combos/jev-quota-activation";
|
|
@@ -38,6 +40,7 @@ export interface OptionalListenerSet<T> {
|
|
|
38
40
|
linkAdmissionKeyIds(): ReadonlySet<string>;
|
|
39
41
|
onAuthenticatedCatalog(listener: (apiKeyId: string) => void): () => void;
|
|
40
42
|
notifyAuthenticatedCatalog(apiKeyId: string): void;
|
|
43
|
+
peerObservation(linkId: string): import("../../link/peer-report").LinkPeerEntry;
|
|
41
44
|
status(): LinkListenerStatus;
|
|
42
45
|
linkStatus(): LinkListenerStatus;
|
|
43
46
|
linkSupervisor(): LinkSupervisor;
|
|
@@ -54,6 +57,9 @@ export function createOptionalListenerSet<T>(linkDeps: LinkListenerDeps = {}): O
|
|
|
54
57
|
let activeConfig: OcxConfig | undefined;
|
|
55
58
|
let stopJevQuotaActivation: (() => void) | undefined;
|
|
56
59
|
const supervisor = createLinkSupervisor({
|
|
60
|
+
// Tunnel state changes and their causes reach <configDir>/link/diagnostics.jsonl (bounded).
|
|
61
|
+
diagnostic: recordLinkDiagnostic,
|
|
62
|
+
flushDiagnostics: flushLinkDiagnostics,
|
|
57
63
|
apiKeys: () => activeConfig?.apiKeys ?? [],
|
|
58
64
|
revokeApiKey: id => {
|
|
59
65
|
if (!activeConfig) return false;
|
|
@@ -83,11 +89,14 @@ export function createOptionalListenerSet<T>(linkDeps: LinkListenerDeps = {}): O
|
|
|
83
89
|
},
|
|
84
90
|
// The Home's link listener admits the data plane plus the read-only management relay
|
|
85
91
|
// (#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
|
-
|
|
92
|
+
// cannot reach the Home's management reads without the Child's dashboard session. The one
|
|
93
|
+
// write is the Child's apply report (OCX-6), which changes only an in-memory status field.
|
|
94
|
+
linkRouteAllowed: (url, req) => linkRouteAllowed(url, req) || linkManagementRouteAllowed(url, req)
|
|
95
|
+
|| linkChildParityRouteAllowed(url, req),
|
|
88
96
|
linkAdmissionKeyIds: () => linkListener.linkAdmissionKeyIds(),
|
|
89
97
|
onAuthenticatedCatalog: listener => linkListener.onAuthenticatedCatalog(listener),
|
|
90
98
|
notifyAuthenticatedCatalog: apiKeyId => linkListener.notifyAuthenticatedCatalog(apiKeyId),
|
|
99
|
+
peerObservation: linkId => linkListener.peerObservation(linkId),
|
|
91
100
|
status: () => linkListener.status(),
|
|
92
101
|
linkStatus: () => linkListener.status(),
|
|
93
102
|
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
|
/**
|
|
@@ -51,7 +51,7 @@ export interface LocalManagementReadRequestDeps extends LocalManagementReadDeps
|
|
|
51
51
|
*/
|
|
52
52
|
export async function fetchBoundLocalManagementRead(
|
|
53
53
|
target: LiveProxy,
|
|
54
|
-
path: LocalManagementReadPath
|
|
54
|
+
path: LocalManagementReadPath | `${LocalManagementReadPath}?${string}`,
|
|
55
55
|
deps: LocalManagementReadRequestDeps = {},
|
|
56
56
|
): Promise<LocalManagementReadResult> {
|
|
57
57
|
if (
|
|
@@ -89,6 +89,8 @@ export async function fetchBoundLocalManagementRead(
|
|
|
89
89
|
const timeoutMs = deps.timeoutMs ?? 4_000;
|
|
90
90
|
const url = `http://${probeHostname(target.hostname)}:${target.port}${path}`;
|
|
91
91
|
const init: RequestInit = {
|
|
92
|
+
redirect: "error",
|
|
93
|
+
credentials: "omit",
|
|
92
94
|
headers: {
|
|
93
95
|
[LOCAL_MANAGEMENT_EXPECTED_PID_HEADER]: String(target.pid),
|
|
94
96
|
[LOCAL_MANAGEMENT_NONCE_HEADER]: nonce,
|
|
@@ -208,7 +208,6 @@ export async function handleComboRoutes(ctx: ManagementContext): Promise<Respons
|
|
|
208
208
|
const {
|
|
209
209
|
clearComboSelectionState,
|
|
210
210
|
clearComboTargetCooldowns,
|
|
211
|
-
comboConfigError,
|
|
212
211
|
comboDisabledModelId,
|
|
213
212
|
comboDisabledModelSelectors,
|
|
214
213
|
comboModelId,
|
|
@@ -225,6 +224,8 @@ export async function handleComboRoutes(ctx: ManagementContext): Promise<Respons
|
|
|
225
224
|
...requestedCombo,
|
|
226
225
|
...(!Object.hasOwn(requestedCombo, "decisionQuotaSignals") && requestedCombo.strategy === "jev" && previous?.decisionQuotaSignals !== undefined ? { decisionQuotaSignals: previous.decisionQuotaSignals } : {}),
|
|
227
226
|
...(!Object.hasOwn(requestedCombo, "decisionQuotaTiers") && requestedCombo.strategy === "jev" && previous?.decisionQuotaTiers !== undefined ? { decisionQuotaTiers: previous.decisionQuotaTiers } : {}),
|
|
227
|
+
// The dashboard does not edit the session pin (#7525): an omitted field keeps it on a JEV save.
|
|
228
|
+
...(!Object.hasOwn(requestedCombo, "sessionPin") && requestedCombo.strategy === "jev" && previous?.sessionPin != null ? { sessionPin: previous.sessionPin } : {}),
|
|
228
229
|
...(!Object.hasOwn(requestedCombo, "allowMeteredTargets") && previous?.allowMeteredTargets === true
|
|
229
230
|
? { allowMeteredTargets: true }
|
|
230
231
|
: {}),
|
|
@@ -257,7 +258,7 @@ export async function handleComboRoutes(ctx: ManagementContext): Promise<Respons
|
|
|
257
258
|
...(Array.isArray(requestedCombo.targets) && Array.isArray(previous?.targets)
|
|
258
259
|
? {
|
|
259
260
|
targets: requestedCombo.targets.map(target => {
|
|
260
|
-
// A non-record entry stays as the client sent it so
|
|
261
|
+
// A non-record entry stays as the client sent it so comboConfigIssues reports it.
|
|
261
262
|
// Reading `lastResort` off it here would throw in place of that structured 400.
|
|
262
263
|
if (!isPlainRecord(target)) return target;
|
|
263
264
|
if (["lastResort", "metered"].every(key => Object.hasOwn(target, key))) return target;
|
|
@@ -331,8 +332,13 @@ export async function handleComboRoutes(ctx: ManagementContext): Promise<Respons
|
|
|
331
332
|
excludeComboId: sourceId,
|
|
332
333
|
normalizeDecisionModel: (model: string) => normalizeDecisionModelSelector(prospectiveConfig, model),
|
|
333
334
|
};
|
|
334
|
-
|
|
335
|
-
|
|
335
|
+
// `field` names the top-level combo field of the first issue, so a client can tell a target or
|
|
336
|
+
// JEV-setting refusal from an alias or rename one without parsing the message (#7579).
|
|
337
|
+
const invalid = comboConfigIssues(id, effectiveCombo, config.providers, validationOptions)[0];
|
|
338
|
+
if (invalid) {
|
|
339
|
+
const field = invalid.path[0];
|
|
340
|
+
return jsonResponse({ error: invalid.message, ...(typeof field === "string" ? { field } : {}) }, 400);
|
|
341
|
+
}
|
|
336
342
|
for (const [otherId, combo] of Object.entries(nextCombos)) {
|
|
337
343
|
if (typeof combo.decisionModel !== "string") continue;
|
|
338
344
|
const issue = otherId === id ? undefined : comboConfigIssues(otherId, combo, config.providers, {
|
|
@@ -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;
|