@bitkyc08/opencodex 2.68.0 → 2.69.0-preview.20260928
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 +25 -17
- package/gui/dist/assets/App-cDPyCb_V.js +51 -0
- package/gui/dist/assets/{Tray-Bn8WhKBH.js → Tray-H1IAB8mN.js} +1 -1
- package/gui/dist/assets/index-BF9QnKO_.css +1 -0
- package/gui/dist/assets/index-CI6YTp8z.js +86 -0
- package/gui/dist/assets/{quota-summary-DL-bNuNL.js → quota-summary-CutOlPu2.js} +1 -1
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/factory-droid.svg +8 -0
- package/package.json +1 -1
- package/src/adapters/anthropic-output-schema.ts +109 -11
- package/src/adapters/coding-agent/protocol.ts +106 -11
- package/src/adapters/coding-agent/turn.ts +23 -18
- package/src/adapters/google-errors.ts +16 -1
- package/src/adapters/google-http.ts +14 -0
- package/src/adapters/openai-chat/passthrough.ts +2 -0
- package/src/adapters/openai-chat/summary-budget.ts +72 -0
- package/src/adapters/openai-chat/tool-call-id-remint.ts +11 -2
- package/src/adapters/openai-chat.ts +6 -9
- package/src/bridge/response-json.ts +3 -1
- package/src/bridge/sse.ts +3 -1
- package/src/claude/desktop-picker.ts +6 -2
- package/src/claude/desktop-system-proxy.ts +155 -0
- package/src/claude/inbound-model-options.ts +10 -2
- package/src/claude/inbound.ts +1 -1
- package/src/claude/intercept/picker-ca-cleanup.ts +31 -0
- package/src/claude/intercept/picker-ca.ts +283 -83
- package/src/claude/intercept/picker-models.ts +4 -1
- package/src/claude/intercept/picker-trust.ts +15 -2
- package/src/claude/intercept/runtime.ts +99 -32
- package/src/cli/account-api.ts +5 -2
- package/src/cli/account-extended.ts +47 -11
- package/src/cli/account-target.ts +12 -7
- package/src/cli/account.ts +47 -3
- package/src/cli/capabilities.ts +33 -7
- package/src/cli/claude-agent-startup-sync.ts +2 -0
- package/src/cli/claude-desktop.ts +37 -13
- package/src/cli/cross-home-owner.ts +138 -0
- package/src/cli/dispatch.ts +1 -1
- package/src/cli/doctor.ts +18 -8
- package/src/cli/ensure-desired-integrations.ts +5 -0
- package/src/cli/export-command.ts +6 -6
- package/src/cli/help.ts +1 -1
- package/src/cli/index.ts +15 -18
- package/src/cli/integrations.ts +8 -1
- package/src/cli/models-runtime-subcommands.ts +1 -0
- package/src/cli/models-runtime.ts +106 -4
- package/src/cli/models.ts +6 -1
- package/src/cli/opencode.ts +2 -83
- package/src/cli/registry.ts +3 -2
- package/src/cli/status.ts +86 -7
- package/src/client/connect.ts +24 -10
- package/src/client/link-ingress.ts +3 -1
- package/src/client/link-join.ts +76 -9
- package/src/client/link-relay-transport.ts +184 -0
- package/src/client/link-relay.ts +16 -4
- package/src/client/machine-listener.ts +2 -0
- package/src/clients/config-export/constants.ts +8 -0
- package/src/clients/config-export/contracts.ts +13 -1
- package/src/clients/config-export/droid.ts +71 -0
- package/src/clients/config-export/kilo.ts +116 -0
- package/src/clients/config-export.ts +33 -2
- package/src/codex/auth-api/routes.ts +1 -1
- package/src/codex/catalog/access-programs.ts +14 -2
- package/src/codex/catalog/provider-models.ts +1 -1
- package/src/codex/catalog-auto-refresh.ts +122 -0
- package/src/codex/config-drift-heal.ts +80 -0
- package/src/codex/desktop-switches.ts +7 -5
- package/src/codex/inject/bounded-config-reader.ts +69 -0
- package/src/codex/inject/config-toml.ts +31 -3
- package/src/codex/inject/routing-target.ts +6 -1
- package/src/codex/inject.ts +4 -0
- package/src/codex/model-entitlements.ts +32 -5
- package/src/codex/native-profile-startup.ts +17 -5
- package/src/codex/project-config-warnings.ts +72 -21
- package/src/codex/quota-auto-refresh-state.ts +8 -2
- package/src/codex/quota-auto-refresh.ts +80 -9
- package/src/codex/sync.ts +10 -0
- package/src/combos/failover.ts +4 -1
- package/src/combos/jev.ts +15 -4
- package/src/combos/types.ts +16 -0
- package/src/config/diagnostics.ts +19 -1
- package/src/config/load-degrade.ts +29 -0
- package/src/config/macos-system-proxy.ts +114 -0
- package/src/config/proxy-env.ts +67 -20
- package/src/config/schema/config-schema.ts +15 -0
- package/src/config/schema/leaf-validators.ts +23 -0
- package/src/config.ts +2 -0
- package/src/generated/compatibility-version.json +220 -144
- package/src/integrations/catalog-refresh.ts +1 -1
- package/src/integrations/config-io.ts +12 -3
- package/src/integrations/droid-settings.ts +65 -0
- package/src/integrations/kilo-candidates.ts +30 -0
- package/src/integrations/mutation-plan.ts +62 -7
- package/src/integrations/native/ownership-preflight.ts +46 -12
- package/src/integrations/registry.ts +136 -7
- package/src/integrations/state.ts +52 -4
- package/src/integrations/target.ts +3 -0
- package/src/integrations/writer.ts +55 -14
- package/src/lib/account-selection-events.ts +14 -0
- package/src/lib/jsonc.ts +110 -0
- package/src/lib/standalone.ts +9 -1
- package/src/lib/windows-system-proxy.ts +101 -1
- package/src/link/relay-auth.ts +29 -0
- package/src/link/ssh-argv.ts +6 -3
- package/src/link/ssh-runner.ts +5 -5
- package/src/oauth/generic-account-failover.ts +52 -8
- package/src/oauth/index.ts +37 -14
- package/src/oauth/store.ts +96 -11
- package/src/oauth/token-guardian.ts +7 -3
- package/src/oauth/types.ts +2 -0
- package/src/plugins/loader.ts +5 -3
- package/src/providers/fastwire.ts +3 -0
- package/src/providers/key-failover.ts +13 -5
- package/src/providers/kiro-model-catalog.ts +4 -3
- package/src/providers/model-rename-fields.ts +1 -0
- package/src/providers/openai-tiers-destination.ts +9 -0
- package/src/providers/quota/account-cache.ts +17 -3
- package/src/providers/quota/vendor-probes-oauth.ts +2 -0
- package/src/providers/quota.ts +2 -2
- package/src/responses/code-mode-helper-compat.ts +14 -1
- package/src/responses/custom-tool-compat.ts +1 -1
- package/src/server/auth-cors.ts +1 -0
- package/src/server/images.ts +70 -20
- package/src/server/index/link-listener.ts +20 -3
- package/src/server/index/link-relay-sessions.ts +138 -0
- package/src/server/index/optional-listeners.ts +11 -1
- package/src/server/index/serve-options.ts +12 -0
- package/src/server/inference/client-encoder-delivery.ts +1 -0
- package/src/server/local-management-read-client.ts +21 -2
- package/src/server/management/agent-settings-routes.ts +19 -5
- package/src/server/management/combo-routes.ts +5 -3
- package/src/server/management/config-routes.ts +32 -6
- package/src/server/management/model-routes.ts +232 -7
- package/src/server/management/model-rows.ts +199 -1
- package/src/server/management/oauth-account-routes.ts +63 -3
- package/src/server/management/route-registry.ts +2 -0
- package/src/server/port-reclaim.ts +170 -32
- package/src/server/responses/adapter-delivery.ts +6 -4
- package/src/server/responses/adapter-dispatch.ts +72 -2
- package/src/server/responses/core-combo.ts +6 -1
- package/src/server/responses/core-normalize.ts +19 -4
- package/src/server/responses/core-options.ts +2 -0
- package/src/server/responses/memory-models.ts +169 -0
- package/src/server/responses/passthrough-delivery.ts +4 -0
- package/src/server/responses/passthrough-dispatch.ts +10 -0
- package/src/server/responses/request-prepare.ts +86 -4
- package/src/server/responses/request-transport.ts +4 -0
- package/src/server/responses/run-turn-execution.ts +5 -2
- package/src/server/responses/shadow-target-availability.ts +17 -4
- package/src/server/responses/sidecar-execution.ts +18 -2
- package/src/server/responses/skills-snapshot.ts +239 -0
- package/src/server/responses-undeclared-tool-guard.ts +43 -6
- package/src/server/search.ts +1 -1
- package/src/service/guards.ts +4 -5
- package/src/service/state.ts +39 -2
- package/src/service.ts +2 -2
- package/src/types/config.ts +46 -4
- package/src/types/provider.ts +9 -0
- package/src/types/request.ts +6 -0
- package/src/types/tools.ts +38 -8
- package/src/types.ts +3 -0
- package/src/update/async-check.ts +5 -1
- package/src/update/index.ts +30 -12
- package/src/update/job.ts +3 -1
- package/src/update/pnpm-read-policy.d.mts +4 -0
- package/src/update/pnpm-read-policy.mjs +44 -0
- package/src/update/worker-launch.ts +152 -11
- package/src/usage/cost.ts +6 -3
- package/src/usage/log.ts +4 -0
- package/src/web-search/backends.ts +2 -2
- package/src/web-search/devin-executor.ts +1 -1
- package/src/web-search/sidecar-providers.ts +5 -5
- package/gui/dist/assets/App-mpsibsFj.js +0 -51
- package/gui/dist/assets/index-BDUBS8PW.css +0 -1
- package/gui/dist/assets/index-kUE8tuAK.js +0 -86
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { getValidAccessTokenForAccount } from "../../oauth";
|
|
3
|
-
import { getAccountCredential, getAccountSet } from "../../oauth/store";
|
|
3
|
+
import { getAccountCredential, getAccountCredentialWithStatus, getAccountSet } from "../../oauth/store";
|
|
4
4
|
import type { GenerationContext } from "../../lib/state-store-sweeper";
|
|
5
5
|
import { ACCOUNT_QUOTA_TTL_MS, toFiniteNumber } from "../quota-wire";
|
|
6
6
|
import { clearKiroAccountUsageState, hydrateKiroUsageVerdict, kiroPersistableVerdicts, reconcileKiroAccountUsageState } from "../kiro-usage";
|
|
@@ -437,8 +437,11 @@ export function clearAccountQuotaCache(provider?: string): void {
|
|
|
437
437
|
* Anthropic's lock only adopts disk credentials for `local-cli` rows.
|
|
438
438
|
*/
|
|
439
439
|
export async function getTokenForAccountQuotaProbe(provider: string, accountId: string): Promise<string> {
|
|
440
|
-
const
|
|
441
|
-
if (!
|
|
440
|
+
const row = getAccountCredentialWithStatus(provider, accountId);
|
|
441
|
+
if (!row) throw new Error("account credential missing");
|
|
442
|
+
// An operator-paused account is excluded from every automatic upstream use, quota reads included.
|
|
443
|
+
if (row.paused) throw new Error("account is paused; quota probe skipped");
|
|
444
|
+
const stored = row.credential;
|
|
442
445
|
if (stored.expires > Date.now() + ACCOUNT_TOKEN_SKEW_MS) return stored.access;
|
|
443
446
|
const activeId = getAccountSet(provider)?.activeAccountId;
|
|
444
447
|
if (activeId !== accountId && stored.source === "local-cli") {
|
|
@@ -447,6 +450,17 @@ export async function getTokenForAccountQuotaProbe(provider: string, accountId:
|
|
|
447
450
|
return getValidAccessTokenForAccount(provider, accountId);
|
|
448
451
|
}
|
|
449
452
|
|
|
453
|
+
/**
|
|
454
|
+
* Rows that must not be probed: an unsupported provider reads as unavailable, and a paused
|
|
455
|
+
* account returns its last reading unchanged (its `ts` shows the age) or an unavailable row,
|
|
456
|
+
* without writing the cache. Undefined means "probe normally".
|
|
457
|
+
*/
|
|
458
|
+
export function accountQuotaProbeSkip(provider: string, accountId: string): AccountQuotaCacheEntry | undefined {
|
|
459
|
+
if (!supportsPerAccountQuota(provider)) return { ts: Date.now(), quota: null, unavailable: true };
|
|
460
|
+
if (getAccountCredentialWithStatus(provider, accountId)?.paused !== true) return undefined;
|
|
461
|
+
return accountQuotaCache.get(accountCacheKey(provider, accountId)) ?? { ts: Date.now(), quota: null, unavailable: true };
|
|
462
|
+
}
|
|
463
|
+
|
|
450
464
|
export function explicitQuotaConfig(provider: string, configured?: OcxProviderConfig): OcxProviderConfig | undefined {
|
|
451
465
|
if (configured) return configured;
|
|
452
466
|
const entry = getProviderRegistryEntry(provider);
|
|
@@ -400,6 +400,8 @@ export async function fetchKiroQuota(provider: string): Promise<ProviderQuotaRep
|
|
|
400
400
|
export async function fetchMuseKeyQuota(provider: string): Promise<ProviderQuotaReport | null> {
|
|
401
401
|
const probedAccountId = getAccountSet(provider)?.activeAccountId;
|
|
402
402
|
if (!probedAccountId) return null;
|
|
403
|
+
// A paused account is excluded from every automatic upstream use; a key mint is one.
|
|
404
|
+
if (getAccountSet(provider)?.accounts.find(account => account.id === probedAccountId)?.paused === true) return null;
|
|
403
405
|
const oauthAccessToken = getAccountCredential(provider, probedAccountId)?.muse?.oauthAccessToken;
|
|
404
406
|
// An imported or pasted credential has no account token and never will: it is
|
|
405
407
|
// capability, not provider id, that decides whether a probe is possible.
|
package/src/providers/quota.ts
CHANGED
|
@@ -45,7 +45,7 @@ import {
|
|
|
45
45
|
explicitQuotaConfig,
|
|
46
46
|
explicitQuotaDestination,
|
|
47
47
|
explicitQuotaIdentity,
|
|
48
|
-
getTokenForAccountQuotaProbe,
|
|
48
|
+
accountQuotaProbeSkip, getTokenForAccountQuotaProbe,
|
|
49
49
|
hasPassiveAccountQuota,
|
|
50
50
|
hydrateAccountQuotaCache,
|
|
51
51
|
mayCommitAccountQuotaKey,
|
|
@@ -421,7 +421,7 @@ async function fetchAccountQuota(
|
|
|
421
421
|
forceRefresh: boolean,
|
|
422
422
|
providerConfig?: OcxProviderConfig,
|
|
423
423
|
): Promise<AccountQuotaCacheEntry> {
|
|
424
|
-
if (
|
|
424
|
+
if (accountQuotaProbeSkip(provider, accountId)) return accountQuotaProbeSkip(provider, accountId)!;
|
|
425
425
|
if (explicitAccountReader(provider)) return fetchExplicitAccountQuota(provider, accountId, forceRefresh, providerConfig);
|
|
426
426
|
if (provider === "anthropic" || provider === "kiro") hydrateAccountQuotaCache();
|
|
427
427
|
const key = accountCacheKey(provider, accountId);
|
|
@@ -3,13 +3,16 @@ import {
|
|
|
3
3
|
normalizeApplyPatchDelimiters,
|
|
4
4
|
unwrapFreeformToolInput,
|
|
5
5
|
} from "./apply-patch-envelope";
|
|
6
|
-
import { declaresCodeModeExec } from "../types/tools";
|
|
6
|
+
import { declaresCodeModeExec, isCodeModeMcpDirectName } from "../types/tools";
|
|
7
7
|
import { parseCodeModeShellInput } from "./code-mode-shell-input";
|
|
8
8
|
|
|
9
9
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
10
10
|
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
/** A nested host tool reachable as `tools.<name>` when the flattened name is one identifier. */
|
|
14
|
+
const CODE_MODE_IDENTIFIER_NAME = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
15
|
+
|
|
13
16
|
/**
|
|
14
17
|
* Convert a nested Code Mode helper call into unified-exec JavaScript.
|
|
15
18
|
*
|
|
@@ -95,6 +98,16 @@ export function compileCodeModeHelperInput(
|
|
|
95
98
|
if (helperName === "create_goal" || helperName === "get_goal" || helperName === "update_goal") {
|
|
96
99
|
return `const result = await tools.${helperName}(${JSON.stringify(args)});\ntext(result);`;
|
|
97
100
|
}
|
|
101
|
+
if (isCodeModeMcpDirectName(helperName)) {
|
|
102
|
+
// Direct `mcp__<server>__<tool>` call under a code-mode catalog: the emitted name IS the
|
|
103
|
+
// nested host tool's name, so compile to the same `tools.<name>(args)` the model could
|
|
104
|
+
// have written. Dot access when the name is a clean identifier (the common case); bracket
|
|
105
|
+
// access otherwise, so a hyphenated server name still addresses the same tool.
|
|
106
|
+
const target = CODE_MODE_IDENTIFIER_NAME.test(helperName)
|
|
107
|
+
? `tools.${helperName}`
|
|
108
|
+
: `tools[${JSON.stringify(helperName)}]`;
|
|
109
|
+
return `const result = await ${target}(${JSON.stringify(args)});\ntext(result);`;
|
|
110
|
+
}
|
|
98
111
|
return `const result = await tools.exec_command(${JSON.stringify(args)});\ntext(result);`;
|
|
99
112
|
}
|
|
100
113
|
|
|
@@ -76,7 +76,7 @@ export function routedCustomToolTargetName(
|
|
|
76
76
|
if (wireName === undefined) return undefined;
|
|
77
77
|
if (names.has(wireName)) return wireName;
|
|
78
78
|
if (!isPlainObject(value) || typeof value.namespace === "string") return undefined;
|
|
79
|
-
const normalized = normalizeDeclaredToolName(wireName, declaredNames);
|
|
79
|
+
const normalized = normalizeDeclaredToolName(wireName, declaredNames, undefined, names);
|
|
80
80
|
return normalized !== wireName && names.has(normalized) ? normalized : undefined;
|
|
81
81
|
}
|
|
82
82
|
|
package/src/server/auth-cors.ts
CHANGED
package/src/server/images.ts
CHANGED
|
@@ -37,7 +37,7 @@ import type { DataPlaneAdmission } from "./auth-cors";
|
|
|
37
37
|
import { admissionScopeDenial } from "./admission-model-scope";
|
|
38
38
|
import type { RequestLogContext } from "./request-log";
|
|
39
39
|
import { codexLogAccountId, decodeRequestErrorResponse } from "./responses";
|
|
40
|
-
import { getValidAccessToken, getOAuthCredentialProjectId } from "../oauth/index";
|
|
40
|
+
import { getValidAccessToken, getOAuthCredentialProjectId, OAuthAccountPausedError } from "../oauth/index";
|
|
41
41
|
import { safeAntigravityHttpErrorMessage } from "../adapters/google-errors";
|
|
42
42
|
import { sanitizeUpstreamErrorText } from "../adapters/upstream-http-error";
|
|
43
43
|
import { ANTIGRAVITY_REQUEST_UA } from "../adapters/google-antigravity-wire";
|
|
@@ -56,6 +56,14 @@ import { codexModelAvailabilityErrorResponse } from "./responses/codex-auth-erro
|
|
|
56
56
|
|
|
57
57
|
export type ImagesEndpoint = "generations" | "edits";
|
|
58
58
|
|
|
59
|
+
function assertImagesUpstreamAuthorization(headers: Headers, config: OcxConfig): void {
|
|
60
|
+
const authorization = headers.get("authorization")?.trim() ?? "";
|
|
61
|
+
if (!/^Bearer[\t ]+[^\s,]+$/i.test(authorization)) {
|
|
62
|
+
throw new TypeError("invalid image upstream authorization");
|
|
63
|
+
}
|
|
64
|
+
validateForwardAdmissionCredential(headers, config);
|
|
65
|
+
}
|
|
66
|
+
|
|
59
67
|
/** Image generation is slow (tens of seconds); bound a hung upstream, not a working one. */
|
|
60
68
|
const IMAGES_UPSTREAM_TIMEOUT_MS = 300_000;
|
|
61
69
|
|
|
@@ -275,6 +283,9 @@ async function tryCcaImageGeneration(
|
|
|
275
283
|
if (linkedSignal.signal.aborted) {
|
|
276
284
|
return formatErrorResponse(504, "upstream_error", "CCA image generation timed out during authentication");
|
|
277
285
|
}
|
|
286
|
+
if (err instanceof OAuthAccountPausedError) {
|
|
287
|
+
return formatErrorResponse(403, "permission_error", err.message);
|
|
288
|
+
}
|
|
278
289
|
// Missing/revoked credential → 401 (re-login required); transient refresh/network → 502.
|
|
279
290
|
const errName = err instanceof Error ? err.name : "";
|
|
280
291
|
if (errName === "OAuthLoginRequiredError") {
|
|
@@ -659,25 +670,41 @@ export async function handleImages(
|
|
|
659
670
|
}
|
|
660
671
|
const explicitKeyedProvider = config.images?.provider !== undefined && candidates.keyed !== undefined;
|
|
661
672
|
// Admission bearer is valid proxy auth (requireApiAuth already passed) but must never be
|
|
662
|
-
// forwarded as OpenAI ChatGPT credentials.
|
|
663
|
-
//
|
|
664
|
-
let
|
|
673
|
+
// forwarded as OpenAI ChatGPT credentials. Pool replaces it with a stored credential;
|
|
674
|
+
// Direct would consume the caller bearer, so only Direct becomes ineligible.
|
|
675
|
+
let callerBearerMayBeForwarded = true;
|
|
665
676
|
if (!explicitKeyedProvider) {
|
|
666
677
|
try { validateForwardAdmissionCredential(req.headers, config); }
|
|
667
678
|
catch (err) {
|
|
668
679
|
if (err instanceof ForwardAdmissionCredentialError) {
|
|
669
|
-
|
|
680
|
+
callerBearerMayBeForwarded = false;
|
|
670
681
|
} else {
|
|
671
682
|
throw err;
|
|
672
683
|
}
|
|
673
684
|
}
|
|
674
685
|
}
|
|
675
686
|
|
|
676
|
-
|
|
687
|
+
// Both relay branches copy the body upstream, so the destination is the
|
|
688
|
+
// provider chosen in that branch and the model the caller named. A scoped key
|
|
689
|
+
// is checked against each forward destination BEFORE any stored credential is
|
|
690
|
+
// resolved, refreshed or leased, so a forbidden destination spends nothing.
|
|
691
|
+
const relaySelector = requestedImageSelector(body);
|
|
692
|
+
let forwardScopeDenial: Response | undefined;
|
|
693
|
+
const eligibleForwardCandidates = candidates.forwardCandidates.filter(candidate => {
|
|
694
|
+
if (!callerBearerMayBeForwarded && candidate.accountMode === "direct") return false;
|
|
695
|
+
const denial = admissionScopeDenial(config, admission, relaySelector, {
|
|
696
|
+
providerName: candidate.providerName,
|
|
697
|
+
modelId: relaySelector,
|
|
698
|
+
});
|
|
699
|
+
if (denial) forwardScopeDenial ??= denial;
|
|
700
|
+
return denial === undefined;
|
|
701
|
+
});
|
|
702
|
+
const canUseOpenAiForward = eligibleForwardCandidates.length > 0;
|
|
677
703
|
|
|
678
704
|
if (!canUseOpenAiForward && !candidates.keyed) {
|
|
679
705
|
const ccaResponse = await tryCcaImageGeneration(body, config, logCtx, req.signal, endpoint, admission);
|
|
680
706
|
if (ccaResponse) return ccaResponse;
|
|
707
|
+
if (forwardScopeDenial) return forwardScopeDenial;
|
|
681
708
|
// 400, not 5xx: codex retries every 5xx up to 5 total attempts, and this is a permanent
|
|
682
709
|
// configuration state that must surface on the first attempt.
|
|
683
710
|
return formatErrorResponse(
|
|
@@ -696,7 +723,7 @@ export async function handleImages(
|
|
|
696
723
|
let forwardAuthError: Response | undefined;
|
|
697
724
|
if (canUseOpenAiForward) {
|
|
698
725
|
try {
|
|
699
|
-
forward = await resolveFirstUsableOpenAiSidecar(
|
|
726
|
+
forward = await resolveFirstUsableOpenAiSidecar(eligibleForwardCandidates, req.headers, config, {
|
|
700
727
|
beginCodexAccountSelection: codexAccountSelectionForTurn(turnAdmissionLease),
|
|
701
728
|
});
|
|
702
729
|
if (forward) logCtx.provider = formatCodexProviderForLog(forward.providerName, codexLogAccountId(forward.authContext), config);
|
|
@@ -708,38 +735,48 @@ export async function handleImages(
|
|
|
708
735
|
} else if (err instanceof CodexThreadAffinityExpiredError) {
|
|
709
736
|
forwardAuthError = formatErrorResponse(409, "invalid_request_error", "Codex thread account affinity expired; start a new session");
|
|
710
737
|
} else if (err instanceof CodexAuthContextError) {
|
|
711
|
-
|
|
712
|
-
console.error(`[images] Pool account ${safeAccountLabel} token failed; reauthentication required`);
|
|
738
|
+
console.error("[images] Pool credential failed; reauthentication required");
|
|
713
739
|
forwardAuthError = formatErrorResponse(401, "authentication_error", "Selected Codex account needs reauthentication");
|
|
714
740
|
} else if (err instanceof CodexModelAvailabilityError) {
|
|
715
741
|
forwardAuthError = codexModelAvailabilityErrorResponse(err);
|
|
716
742
|
} else if (err instanceof CodexPoolAuthenticationError) {
|
|
717
743
|
forwardAuthError = formatErrorResponse(401, "authentication_error", err.message);
|
|
744
|
+
} else if (err instanceof TypeError) {
|
|
745
|
+
// The sidecar releases any acquired probe lease if credential materialization fails.
|
|
746
|
+
return formatErrorResponse(500, "internal_error", "image generation request failed forward credential validation");
|
|
718
747
|
} else {
|
|
719
748
|
throw err;
|
|
720
749
|
}
|
|
721
750
|
}
|
|
722
751
|
}
|
|
723
752
|
|
|
724
|
-
const headers
|
|
753
|
+
const headers = new Headers({ "content-type": "application/json" });
|
|
754
|
+
const releaseForwardProbe = (): void => {
|
|
755
|
+
try { forward?.releaseProbeLease?.(); }
|
|
756
|
+
catch { console.error("[images] Failed to release probe lease"); }
|
|
757
|
+
};
|
|
725
758
|
let url: string;
|
|
726
|
-
//
|
|
727
|
-
//
|
|
728
|
-
//
|
|
729
|
-
// key rotation, so a refused request spends nothing.
|
|
730
|
-
const relaySelector = requestedImageSelector(body);
|
|
759
|
+
// Each branch is re-checked as it is entered: the forward check is defence in
|
|
760
|
+
// depth for the pre-filter above, and the keyed check runs before a key
|
|
761
|
+
// rotation is committed.
|
|
731
762
|
if (forward) {
|
|
732
763
|
const denial = admissionScopeDenial(config, admission, relaySelector, {
|
|
733
764
|
providerName: forward.providerName,
|
|
734
765
|
modelId: relaySelector,
|
|
735
766
|
});
|
|
736
767
|
if (denial) {
|
|
737
|
-
|
|
768
|
+
releaseForwardProbe();
|
|
738
769
|
return denial;
|
|
739
770
|
}
|
|
740
771
|
const { provider } = forward;
|
|
741
|
-
|
|
742
|
-
|
|
772
|
+
try {
|
|
773
|
+
for (const [name, value] of Object.entries(provider.headers ?? {})) headers.set(name, value);
|
|
774
|
+
for (const [name, value] of forward.headers) headers.set(name, value);
|
|
775
|
+
assertImagesUpstreamAuthorization(headers, config);
|
|
776
|
+
} catch {
|
|
777
|
+
releaseForwardProbe();
|
|
778
|
+
return formatErrorResponse(500, "internal_error", "image generation request failed forward credential validation");
|
|
779
|
+
}
|
|
743
780
|
// The ChatGPT codex backend takes bare paths (matches the adapter's `${baseUrl}/responses`).
|
|
744
781
|
url = `${provider.baseUrl}/images/${endpoint}`;
|
|
745
782
|
} else if (forwardAuthError) {
|
|
@@ -783,8 +820,13 @@ export async function handleImages(
|
|
|
783
820
|
);
|
|
784
821
|
}
|
|
785
822
|
const apiKey = warmKeyProvider?.apiKey ?? candidates.keyed.apiKey;
|
|
786
|
-
|
|
787
|
-
|
|
823
|
+
try {
|
|
824
|
+
for (const [name, value] of Object.entries(provider.headers ?? {})) headers.set(name, value);
|
|
825
|
+
headers.set("authorization", `Bearer ${apiKey}`);
|
|
826
|
+
assertImagesUpstreamAuthorization(headers, config);
|
|
827
|
+
} catch {
|
|
828
|
+
return formatErrorResponse(500, "internal_error", "image generation request failed forward credential validation");
|
|
829
|
+
}
|
|
788
830
|
logCtx.provider = providerName;
|
|
789
831
|
// Keyed providers tolerate baseUrl with or without /v1 (mirrors openai-responses.ts).
|
|
790
832
|
url = `${provider.baseUrl.replace(/\/v1\/?$/, "")}/v1/images/${endpoint}`;
|
|
@@ -799,6 +841,11 @@ export async function handleImages(
|
|
|
799
841
|
);
|
|
800
842
|
}
|
|
801
843
|
|
|
844
|
+
if (req.signal.aborted) {
|
|
845
|
+
releaseForwardProbe();
|
|
846
|
+
return formatErrorResponse(499, "client_closed_request", `image ${endpoint} request canceled by client`);
|
|
847
|
+
}
|
|
848
|
+
|
|
802
849
|
const timeoutMs = config.images?.timeoutMs ?? IMAGES_UPSTREAM_TIMEOUT_MS;
|
|
803
850
|
const linkedSignal = signalWithTimeout(timeoutMs, req.signal);
|
|
804
851
|
const sidecarExit = sidecarEnter("images");
|
|
@@ -846,6 +893,9 @@ export async function handleImages(
|
|
|
846
893
|
// Client cancel first: it aborts the linked signal too, and must not be logged as an
|
|
847
894
|
// upstream failure (499 maps to client_closed_request in the request log).
|
|
848
895
|
if (req.signal.aborted) {
|
|
896
|
+
// No upstream outcome is recorded for a client cancel, so a recovery probe
|
|
897
|
+
// lease taken by the selected Pool account must be returned explicitly.
|
|
898
|
+
releaseForwardProbe();
|
|
849
899
|
return formatErrorResponse(499, "client_closed_request", `image ${endpoint} request canceled by client`);
|
|
850
900
|
}
|
|
851
901
|
if (linkedSignal.signal.aborted || (err instanceof Error && err.name === "TimeoutError")) {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createLinkRelaySessions } from "./link-relay-sessions";
|
|
1
2
|
import type { Server } from "bun";
|
|
2
3
|
import {
|
|
3
4
|
emptyLinkStore,
|
|
@@ -13,6 +14,7 @@ export const LINK_INGRESS_HOSTNAME = "opencodex-link.invalid";
|
|
|
13
14
|
export interface LinkListenerStartContext<T> {
|
|
14
15
|
dispatch: (req: Request, server: Server<T>) => Promise<Response>;
|
|
15
16
|
maxRequestBodySize: number;
|
|
17
|
+
keyFingerprints?: (apiKeyId: string) => readonly string[];
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
export interface LinkListenerDeps {
|
|
@@ -58,6 +60,7 @@ export function createLinkListenerLifecycle<T>(deps: LinkListenerDeps = {}): Lin
|
|
|
58
60
|
const serve = deps.serve ?? (options => Bun.serve(options));
|
|
59
61
|
const warn = deps.warn ?? ((message: string) => console.warn(message));
|
|
60
62
|
let listener: Server<T> | null = null;
|
|
63
|
+
let relaySessions: ReturnType<typeof createLinkRelaySessions> | undefined;
|
|
61
64
|
let startContext: LinkListenerStartContext<T> | undefined;
|
|
62
65
|
let ensureFlight: Promise<void> | undefined;
|
|
63
66
|
let closeFlight: Promise<void> | undefined;
|
|
@@ -92,6 +95,10 @@ export function createLinkListenerLifecycle<T>(deps: LinkListenerDeps = {}): Lin
|
|
|
92
95
|
return;
|
|
93
96
|
}
|
|
94
97
|
const requestedPort = store.listenerPort ?? 0;
|
|
98
|
+
const sessions = createLinkRelaySessions({ fingerprints: (keyId, linkId) => {
|
|
99
|
+
if (!readStoreForAdmission().links.some(link => link.id === linkId && link.apiKeyId === keyId)) return [];
|
|
100
|
+
return startContext?.keyFingerprints?.(keyId) ?? [];
|
|
101
|
+
} });
|
|
95
102
|
let bound: Server<unknown>;
|
|
96
103
|
try {
|
|
97
104
|
bound = serve({
|
|
@@ -101,7 +108,7 @@ export function createLinkListenerLifecycle<T>(deps: LinkListenerDeps = {}): Lin
|
|
|
101
108
|
// relayed turn that the Home holds or that streams with a long gap.
|
|
102
109
|
idleTimeout: 255,
|
|
103
110
|
maxRequestBodySize: startContext.maxRequestBodySize,
|
|
104
|
-
fetch: (req: Request, server: Server<unknown>) =>
|
|
111
|
+
fetch: (req: Request, server: Server<unknown>) => sessions.dispatch(req, server as Server<T>, startContext!.dispatch),
|
|
105
112
|
} as Parameters<typeof Bun.serve>[0]);
|
|
106
113
|
} catch (error) {
|
|
107
114
|
reportFailure("bind", error, "bind");
|
|
@@ -136,6 +143,7 @@ export function createLinkListenerLifecycle<T>(deps: LinkListenerDeps = {}): Lin
|
|
|
136
143
|
}
|
|
137
144
|
}
|
|
138
145
|
listener = bound as Server<T>;
|
|
146
|
+
relaySessions = sessions;
|
|
139
147
|
setStatus("listening", bound.port ?? (requestedPort > 0 ? requestedPort : null), null);
|
|
140
148
|
};
|
|
141
149
|
|
|
@@ -159,8 +167,13 @@ export function createLinkListenerLifecycle<T>(deps: LinkListenerDeps = {}): Lin
|
|
|
159
167
|
const flight = (async () => {
|
|
160
168
|
if (ensureFlight) await ensureFlight;
|
|
161
169
|
const current = listener;
|
|
162
|
-
|
|
163
|
-
|
|
170
|
+
// Retain the bound port AND ingress ownership while an authenticated connection is
|
|
171
|
+
// reserved or a dispatched response is still being consumed. No new proofs are issued.
|
|
172
|
+
if (current) {
|
|
173
|
+
await relaySessions?.drain();
|
|
174
|
+
await current.stop(false);
|
|
175
|
+
}
|
|
176
|
+
if (listener === current) { listener = null; relaySessions = undefined; }
|
|
164
177
|
// Closing is the caller saying "no links now": an earlier bind or persist failure no
|
|
165
178
|
// longer describes anything, so the status reads off either way.
|
|
166
179
|
setStatus("off", null, null);
|
|
@@ -194,6 +207,10 @@ export function createLinkListenerLifecycle<T>(deps: LinkListenerDeps = {}): Lin
|
|
|
194
207
|
close,
|
|
195
208
|
async stop() {
|
|
196
209
|
stopped = true;
|
|
210
|
+
// Process shutdown is deliberately forceful, unlike last-link removal. Every
|
|
211
|
+
// authenticated socket is closed, and the client transport cannot reconnect it.
|
|
212
|
+
relaySessions?.abortReservations();
|
|
213
|
+
if (listener) await listener.stop(true);
|
|
197
214
|
await close();
|
|
198
215
|
startContext = undefined;
|
|
199
216
|
setStatus("off", null, null);
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import type { Server } from "bun";
|
|
2
|
+
import {
|
|
3
|
+
LINK_RELAY_AUTH_PATH, LINK_RELAY_AUTH_TIMEOUT_MS, LINK_RELAY_AUTH_VERSION,
|
|
4
|
+
LINK_RELAY_SESSION_HEADER, linkRelayProof, linkRelayProofMatches,
|
|
5
|
+
} from "../../link/relay-auth";
|
|
6
|
+
|
|
7
|
+
interface Session {
|
|
8
|
+
peer: string;
|
|
9
|
+
keyId: string;
|
|
10
|
+
linkId: string;
|
|
11
|
+
fingerprint: string;
|
|
12
|
+
expiresAt: number;
|
|
13
|
+
timer: ReturnType<typeof setTimeout>;
|
|
14
|
+
release: () => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface LinkRelaySessionOptions {
|
|
18
|
+
fingerprints: (keyId: string, linkId: string) => readonly string[];
|
|
19
|
+
/** Tests can use a bounded shorter reservation; production uses five seconds. */
|
|
20
|
+
reservationMs?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** One book per bound listener. A pending proof reserves the bind before the data request arrives. */
|
|
24
|
+
export function createLinkRelaySessions(options: LinkRelaySessionOptions) {
|
|
25
|
+
const sessions = new Map<string, Session>();
|
|
26
|
+
const drains = new Set<() => void>();
|
|
27
|
+
let closing = false;
|
|
28
|
+
let active = 0;
|
|
29
|
+
const reservationMs = options.reservationMs ?? LINK_RELAY_AUTH_TIMEOUT_MS;
|
|
30
|
+
const refuse = () => new Response(null, { status: 404 });
|
|
31
|
+
const peerOf = (req: Request, server: Server<unknown>): string | null => {
|
|
32
|
+
const peer = server.requestIP(req);
|
|
33
|
+
return peer ? `${peer.address}:${peer.port}` : null;
|
|
34
|
+
};
|
|
35
|
+
const acquire = () => {
|
|
36
|
+
active += 1;
|
|
37
|
+
let done = false;
|
|
38
|
+
return () => {
|
|
39
|
+
if (done) return;
|
|
40
|
+
done = true;
|
|
41
|
+
if (--active === 0) { for (const resolve of drains) resolve(); drains.clear(); }
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
const consume = (nonce: string): Session | undefined => {
|
|
45
|
+
const session = sessions.get(nonce);
|
|
46
|
+
if (session) { clearTimeout(session.timer); sessions.delete(nonce); }
|
|
47
|
+
return session;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
/** Forced process shutdown retires unused proofs before closing their sockets. */
|
|
52
|
+
abortReservations(): void {
|
|
53
|
+
closing = true;
|
|
54
|
+
for (const nonce of [...sessions.keys()]) consume(nonce)?.release();
|
|
55
|
+
},
|
|
56
|
+
/** Refuse new work, but keep reservations and active bodies alive until they settle. */
|
|
57
|
+
async drain(): Promise<void> {
|
|
58
|
+
closing = true;
|
|
59
|
+
if (active > 0) await new Promise<void>(resolve => drains.add(resolve));
|
|
60
|
+
},
|
|
61
|
+
async dispatch<T>(req: Request, server: Server<T>, dispatch: (req: Request, server: Server<T>) => Promise<Response>): Promise<Response> {
|
|
62
|
+
const url = new URL(req.url);
|
|
63
|
+
if (url.pathname === LINK_RELAY_AUTH_PATH) {
|
|
64
|
+
if (closing || req.method !== "GET" || sessions.size >= 256 || req.headers.has("upgrade")
|
|
65
|
+
|| req.headers.has("authorization") || req.headers.has("x-opencodex-api-key")) return refuse();
|
|
66
|
+
const q = url.searchParams;
|
|
67
|
+
if (q.get("version") !== LINK_RELAY_AUTH_VERSION || [...q.keys()].length !== 5
|
|
68
|
+
|| ["version", "key", "link", "nonce", "proof"].some(name => q.getAll(name).length !== 1)) return refuse();
|
|
69
|
+
const keyId = q.get("key")!, linkId = q.get("link")!, nonce = q.get("nonce")!;
|
|
70
|
+
const peer = peerOf(req, server as Server<unknown>);
|
|
71
|
+
if (!peer || sessions.has(nonce)) return refuse();
|
|
72
|
+
const fingerprint = options.fingerprints(keyId, linkId).find(candidate =>
|
|
73
|
+
linkRelayProofMatches(q.get("proof"), linkRelayProof(candidate, "caller", keyId, linkId, nonce)));
|
|
74
|
+
if (!fingerprint) return refuse();
|
|
75
|
+
const release = acquire();
|
|
76
|
+
const timer = setTimeout(() => { consume(nonce)?.release(); }, reservationMs);
|
|
77
|
+
sessions.set(nonce, { peer, keyId, linkId, fingerprint, release, timer, expiresAt: Date.now() + reservationMs });
|
|
78
|
+
return new Response(null, { status: 204, headers: {
|
|
79
|
+
"x-opencodex-link-proof": linkRelayProof(fingerprint, "listener", keyId, linkId, nonce)!,
|
|
80
|
+
"cache-control": "no-store",
|
|
81
|
+
} });
|
|
82
|
+
}
|
|
83
|
+
const nonce = req.headers.get(LINK_RELAY_SESSION_HEADER);
|
|
84
|
+
let release: () => void;
|
|
85
|
+
if (nonce !== null) {
|
|
86
|
+
// A proof received on one HTTP socket never authorizes another socket, even on the same port.
|
|
87
|
+
const session = consume(nonce);
|
|
88
|
+
if (!session) return refuse();
|
|
89
|
+
release = session.release;
|
|
90
|
+
if (session.peer !== peerOf(req, server as Server<unknown>) || Date.now() >= session.expiresAt
|
|
91
|
+
|| !options.fingerprints(session.keyId, session.linkId).includes(session.fingerprint)) {
|
|
92
|
+
release(); return refuse();
|
|
93
|
+
}
|
|
94
|
+
// Keep the native Request identity: Bun's requestIP/timeout APIs need the original request.
|
|
95
|
+
try { req.headers.delete(LINK_RELAY_SESSION_HEADER); } catch { release(); return refuse(); }
|
|
96
|
+
} else {
|
|
97
|
+
if (closing) return new Response(null, { status: 503 });
|
|
98
|
+
// Older peers may still use the ordinary key admission. Its response also owns a drain lease.
|
|
99
|
+
release = acquire();
|
|
100
|
+
}
|
|
101
|
+
// Socket cancellation before headers is terminal too; an upstream handler that is
|
|
102
|
+
// settling its own cleanup cannot keep a dead transport's listener lease forever.
|
|
103
|
+
const beforeHeadersAbort = () => release();
|
|
104
|
+
req.signal.addEventListener("abort", beforeHeadersAbort, { once: true });
|
|
105
|
+
if (req.signal.aborted) beforeHeadersAbort();
|
|
106
|
+
try {
|
|
107
|
+
const response = await dispatch(req, server);
|
|
108
|
+
req.signal.removeEventListener("abort", beforeHeadersAbort);
|
|
109
|
+
if (!response.body || req.method === "HEAD") { release(); return response; }
|
|
110
|
+
const reader = response.body.getReader();
|
|
111
|
+
let ended = false;
|
|
112
|
+
const finish = () => {
|
|
113
|
+
if (ended) return;
|
|
114
|
+
ended = true;
|
|
115
|
+
req.signal.removeEventListener("abort", aborted);
|
|
116
|
+
release();
|
|
117
|
+
};
|
|
118
|
+
const cancel = async (reason?: unknown) => {
|
|
119
|
+
try { await reader.cancel(reason); } finally { finish(); }
|
|
120
|
+
};
|
|
121
|
+
const aborted = () => { void cancel(req.signal.reason).catch(() => {}); };
|
|
122
|
+
req.signal.addEventListener("abort", aborted, { once: true });
|
|
123
|
+
if (req.signal.aborted) aborted();
|
|
124
|
+
const body = new ReadableStream<Uint8Array>({
|
|
125
|
+
async pull(controller) {
|
|
126
|
+
try {
|
|
127
|
+
const next = await reader.read();
|
|
128
|
+
if (next.done) { finish(); controller.close(); }
|
|
129
|
+
else controller.enqueue(next.value);
|
|
130
|
+
} catch (error) { finish(); controller.error(error); }
|
|
131
|
+
},
|
|
132
|
+
cancel,
|
|
133
|
+
}, { highWaterMark: 0 });
|
|
134
|
+
return new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers });
|
|
135
|
+
} catch (error) { req.signal.removeEventListener("abort", beforeHeadersAbort); release(); throw error; }
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { serviceApiTokenFingerprint } from "../../lib/service-secrets";
|
|
1
2
|
import type { Server } from "bun";
|
|
2
3
|
import type { OcxConfig } from "../../types";
|
|
3
4
|
import {
|
|
@@ -81,7 +82,16 @@ export function createOptionalListenerSet<T>(linkDeps: LinkListenerDeps = {}): O
|
|
|
81
82
|
linkSupervisor: () => supervisor,
|
|
82
83
|
start(ctx) {
|
|
83
84
|
activeConfig = ctx.config;
|
|
84
|
-
linkListener.start({ dispatch: ctx.dispatch, maxRequestBodySize: ctx.maxRequestBodySize
|
|
85
|
+
linkListener.start({ dispatch: ctx.dispatch, maxRequestBodySize: ctx.maxRequestBodySize,
|
|
86
|
+
keyFingerprints: apiKeyId => {
|
|
87
|
+
const entry = activeConfig?.apiKeys?.find(candidate => candidate.id === apiKeyId);
|
|
88
|
+
if (!entry?.key) return [];
|
|
89
|
+
const fingerprints = [serviceApiTokenFingerprint(entry.key)];
|
|
90
|
+
const pending = entry.pendingRotation;
|
|
91
|
+
if (pending && Date.parse(pending.expiresAt) > Date.now()) fingerprints.push(serviceApiTokenFingerprint(pending.key));
|
|
92
|
+
return fingerprints;
|
|
93
|
+
},
|
|
94
|
+
});
|
|
85
95
|
unregisterSupervisorAdmission ??= linkListener.onAuthenticatedCatalog(apiKeyId => supervisor.notifyAuthenticatedRequest?.(apiKeyId));
|
|
86
96
|
if (linkListenerOwnsTarget(linkListener.status())) {
|
|
87
97
|
supervisor.start();
|
|
@@ -170,6 +170,7 @@ import {
|
|
|
170
170
|
createLocalAttestationProof,
|
|
171
171
|
} from "../../lib/local-management-attestation";
|
|
172
172
|
import { SYSTEM_RESTART_CAPABILITY_VERSION } from "../../lib/system-restart-contract";
|
|
173
|
+
import { LOCAL_MANAGEMENT_NONCE_HEADER } from "../../lib/local-management-capability";
|
|
173
174
|
import { LOCAL_PROVIDER_RELOAD_CAPABILITY_VERSION } from "../../lib/local-provider-reload-contract";
|
|
174
175
|
import { LOCAL_ASIDE_SYNC_CAPABILITY_VERSION } from "../../lib/local-aside-sync-contract";
|
|
175
176
|
import {
|
|
@@ -697,6 +698,17 @@ export function createServeOptions(ctx: ServeOptionsContext) {
|
|
|
697
698
|
trustedLoopback: trustedLoopbackForIngress(ingress, config.hostname ?? "127.0.0.1"),
|
|
698
699
|
guiSessionIssuance: managementSessionIssuance(req, managementAuth),
|
|
699
700
|
});
|
|
701
|
+
// A local read capability authenticates the request; sign its single-use nonce so the
|
|
702
|
+
// caller can tell this answer came from this process and not from whoever holds the port.
|
|
703
|
+
const readNonce = principal === "local-read-capability" ? req.headers.get(LOCAL_MANAGEMENT_NONCE_HEADER) : null;
|
|
704
|
+
const readProof = readNonce
|
|
705
|
+
? createLocalAttestationProof(localAttestationSecret, readNonce, process.pid, localManagementAuth.port) : null;
|
|
706
|
+
if (mgmtResponse && readProof) {
|
|
707
|
+
const headers = new Headers(mgmtResponse.headers);
|
|
708
|
+
headers.set(LOCAL_ATTESTATION_PROOF_HEADER, readProof);
|
|
709
|
+
const signed = new Response(mgmtResponse.body, { status: mgmtResponse.status, statusText: mgmtResponse.statusText, headers });
|
|
710
|
+
return withManagementCors(signed, req, config);
|
|
711
|
+
}
|
|
700
712
|
if (mgmtResponse) return withManagementCors(mgmtResponse, req, config);
|
|
701
713
|
return withManagementCors(formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${url.pathname}`), req, config);
|
|
702
714
|
}
|
|
@@ -91,6 +91,7 @@ export interface ClientEncodedDelivery {
|
|
|
91
91
|
declaredToolNames?: ReadonlySet<string>;
|
|
92
92
|
toolParameterSchemas?: ReadonlyMap<string, Record<string, unknown>>;
|
|
93
93
|
freeformToolNames?: Set<string>;
|
|
94
|
+
bareCustomToolNames?: ReadonlySet<string>;
|
|
94
95
|
toolSearchToolNames?: Set<string>;
|
|
95
96
|
};
|
|
96
97
|
stallTimeoutSec?: number;
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { readRuntimePort, type RuntimePortState } from "../config/process-state";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
LOCAL_ATTESTATION_PROOF_HEADER,
|
|
4
|
+
createLocalAttestationChallenge,
|
|
5
|
+
verifyLocalAttestationProof,
|
|
6
|
+
} from "../lib/local-management-attestation";
|
|
3
7
|
import {
|
|
4
8
|
LOCAL_MANAGEMENT_CAPABILITY_HEADER,
|
|
5
9
|
LOCAL_MANAGEMENT_CAPABILITY_EXPIRES_AT_HEADER,
|
|
@@ -14,7 +18,10 @@ import { probeHostname, type LiveProxy } from "./proxy-liveness";
|
|
|
14
18
|
|
|
15
19
|
export type LocalManagementReadResult =
|
|
16
20
|
| { kind: "response"; response: Response; targetPid: number }
|
|
17
|
-
| {
|
|
21
|
+
| {
|
|
22
|
+
kind: "unavailable";
|
|
23
|
+
reason: "unattested-target" | "runtime-mismatch" | "capability-unavailable" | "transport" | "unattested-response";
|
|
24
|
+
};
|
|
18
25
|
|
|
19
26
|
export interface LocalManagementReadDeps {
|
|
20
27
|
fetchImpl?: typeof fetch;
|
|
@@ -25,6 +32,12 @@ export interface LocalManagementReadDeps {
|
|
|
25
32
|
|
|
26
33
|
export interface LocalManagementReadRequestDeps extends LocalManagementReadDeps {
|
|
27
34
|
timeoutMs?: number;
|
|
35
|
+
/**
|
|
36
|
+
* Require the response to carry the server's attestation over this request's nonce. The
|
|
37
|
+
* capability authenticates the request to the real server; only this proof tells the caller
|
|
38
|
+
* that the answer came from it and not from whatever process now holds the port.
|
|
39
|
+
*/
|
|
40
|
+
requireResponseProof?: boolean;
|
|
28
41
|
}
|
|
29
42
|
|
|
30
43
|
/**
|
|
@@ -83,6 +96,12 @@ export async function fetchBoundLocalManagementRead(
|
|
|
83
96
|
signal: AbortSignal.timeout(deps.timeoutMs ?? 4_000),
|
|
84
97
|
},
|
|
85
98
|
);
|
|
99
|
+
if (deps.requireResponseProof && !verifyLocalAttestationProof(
|
|
100
|
+
runtime.attestationSecret, nonce, target.pid, target.port, response.headers.get(LOCAL_ATTESTATION_PROOF_HEADER),
|
|
101
|
+
)) {
|
|
102
|
+
void response.body?.cancel().catch(() => {});
|
|
103
|
+
return { kind: "unavailable", reason: "unattested-response" };
|
|
104
|
+
}
|
|
86
105
|
return { kind: "response", response, targetPid: target.pid };
|
|
87
106
|
} catch {
|
|
88
107
|
return { kind: "unavailable", reason: "transport" };
|