@bitkyc08/opencodex 2.75.0 → 2.76.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/README.md +6 -1
- package/bin/ocx.mjs +12 -1
- package/gui/dist/assets/App-CddorHdA.js +51 -0
- package/gui/dist/assets/App-DMudtISW.css +1 -0
- package/gui/dist/assets/{Tray-CGAD2AGk.js → Tray-DjNwwrWS.js} +1 -1
- package/gui/dist/assets/index-Btze1I3T.css +1 -0
- package/gui/dist/assets/index-CvnY7E6i.js +86 -0
- package/gui/dist/assets/quota-summary-B_AWpHfP.js +1 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/opengateway.svg +12 -0
- package/package.json +1 -1
- package/src/adapters/anthropic/account-metadata.ts +17 -0
- package/src/adapters/anthropic/client-identity.ts +69 -0
- package/src/adapters/anthropic/passthrough.ts +23 -4
- package/src/adapters/anthropic.ts +18 -1
- package/src/adapters/command-code-project-context.ts +7 -11
- package/src/adapters/devin/cloud-direct/tool-schema.ts +12 -48
- package/src/adapters/kiro-retry.ts +23 -8
- package/src/adapters/openai-chat/messages.ts +1 -1
- package/src/adapters/registry.ts +8 -1
- package/src/adapters/zed.ts +377 -0
- package/src/bridge/sse.ts +4 -2
- package/src/chatgpt/app-server-shim/app-server-rewrite.ts +45 -0
- package/src/chatgpt/app-server-shim/bundle-trust.ts +77 -0
- package/src/chatgpt/app-server-shim/filter.ts +157 -0
- package/src/chatgpt/app-server-shim/gate-rewrite.ts +99 -0
- package/src/chatgpt/app-server-shim/launcher.ts +91 -0
- package/src/claude/desktop-3p-startup.ts +62 -0
- package/src/claude/desktop-3p.ts +5 -0
- package/src/claude/first-party-settings.ts +14 -0
- package/src/claude/intercept/cli-catalog.ts +150 -0
- package/src/claude/intercept/cli-picker.ts +82 -0
- package/src/claude/intercept/listener.ts +21 -6
- package/src/claude/intercept/picker-bootstrap.ts +23 -5
- package/src/claude/intercept/picker-models.ts +57 -13
- package/src/claude/intercept/runtime.ts +30 -2
- package/src/claude/intercept/settings.ts +3 -0
- package/src/cli/agent.ts +172 -0
- package/src/cli/alias.ts +2 -1
- package/src/cli/capabilities-command.ts +3 -2
- package/src/cli/capabilities.ts +87 -0
- package/src/cli/chatgpt-command.ts +140 -0
- package/src/cli/combo.ts +144 -19
- package/src/cli/config-command.ts +3 -1
- package/src/cli/dispatch.ts +10 -1
- package/src/cli/help.ts +1 -0
- package/src/cli/index.ts +5 -24
- package/src/cli/init.ts +16 -2
- package/src/cli/integrations.ts +13 -0
- package/src/cli/internal-command.ts +12 -5
- package/src/cli/observe.ts +6 -1
- package/src/cli/registry.ts +6 -0
- package/src/cli/route-policy.ts +3 -1
- package/src/cli/runtime-api.ts +5 -2
- package/src/cli/status-oauth.ts +5 -0
- package/src/cli/status.ts +8 -1
- package/src/cli/stop-approval.ts +55 -16
- package/src/cli/stop-report.ts +5 -0
- package/src/clients/lazycodex.ts +48 -0
- package/src/clients/omo-role-models.ts +111 -0
- package/src/codex/account-credit-use.ts +99 -0
- package/src/codex/account-lifecycle.ts +2 -0
- package/src/codex/agent-role-models.ts +171 -0
- package/src/codex/auth-api/account-list.ts +10 -3
- package/src/codex/auth-api/routes.ts +34 -0
- package/src/codex/auth-context.ts +52 -11
- package/src/codex/catalog/discovered-natives.ts +11 -1
- package/src/codex/catalog/gather-capture.ts +6 -1
- package/src/codex/catalog/model-hints.ts +9 -1
- package/src/codex/catalog/provider-models.ts +60 -0
- package/src/codex/catalog/routed-gather.ts +30 -5
- package/src/codex/convergence-types.ts +2 -1
- package/src/codex/main-account-external-usage.ts +62 -0
- package/src/codex/main-account-hard-lock.ts +24 -13
- package/src/codex/native-profile-processes.ts +53 -16
- package/src/codex/prompt-layers/toml-edit.ts +1 -1
- package/src/codex/quota-types.ts +3 -7
- package/src/codex/quota.ts +19 -4
- package/src/codex/role-auto-assign.ts +159 -0
- package/src/codex/role-sizing-limits.ts +2 -0
- package/src/codex/role-sizing.ts +216 -0
- package/src/codex/routing/selection.ts +36 -0
- package/src/codex/routing/thread-affinity.ts +2 -0
- package/src/codex/routing.ts +4 -4
- package/src/codex/subagent-model-fallback.ts +82 -26
- package/src/codex/subagent-selectable-models.ts +32 -0
- package/src/combos/index.ts +18 -0
- package/src/combos/jev-decision-contract.ts +20 -0
- package/src/combos/jev-dispatch.ts +18 -0
- package/src/combos/jev-model-backend.ts +127 -0
- package/src/combos/jev.ts +249 -33
- package/src/combos/types.ts +125 -2
- package/src/config/diagnostics.ts +18 -0
- package/src/config/macos-system-proxy.ts +72 -15
- package/src/config/owner-registry.ts +13 -4
- package/src/config/process-state.ts +17 -8
- package/src/config/proxy-env.ts +35 -5
- package/src/config/schema/config-schema.ts +30 -1
- package/src/config/schema/leaf-validators.ts +4 -0
- package/src/config/serving-runtimes.ts +95 -1
- package/src/generated/compatibility-version.json +336 -156
- package/src/lib/errors.ts +7 -7
- package/src/lib/jsonc.ts +13 -0
- package/src/lib/local-chat-completion.ts +144 -0
- package/src/lib/provider-outbound.ts +87 -1
- package/src/lib/provider-runtime-fetch.ts +23 -0
- package/src/lib/provider-tls-profile.ts +221 -0
- package/src/lib/request-execution-budget.ts +16 -6
- package/src/lib/spend-reservation-ledger.ts +54 -20
- package/src/lib/sse-decoder.ts +20 -17
- package/src/lib/synced-state-location.ts +96 -0
- package/src/lib/winsw.ts +14 -5
- package/src/oauth/anthropic-account-refusal.ts +53 -4
- package/src/oauth/anthropic-model-quota.ts +83 -0
- package/src/oauth/anthropic-rate-limit-policy.ts +37 -0
- package/src/oauth/anthropic-routing.ts +112 -58
- package/src/oauth/health.ts +39 -3
- package/src/oauth/index.ts +14 -0
- package/src/oauth/kiro-account-load.ts +10 -0
- package/src/oauth/zed.ts +134 -0
- package/src/providers/model-discovery.ts +19 -8
- package/src/providers/model-rename-fields.ts +1 -0
- package/src/providers/provider-id-rewrite.ts +9 -2
- package/src/providers/quota/account-cache.ts +29 -7
- package/src/providers/quota/anthropic-cooldown-recovery.ts +18 -0
- package/src/providers/quota/anthropic-family-headers.ts +25 -0
- package/src/providers/quota/vendor-probes-oauth.ts +5 -1
- package/src/providers/quota-types.ts +4 -0
- package/src/providers/registry/entries-core.ts +18 -0
- package/src/providers/registry/entries-extended.ts +55 -0
- package/src/providers/registry/types.ts +3 -0
- package/src/providers/xai-fast-model.ts +12 -4
- package/src/providers/zed.ts +598 -0
- package/src/responses/state/snapshot-select.ts +22 -9
- package/src/responses/state.ts +2 -3
- package/src/router.ts +9 -0
- package/src/server/anthropic-rate-limit-headers.ts +7 -0
- package/src/server/auth-cors.ts +7 -0
- package/src/server/chat-completions.ts +9 -4
- package/src/server/claude-messages.ts +59 -21
- package/src/server/index/claude-intercept-lifecycle.ts +71 -25
- package/src/server/index/optional-listeners.ts +4 -0
- package/src/server/index/spend-ledger-lifecycle.ts +4 -0
- package/src/server/management/agent-settings-routes.ts +74 -37
- package/src/server/management/claude-desktop-picker-routes.ts +7 -0
- package/src/server/management/claude-intercept-routes.ts +35 -0
- package/src/server/management/codex-agent-role-routes.ts +122 -0
- package/src/server/management/codex-role-auto-assign.ts +208 -0
- package/src/server/management/combo-routes.ts +62 -5
- package/src/server/management/config-routes.ts +35 -3
- package/src/server/management/context.ts +4 -1
- package/src/server/management/decision-discovery.ts +74 -0
- package/src/server/management/decision-model-validation.ts +80 -0
- package/src/server/management/decision-routes.ts +160 -0
- package/src/server/management/native-integration-routes.ts +16 -0
- package/src/server/management/provider-routes.ts +30 -34
- package/src/server/management/route-registry.ts +12 -0
- package/src/server/management/sibling-guard.ts +1 -0
- package/src/server/management/zed-provider-probe.ts +38 -0
- package/src/server/management-api.ts +12 -1
- package/src/server/messages-native-oauth.ts +7 -2
- package/src/server/messages-native.ts +7 -3
- package/src/server/relay.ts +3 -1
- package/src/server/request-log.ts +2 -0
- package/src/server/responses/adapter-continuation.ts +3 -3
- package/src/server/responses/adapter-dispatch.ts +18 -3
- package/src/server/responses/compact.ts +14 -1
- package/src/server/responses/core-auth.ts +2 -0
- package/src/server/responses/core-combo.ts +67 -11
- package/src/server/responses/core-normalize.ts +21 -1
- package/src/server/responses/core-options.ts +11 -1
- package/src/server/responses/core.ts +5 -3
- package/src/server/responses/fetch-helpers.ts +6 -3
- package/src/server/responses/jev-model-invoke.ts +190 -0
- package/src/server/responses/passthrough-delivery.ts +16 -0
- package/src/server/responses/policy-fallback.ts +55 -3
- package/src/server/responses/policy-request-scope.ts +80 -0
- package/src/server/responses/request-prepare.ts +70 -18
- package/src/server/responses/request-transport.ts +54 -23
- package/src/server/responses/run-turn-execution.ts +1 -0
- package/src/server/responses/sidecar-execution.ts +9 -3
- package/src/server/responses-hosted-image-display.ts +225 -0
- package/src/service/managing-cli.ts +34 -4
- package/src/service/systemd-env.ts +75 -0
- package/src/service/windows-guarded-manager.ts +60 -19
- package/src/service/windows-scheduler.ts +22 -3
- package/src/service/windows-taskxml.ts +32 -10
- package/src/service-manager-probe.ts +72 -19
- package/src/types/config.ts +31 -1
- package/src/types/provider.ts +2 -0
- package/src/types/request.ts +2 -0
- package/src/update/index.ts +39 -6
- package/src/update/job.ts +63 -63
- package/src/update/npm-cache-preflight.d.mts +33 -1
- package/src/update/npm-cache-preflight.mjs +131 -18
- package/src/update/ownership-transaction.ts +19 -1
- package/src/update/restart-ownership.ts +80 -6
- package/src/update/transactional-install.d.mts +2 -0
- package/src/update/transactional-install.mjs +6 -1
- package/src/usage/jev-stats.ts +28 -1
- package/src/usage/log.ts +1 -1
- package/src/vision/anthropic-describe.ts +5 -3
- package/src/vision/index.ts +1 -0
- package/src/vision/plan.ts +2 -1
- package/src/vision/routed-describe.ts +29 -121
- package/src/web-search/alpha-search.ts +3 -3
- package/src/web-search/anthropic-executor.ts +6 -4
- package/src/web-search/index.ts +1 -1
- package/src/web-search/loop.ts +3 -3
- package/src/web-search/passthrough-bridge.ts +2 -1
- package/src/web-search/run-turn-loop.ts +1 -1
- package/src/web-search/sidecar-providers.ts +2 -1
- package/gui/dist/assets/App-ia4U0ufY.css +0 -1
- package/gui/dist/assets/App-xm0zGNOe.js +0 -51
- package/gui/dist/assets/index-DBT3Q31Y.css +0 -1
- package/gui/dist/assets/index-D_RGAT6Q.js +0 -86
- package/gui/dist/assets/quota-summary-CUFq48t-.js +0 -1
package/src/lib/errors.ts
CHANGED
|
@@ -18,7 +18,7 @@ export const ENCRYPTED_FUNCTION_OUTPUT_REJECTION =
|
|
|
18
18
|
*/
|
|
19
19
|
export const SEND_BUDGET_EXHAUSTED_CODE = "request_send_budget_exhausted";
|
|
20
20
|
|
|
21
|
-
/**
|
|
21
|
+
/** First nonblank string across the canonical upstream error paths, in priority order. */
|
|
22
22
|
export function upstreamErrorMessageFromPayload(payload: unknown): string | undefined {
|
|
23
23
|
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return undefined;
|
|
24
24
|
const json = payload as {
|
|
@@ -31,14 +31,14 @@ export function upstreamErrorMessageFromPayload(payload: unknown): string | unde
|
|
|
31
31
|
incomplete_details?: { message?: unknown };
|
|
32
32
|
};
|
|
33
33
|
};
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
34
|
+
const messages = [json.error?.message,
|
|
35
|
+
json.last_error?.message,
|
|
36
|
+
json.response?.error?.message,
|
|
37
|
+
json.response?.incomplete_details?.message,
|
|
38
38
|
// The Responses stream error event carries a flat message (type/code/message),
|
|
39
39
|
// unlike the response.failed envelope the branches above already cover.
|
|
40
|
-
|
|
41
|
-
return typeof message === "string"
|
|
40
|
+
json.type === "error" ? json.message : undefined];
|
|
41
|
+
return messages.find((message): message is string => typeof message === "string" && message.trim().length > 0);
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
/** OpenAI / Codex hard block for high-risk cybersecurity activity (HTTP 400 or mid-stream). */
|
package/src/lib/jsonc.ts
CHANGED
|
@@ -108,3 +108,16 @@ export function canonicalizeJsonc(text: string): string {
|
|
|
108
108
|
export function parseJsonc(text: string): unknown {
|
|
109
109
|
return JSON.parse(canonicalizeJsonc(text));
|
|
110
110
|
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* True when the text carries a `//` or block comment outside a string. A writer that
|
|
114
|
+
* re-serializes the document would drop those, so it checks this before writing.
|
|
115
|
+
* An unterminated block comment counts as a comment.
|
|
116
|
+
*/
|
|
117
|
+
export function hasJsoncComments(text: string): boolean {
|
|
118
|
+
try {
|
|
119
|
+
return stripJsonComments(text) !== text;
|
|
120
|
+
} catch {
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One non-streaming chat completion through this proxy's own /v1/chat/completions.
|
|
3
|
+
*
|
|
4
|
+
* The way opencodex makes a model call of its own: the request goes back through the router like
|
|
5
|
+
* any client's, so every provider the user has configured is reachable and none needs its own
|
|
6
|
+
* client here. The routed vision describer and the Codex role sizing both send through it.
|
|
7
|
+
*
|
|
8
|
+
* Destination: the unauthenticated loopback listener when one is enabled, otherwise the bind
|
|
9
|
+
* address (#4236), resolved by localInferenceDestination rather than composing 127.0.0.1 by hand.
|
|
10
|
+
* Admission: localAdmissionToken (env token, hardened service token file, first apiKeys entry),
|
|
11
|
+
* sent as x-opencodex-api-key and never Authorization, because an admission secret in a
|
|
12
|
+
* forwardable header is a forwarding hazard. Never the admin token.
|
|
13
|
+
*/
|
|
14
|
+
import type { OcxConfig } from "../types";
|
|
15
|
+
import { localAdmissionToken, localInferenceDestination } from "./local-destinations";
|
|
16
|
+
import { signalWithTimeout, cancelBodyOnAbort } from "./abort";
|
|
17
|
+
import { readBoundedResponseBytes } from "./bounded-body";
|
|
18
|
+
import { redactSecretString } from "./redact";
|
|
19
|
+
import { sidecarEnter } from "./sidecar-tracker";
|
|
20
|
+
import { configuredPort } from "../server/auth-cors";
|
|
21
|
+
|
|
22
|
+
export type LocalChatConfig = Pick<OcxConfig, "port" | "hostname" | "apiKeys" | "unauthenticatedLoopbackListener">;
|
|
23
|
+
|
|
24
|
+
export interface LocalChatCompletionRequest {
|
|
25
|
+
readonly config: LocalChatConfig;
|
|
26
|
+
readonly body: Record<string, unknown>;
|
|
27
|
+
/** Names the caller in error text and logs, e.g. "routed describe". */
|
|
28
|
+
readonly label: string;
|
|
29
|
+
/** Log prefix and sidecar breadcrumb, e.g. "vision". */
|
|
30
|
+
readonly logTag: string;
|
|
31
|
+
readonly timeoutMs: number;
|
|
32
|
+
readonly maxResponseBytes: number;
|
|
33
|
+
/**
|
|
34
|
+
* Count raw bytes while reading and cancel the body at the bound. Unset keeps the routed
|
|
35
|
+
* describer's bound: the whole body is read, then its UTF-16 length is compared.
|
|
36
|
+
*/
|
|
37
|
+
readonly boundWhileStreaming?: boolean;
|
|
38
|
+
readonly headers?: Record<string, string>;
|
|
39
|
+
readonly abortSignal?: AbortSignal;
|
|
40
|
+
/** Test seam; production always self-fetches the resolved local destination. */
|
|
41
|
+
readonly baseUrlOverride?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type LocalChatCompletionOutcome = { text: string; error?: string };
|
|
45
|
+
|
|
46
|
+
function destination(config: Pick<OcxConfig, "port" | "hostname" | "unauthenticatedLoopbackListener">) {
|
|
47
|
+
// config.port can be 0 (ephemeral bind, tests) or stale after a live port override; the
|
|
48
|
+
// server records its ACTUAL bound port via setCorsOrigin at startup, so prefer that when
|
|
49
|
+
// config carries no positive port. configuredPort() is itself 0 when _corsOrigin has no
|
|
50
|
+
// explicit port (a default-port origin), so the literal default has to backstop it or the
|
|
51
|
+
// composed URL names port 0 and the self-fetch cannot connect.
|
|
52
|
+
const port = config.port && config.port > 0
|
|
53
|
+
? config.port
|
|
54
|
+
: Number(configuredPort()) || 10_100;
|
|
55
|
+
return localInferenceDestination(config, port);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function localChatCompletionBaseUrl(
|
|
59
|
+
config: Pick<OcxConfig, "port" | "hostname" | "unauthenticatedLoopbackListener">,
|
|
60
|
+
): string {
|
|
61
|
+
return destination(config).origin;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function postLocalChatCompletion(request: LocalChatCompletionRequest): Promise<LocalChatCompletionOutcome> {
|
|
65
|
+
const { config, label, logTag } = request;
|
|
66
|
+
const headers: Record<string, string> = { "Content-Type": "application/json", ...request.headers };
|
|
67
|
+
const admission = localAdmissionToken(config);
|
|
68
|
+
if (admission) headers["x-opencodex-api-key"] = admission;
|
|
69
|
+
// A bind that demands admission with no resolvable credential would return 401 with a body
|
|
70
|
+
// the caller reports as a failure; naming the cause once is the difference between "it is
|
|
71
|
+
// broken" and a fixable configuration note.
|
|
72
|
+
if (!admission && !request.baseUrlOverride && destination(config).requiresAdmissionToken) {
|
|
73
|
+
console.warn(
|
|
74
|
+
`[${logTag}] ${label} has no opencodex data-plane credential for `
|
|
75
|
+
+ `${localChatCompletionBaseUrl(config)} — the self-fetch will be refused.`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const linkedSignal = signalWithTimeout(request.timeoutMs, request.abortSignal);
|
|
80
|
+
const sidecarExit = sidecarEnter(logTag);
|
|
81
|
+
const t0 = Date.now();
|
|
82
|
+
try {
|
|
83
|
+
const res = await fetch(`${request.baseUrlOverride ?? localChatCompletionBaseUrl(config)}/v1/chat/completions`, {
|
|
84
|
+
method: "POST",
|
|
85
|
+
headers,
|
|
86
|
+
body: JSON.stringify({ ...request.body, stream: false }),
|
|
87
|
+
signal: linkedSignal.signal,
|
|
88
|
+
redirect: "manual",
|
|
89
|
+
});
|
|
90
|
+
const detachBodyGuard = cancelBodyOnAbort(res.body, linkedSignal.signal);
|
|
91
|
+
try {
|
|
92
|
+
let raw: string;
|
|
93
|
+
if (request.boundWhileStreaming) {
|
|
94
|
+
const read = await readBoundedResponseBytes(res, { maxBytes: request.maxResponseBytes, signal: linkedSignal.signal });
|
|
95
|
+
if (read.oversized) return { text: "", error: `${label} response exceeded byte bound` };
|
|
96
|
+
raw = new TextDecoder().decode(read.bytes);
|
|
97
|
+
} else {
|
|
98
|
+
raw = await res.text();
|
|
99
|
+
if (raw.length > request.maxResponseBytes) {
|
|
100
|
+
return { text: "", error: `${label} response exceeded byte bound` };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (!res.ok) {
|
|
104
|
+
return { text: "", error: `${label} HTTP ${res.status}: ${redactSecretString(raw.slice(0, 200))}` };
|
|
105
|
+
}
|
|
106
|
+
let payload: unknown;
|
|
107
|
+
try { payload = JSON.parse(raw); } catch {
|
|
108
|
+
return { text: "", error: `${label} returned non-JSON` };
|
|
109
|
+
}
|
|
110
|
+
const content = extractChatContent(payload);
|
|
111
|
+
if (!content) return { text: "", error: `${label} returned no text` };
|
|
112
|
+
return { text: content };
|
|
113
|
+
} finally {
|
|
114
|
+
detachBodyGuard();
|
|
115
|
+
}
|
|
116
|
+
} catch (e) {
|
|
117
|
+
const kind = e instanceof Error && e.name === "TimeoutError" ? "timeout" : "connect_error";
|
|
118
|
+
console.warn(`[${logTag}] ${label} ${kind} (${Date.now() - t0}ms)`);
|
|
119
|
+
return { text: "", error: redactSecretString(e instanceof Error ? e.message : String(e)) };
|
|
120
|
+
} finally {
|
|
121
|
+
sidecarExit();
|
|
122
|
+
linkedSignal.cleanup();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function extractChatContent(payload: unknown): string | undefined {
|
|
127
|
+
if (!payload || typeof payload !== "object") return undefined;
|
|
128
|
+
const choices = (payload as { choices?: unknown }).choices;
|
|
129
|
+
if (!Array.isArray(choices) || choices.length === 0) return undefined;
|
|
130
|
+
const message = (choices[0] as { message?: unknown })?.message;
|
|
131
|
+
if (!message || typeof message !== "object") return undefined;
|
|
132
|
+
const content = (message as { content?: unknown }).content;
|
|
133
|
+
if (typeof content === "string" && content.trim().length > 0) return content;
|
|
134
|
+
// Some adapters emit content parts; join text parts.
|
|
135
|
+
if (Array.isArray(content)) {
|
|
136
|
+
const joined = content
|
|
137
|
+
.map(part => (part && typeof part === "object" && typeof (part as { text?: unknown }).text === "string"
|
|
138
|
+
? (part as { text: string }).text
|
|
139
|
+
: ""))
|
|
140
|
+
.join("");
|
|
141
|
+
if (joined.trim().length > 0) return joined;
|
|
142
|
+
}
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isIP } from "node:net";
|
|
1
2
|
import type { OcxProviderConfig } from "../types";
|
|
2
3
|
import {
|
|
3
4
|
assessUrlDestination,
|
|
@@ -30,6 +31,73 @@ export interface ProviderOutboundDependencies {
|
|
|
30
31
|
isCanonicalUrl?: (name: string, url: string) => boolean;
|
|
31
32
|
/** Recheck caller-owned credential authority after DNS and immediately before transport. */
|
|
32
33
|
beforeSend?: () => boolean;
|
|
34
|
+
/**
|
|
35
|
+
* Caller opt-in for a cleartext `http:` POST to a self-hosted service. Honoured only when the
|
|
36
|
+
* URL host is exactly `localhost` or an address literal in the narrow local allowlist
|
|
37
|
+
* (`localCleartextAddressAllowed`) AND the provider row itself sets `allowPrivateNetwork: true`
|
|
38
|
+
* (a registry default does not count). Every resolved answer must stay inside the same
|
|
39
|
+
* allowlist, and a proxied route is refused, so the cleartext body never leaves the host or
|
|
40
|
+
* LAN. Every other destination keeps the HTTPS-only POST gate. Defaults to refusing.
|
|
41
|
+
*/
|
|
42
|
+
allowLocalCleartextPost?: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function ipv4Octets(address: string): number[] | null {
|
|
46
|
+
return isIP(address) === 4 ? address.split(".").map(Number) : null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function ipv6Hextets(address: string): number[] | null {
|
|
50
|
+
if (isIP(address) !== 6 || address.includes("%")) return null;
|
|
51
|
+
let text = address.toLowerCase();
|
|
52
|
+
const lastColon = text.lastIndexOf(":");
|
|
53
|
+
const embedded = ipv4Octets(text.slice(lastColon + 1));
|
|
54
|
+
if (text.slice(lastColon + 1).includes(".")) {
|
|
55
|
+
if (!embedded) return null;
|
|
56
|
+
const [a, b, c, d] = embedded as [number, number, number, number];
|
|
57
|
+
text = `${text.slice(0, lastColon + 1)}${((a << 8) | b).toString(16)}:${((c << 8) | d).toString(16)}`;
|
|
58
|
+
}
|
|
59
|
+
const [head = "", tail] = text.split("::");
|
|
60
|
+
const headParts = head ? head.split(":") : [];
|
|
61
|
+
const tailParts = tail ? tail.split(":") : [];
|
|
62
|
+
const fill = tail === undefined ? 0 : 8 - headParts.length - tailParts.length;
|
|
63
|
+
const parts = [...headParts, ...Array<string>(Math.max(0, fill)).fill("0"), ...tailParts];
|
|
64
|
+
return parts.length === 8 ? parts.map(part => Number.parseInt(part, 16)) : null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The only addresses a cleartext provider POST may reach: IPv4 loopback 127/8, IPv4-mapped
|
|
69
|
+
* loopback ::ffff:127.0.0.0/104, ::1, RFC 1918 (10/8, 172.16/12, 192.168/16), and IPv6 ULA
|
|
70
|
+
* fc00::/7. Deliberately narrower than the private-network classification: CGNAT 100.64/10,
|
|
71
|
+
* NAT64 64:ff9b:1::/48, benchmark, documentation, multicast/broadcast, link-local, metadata and
|
|
72
|
+
* unspecified addresses never qualify.
|
|
73
|
+
*/
|
|
74
|
+
export function localCleartextAddressAllowed(address: string): boolean {
|
|
75
|
+
const v4 = ipv4Octets(address);
|
|
76
|
+
if (v4) {
|
|
77
|
+
const [a, b] = v4 as [number, number];
|
|
78
|
+
return a === 127 || a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168);
|
|
79
|
+
}
|
|
80
|
+
const v6 = ipv6Hextets(address);
|
|
81
|
+
if (!v6) return false;
|
|
82
|
+
if (v6.slice(0, 7).every(part => part === 0) && v6[7] === 1) return true;
|
|
83
|
+
if (v6.slice(0, 5).every(part => part === 0) && v6[5] === 0xffff && (v6[6]! >> 8) === 127) return true;
|
|
84
|
+
return (v6[0]! & 0xfe00) === 0xfc00;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function unbracketedHostname(url: URL): string {
|
|
88
|
+
const host = url.hostname;
|
|
89
|
+
return host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function localCleartextPostAllowed(
|
|
93
|
+
provider: ProviderOutboundConfig,
|
|
94
|
+
url: URL,
|
|
95
|
+
dependencies: ProviderOutboundDependencies,
|
|
96
|
+
): boolean {
|
|
97
|
+
if (dependencies.allowLocalCleartextPost !== true || url.protocol !== "http:") return false;
|
|
98
|
+
if (provider.allowPrivateNetwork !== true) return false;
|
|
99
|
+
const host = unbracketedHostname(url);
|
|
100
|
+
return host === "localhost" || localCleartextAddressAllowed(host);
|
|
33
101
|
}
|
|
34
102
|
|
|
35
103
|
export class ProviderOutboundPolicyError extends Error {
|
|
@@ -167,14 +235,21 @@ async function providerOutboundRequest(
|
|
|
167
235
|
if (dependencies.beforeSend?.() === false) throw new ProviderOutboundSendCancelledError("provider credential changed before send");
|
|
168
236
|
};
|
|
169
237
|
const postUrl = method === "POST" ? new URL(url) : undefined;
|
|
170
|
-
if (postUrl?.protocol !== undefined && postUrl.protocol !== "https:"
|
|
238
|
+
if (postUrl?.protocol !== undefined && postUrl.protocol !== "https:"
|
|
239
|
+
&& !localCleartextPostAllowed(provider, postUrl, dependencies)) {
|
|
171
240
|
throw new ProviderOutboundPolicyError("provider POST URL must use HTTPS");
|
|
172
241
|
}
|
|
242
|
+
// Only reachable after the narrow opt-in above admitted it.
|
|
243
|
+
const localCleartextPost = postUrl?.protocol === "http:";
|
|
173
244
|
// A provider entry keeps unknown configuration keys, so `fetch` can arrive as a value the
|
|
174
245
|
// operator wrote into the file rather than an executor a caller attached. Calling that would
|
|
175
246
|
// throw inside discovery and fail the provider for a reason nothing in its configuration
|
|
176
247
|
// explains; the built-in transport is what a configured value means.
|
|
177
248
|
if (typeof provider.fetch === "function") {
|
|
249
|
+
// An executor resolves names itself, so a cleartext POST through one must name an address.
|
|
250
|
+
if (localCleartextPost && !isIP(unbracketedHostname(postUrl!))) {
|
|
251
|
+
throw new ProviderOutboundPolicyError("a cleartext provider POST through a caller-supplied executor must use an address literal");
|
|
252
|
+
}
|
|
178
253
|
// A caller-owned executor decides its own transport, so a provider egress route cannot be
|
|
179
254
|
// applied to it. Refusing is the only honest answer: running the executor anyway would send
|
|
180
255
|
// the request by whatever route that executor picked while the configuration says otherwise.
|
|
@@ -231,6 +306,13 @@ async function providerOutboundRequest(
|
|
|
231
306
|
const proxyApplies = egress.kind === "inherit"
|
|
232
307
|
? globalProxy !== null && !noProxyMatches(parsed)
|
|
233
308
|
: providerProxy !== null;
|
|
309
|
+
// A cleartext body must go straight to the local peer: a proxy (global or provider route, and
|
|
310
|
+
// the DNS-failure proxy degradation below, which needs one) would carry it off-host unencrypted.
|
|
311
|
+
if (localCleartextPost && proxyApplies) {
|
|
312
|
+
throw new ProviderOutboundPolicyError(
|
|
313
|
+
`a cleartext provider POST cannot use an outbound proxy; add ${normalizeProxyHostname(parsed.hostname)} to NO_PROXY`,
|
|
314
|
+
);
|
|
315
|
+
}
|
|
234
316
|
const isCanonicalUrl = dependencies.isCanonicalUrl ?? (() => false);
|
|
235
317
|
// The IPv6 fake-IP gate keeps its stricter documented condition — a
|
|
236
318
|
// scheme-matched variable or a SOCKS5 ALL_PROXY, never a non-SOCKS
|
|
@@ -306,6 +388,10 @@ async function providerOutboundRequest(
|
|
|
306
388
|
`provider URL resolves to a private-network destination; add ${hostname} to NO_PROXY before using allowPrivateNetwork with an outbound proxy`,
|
|
307
389
|
);
|
|
308
390
|
}
|
|
391
|
+
if (localCleartextPost
|
|
392
|
+
&& !resolved.addresses.every(answer => localCleartextAddressAllowed(answer.address))) {
|
|
393
|
+
throw new ProviderOutboundPolicyError("a cleartext provider POST resolved outside the local address allowlist");
|
|
394
|
+
}
|
|
309
395
|
const requestOptions = {
|
|
310
396
|
headers: init.headers,
|
|
311
397
|
rejectUnauthorized: true,
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { OcxProviderConfig } from "../types";
|
|
2
|
+
|
|
3
|
+
export const RUNTIME_PROVIDER_FETCH = Symbol("opencodex.provider.runtime-fetch");
|
|
4
|
+
|
|
5
|
+
export interface RuntimeProviderFetch {
|
|
6
|
+
providerName: string;
|
|
7
|
+
origins: readonly string[];
|
|
8
|
+
fetch: typeof globalThis.fetch;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Return only an explicitly injected executor matching the provider and exact destination origin. */
|
|
12
|
+
export function runtimeProviderFetch(
|
|
13
|
+
provider: OcxProviderConfig,
|
|
14
|
+
providerName: string | undefined,
|
|
15
|
+
): typeof globalThis.fetch | undefined {
|
|
16
|
+
const runtime = (provider as OcxProviderConfig & { [RUNTIME_PROVIDER_FETCH]?: RuntimeProviderFetch })[RUNTIME_PROVIDER_FETCH];
|
|
17
|
+
if (!runtime || runtime.providerName !== providerName) return undefined;
|
|
18
|
+
try {
|
|
19
|
+
return runtime.origins.includes(new URL(provider.baseUrl).origin) ? runtime.fetch : undefined;
|
|
20
|
+
} catch {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import type { OcxProviderConfig } from "../types";
|
|
2
|
+
import { redactSecretString } from "./redact";
|
|
3
|
+
import { runtimeProviderFetch } from "./provider-runtime-fetch";
|
|
4
|
+
import { markEgressTransparentExecutor } from "./provider-egress";
|
|
5
|
+
import {
|
|
6
|
+
outboundProxyConfigured,
|
|
7
|
+
proxyEnvPresent,
|
|
8
|
+
resolveProxyRoute,
|
|
9
|
+
socks5ProxyFromEnv,
|
|
10
|
+
type ProxyEnvMap,
|
|
11
|
+
} from "./proxy-env";
|
|
12
|
+
|
|
13
|
+
export type ProviderTlsProfile = "antigravity-browser";
|
|
14
|
+
/**
|
|
15
|
+
* `pending` means the profile is configured and valid but no request has used it yet; the
|
|
16
|
+
* dashboard must not report a configured profile as `disabled` before its first send.
|
|
17
|
+
*/
|
|
18
|
+
export type ProviderTlsProfileStatus = "disabled" | "pending" | "active" | "failed";
|
|
19
|
+
export const ANTIGRAVITY_TLS_HOSTS = new Set([
|
|
20
|
+
"daily-cloudcode-pa.googleapis.com",
|
|
21
|
+
"cloudcode-pa.googleapis.com",
|
|
22
|
+
]);
|
|
23
|
+
type TlsRuntime = {
|
|
24
|
+
fetch(input: string | URL | Request, init?: RequestInit): Promise<Response>;
|
|
25
|
+
resolveProxyRoute?: typeof resolveProxyRoute;
|
|
26
|
+
env?: ProxyEnvMap;
|
|
27
|
+
};
|
|
28
|
+
let status = new Map<string, ProviderTlsProfileStatus>();
|
|
29
|
+
let runtime: TlsRuntime | undefined;
|
|
30
|
+
|
|
31
|
+
export function isCanonicalAntigravityUrl(input: string | URL): boolean {
|
|
32
|
+
try {
|
|
33
|
+
const url = new URL(input);
|
|
34
|
+
return (
|
|
35
|
+
url.protocol === "https:" &&
|
|
36
|
+
(url.port === "" || url.port === "443") &&
|
|
37
|
+
!url.username &&
|
|
38
|
+
!url.password &&
|
|
39
|
+
ANTIGRAVITY_TLS_HOSTS.has(url.hostname.toLowerCase())
|
|
40
|
+
);
|
|
41
|
+
} catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function providerTlsProfileConfigError(
|
|
47
|
+
providerName: string,
|
|
48
|
+
provider: Pick<
|
|
49
|
+
OcxProviderConfig,
|
|
50
|
+
"adapter" | "authMode" | "googleMode" | "baseUrl" | "tlsProfile"
|
|
51
|
+
>,
|
|
52
|
+
): string | null {
|
|
53
|
+
if (provider.tlsProfile === undefined) return null;
|
|
54
|
+
if (provider.tlsProfile !== "antigravity-browser")
|
|
55
|
+
return "tlsProfile must be antigravity-browser";
|
|
56
|
+
if (
|
|
57
|
+
providerName !== "google-antigravity" ||
|
|
58
|
+
provider.adapter !== "google" ||
|
|
59
|
+
provider.authMode !== "oauth" ||
|
|
60
|
+
provider.googleMode !== "cloud-code-assist" ||
|
|
61
|
+
!isCanonicalAntigravityUrl(provider.baseUrl)
|
|
62
|
+
) {
|
|
63
|
+
return "tlsProfile antigravity-browser requires the canonical Google Antigravity OAuth destination";
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function getProviderTlsProfileStatus(
|
|
69
|
+
name: string,
|
|
70
|
+
configured?: boolean,
|
|
71
|
+
): ProviderTlsProfileStatus {
|
|
72
|
+
const recorded = status.get(name);
|
|
73
|
+
if (configured === undefined) return recorded ?? "disabled";
|
|
74
|
+
if (!configured) return "disabled";
|
|
75
|
+
return recorded === undefined || recorded === "disabled" ? "pending" : recorded;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The `/api/providers` fragment for a configured profile; empty when the provider has none. */
|
|
79
|
+
export function providerTlsProfileDiagnostic(
|
|
80
|
+
name: string,
|
|
81
|
+
provider: Pick<OcxProviderConfig, "tlsProfile">,
|
|
82
|
+
): { tlsProfile?: { profile: ProviderTlsProfile; status: ProviderTlsProfileStatus } } {
|
|
83
|
+
if (provider.tlsProfile === undefined) return {};
|
|
84
|
+
return { tlsProfile: { profile: provider.tlsProfile, status: getProviderTlsProfileStatus(name, true) } };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function resetProviderTlsProfileForTests(): void {
|
|
88
|
+
status = new Map();
|
|
89
|
+
runtime = undefined;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function setProviderTlsRuntimeForTest(
|
|
93
|
+
next: TlsRuntime | undefined,
|
|
94
|
+
): void {
|
|
95
|
+
runtime = next;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function preserveTransportError(error: unknown): Error {
|
|
99
|
+
// A configured proxy URL can carry user:pass@, and the native transport may echo it. The
|
|
100
|
+
// shared redactor does not mask URL userinfo, so strip it here before the message travels.
|
|
101
|
+
const message = redactSecretString(
|
|
102
|
+
error instanceof Error ? error.message : "provider TLS transport failed",
|
|
103
|
+
).replace(/\/\/[^/@\s]+@/g, "//<redacted>@");
|
|
104
|
+
const name = error instanceof Error ? error.name : "Error";
|
|
105
|
+
if (name === "AbortError" || name === "TimeoutError")
|
|
106
|
+
return new DOMException(message, name);
|
|
107
|
+
const wrapped = new Error(message);
|
|
108
|
+
wrapped.name = name;
|
|
109
|
+
return wrapped;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Proxy schemes the native TLS transport can carry for a route decided elsewhere. */
|
|
113
|
+
const TLS_PROXY_PROTOCOLS = new Set(["http:", "https:", "socks5:", "socks5h:"]);
|
|
114
|
+
|
|
115
|
+
function requireDirect(env: ProxyEnvMap): Record<string, never> {
|
|
116
|
+
// The native transport reads HTTP_PROXY/HTTPS_PROXY/ALL_PROXY itself whenever no proxy option
|
|
117
|
+
// is given, and it has no per-request "direct" switch. A direct route is therefore only
|
|
118
|
+
// honoured when no proxy variable exists at all; otherwise omitting the option would send the
|
|
119
|
+
// credential through the environment proxy the operator routed this request away from.
|
|
120
|
+
if (outboundProxyConfigured(env)) {
|
|
121
|
+
throw new Error("provider TLS profile cannot force a direct connection while a proxy environment variable is set");
|
|
122
|
+
}
|
|
123
|
+
return {};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The proxy option expressing this send's route on the native transport, or a refusal.
|
|
128
|
+
*
|
|
129
|
+
* `sendWithConnectionPolicy` resolves the per-provider egress (`providers.<name>.proxy` /
|
|
130
|
+
* `noProxy`) and passes it as `init.proxy`: a URL, `false` for direct, or absent when the
|
|
131
|
+
* provider inherits the global environment route.
|
|
132
|
+
*/
|
|
133
|
+
function tlsProxyOption(init: RequestInit | undefined, destination: string | URL): { proxy?: string } {
|
|
134
|
+
const env = runtime?.env ?? process.env;
|
|
135
|
+
const decided = init !== undefined && Object.hasOwn(init, "proxy")
|
|
136
|
+
? (init as RequestInit & { proxy?: unknown }).proxy
|
|
137
|
+
: undefined;
|
|
138
|
+
if (typeof decided === "string") {
|
|
139
|
+
let protocol: string;
|
|
140
|
+
try {
|
|
141
|
+
protocol = new URL(decided).protocol;
|
|
142
|
+
} catch {
|
|
143
|
+
throw new Error("provider TLS profile cannot preserve configured proxy semantics");
|
|
144
|
+
}
|
|
145
|
+
if (!TLS_PROXY_PROTOCOLS.has(protocol)) {
|
|
146
|
+
throw new Error("provider TLS profile cannot preserve configured proxy semantics");
|
|
147
|
+
}
|
|
148
|
+
return { proxy: decided };
|
|
149
|
+
}
|
|
150
|
+
if (decided === false) return requireDirect(env);
|
|
151
|
+
const route = (runtime?.resolveProxyRoute ?? resolveProxyRoute)(new URL(destination), env);
|
|
152
|
+
if (route.kind === "fallback") {
|
|
153
|
+
// `resolveProxyRoute` only classifies HTTP(S) proxies; an inherited ALL_PROXY of socks5://
|
|
154
|
+
// or socks5h:// is the route the ordinary outbound path takes through socks5ProxyFromEnv().
|
|
155
|
+
// Carry that same route when no HTTPS-specific variable outranks it, instead of refusing
|
|
156
|
+
// every Antigravity send for an operator whose only global proxy is SOCKS.
|
|
157
|
+
const socks = proxyEnvPresent("HTTPS_PROXY", env) ? undefined : socks5ProxyFromEnv(env);
|
|
158
|
+
if (socks) return { proxy: socks.trim() };
|
|
159
|
+
throw new Error("provider TLS profile cannot preserve configured proxy semantics");
|
|
160
|
+
}
|
|
161
|
+
if (route.kind === "proxy") return { proxy: route.proxy };
|
|
162
|
+
return requireDirect(env);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function providerTlsFetch(
|
|
166
|
+
name: string,
|
|
167
|
+
provider: Pick<
|
|
168
|
+
OcxProviderConfig,
|
|
169
|
+
"adapter" | "authMode" | "googleMode" | "baseUrl" | "tlsProfile"
|
|
170
|
+
>,
|
|
171
|
+
fallback: typeof globalThis.fetch,
|
|
172
|
+
): typeof globalThis.fetch {
|
|
173
|
+
if (provider.tlsProfile === undefined) {
|
|
174
|
+
status.set(name, "disabled");
|
|
175
|
+
return fallback;
|
|
176
|
+
}
|
|
177
|
+
if (providerTlsProfileConfigError(name, provider)) {
|
|
178
|
+
status.set(name, "failed");
|
|
179
|
+
return (async () => {
|
|
180
|
+
throw new Error("invalid provider TLS profile");
|
|
181
|
+
}) as unknown as typeof globalThis.fetch;
|
|
182
|
+
}
|
|
183
|
+
// Transparent to provider egress: the route decided at the physical send arrives as
|
|
184
|
+
// `init.proxy` and is either carried by the native transport or refused below.
|
|
185
|
+
return markEgressTransparentExecutor((async (input, init) => {
|
|
186
|
+
const destination =
|
|
187
|
+
typeof input === "string" || input instanceof URL ? input : input.url;
|
|
188
|
+
if (!isCanonicalAntigravityUrl(destination)) {
|
|
189
|
+
status.set(name, "failed");
|
|
190
|
+
throw new Error("provider TLS profile refused noncanonical destination");
|
|
191
|
+
}
|
|
192
|
+
try {
|
|
193
|
+
const configured = runtimeProviderFetch(
|
|
194
|
+
provider as OcxProviderConfig,
|
|
195
|
+
name,
|
|
196
|
+
);
|
|
197
|
+
const proxyOption = tlsProxyOption(init, destination);
|
|
198
|
+
const mod =
|
|
199
|
+
configured === undefined
|
|
200
|
+
? runtime ?? ((await import("wreq-js")) as unknown as TlsRuntime)
|
|
201
|
+
: undefined;
|
|
202
|
+
const { proxy: _decidedRoute, ...rest } = (init ?? {}) as RequestInit & { proxy?: unknown };
|
|
203
|
+
const response = await (configured ?? mod!.fetch)(input, {
|
|
204
|
+
...rest,
|
|
205
|
+
redirect: "manual",
|
|
206
|
+
browser: "chrome_142",
|
|
207
|
+
os: "windows",
|
|
208
|
+
...proxyOption,
|
|
209
|
+
} as RequestInit & { browser: string; os: string });
|
|
210
|
+
status.set(name, "active");
|
|
211
|
+
return response;
|
|
212
|
+
} catch (error) {
|
|
213
|
+
if (init?.signal?.aborted && error === init.signal.reason) {
|
|
214
|
+
// A caller cancellation says nothing about the profile's health.
|
|
215
|
+
throw error;
|
|
216
|
+
}
|
|
217
|
+
status.set(name, "failed");
|
|
218
|
+
throw preserveTransportError(error);
|
|
219
|
+
}
|
|
220
|
+
}) as typeof globalThis.fetch);
|
|
221
|
+
}
|
|
@@ -79,6 +79,14 @@ export interface DispatchIntent {
|
|
|
79
79
|
* second charge. Charging both is how a four-send cap silently becomes a two-send cap.
|
|
80
80
|
*/
|
|
81
81
|
readonly countedExternally?: boolean;
|
|
82
|
+
/**
|
|
83
|
+
* True when the caller's request was rebuilt onto a different destination by an
|
|
84
|
+
* authorized mid-flight change (e.g. Kiro's reset-triggered account rebuild).
|
|
85
|
+
* The physical destination is still recorded, but the move is not a failover
|
|
86
|
+
* decision: it must not consume the request's single target transition or
|
|
87
|
+
* alternate-target allowance, which belong to the actual endpoint fallback.
|
|
88
|
+
*/
|
|
89
|
+
readonly rebasedTarget?: boolean;
|
|
82
90
|
}
|
|
83
91
|
|
|
84
92
|
export interface SingleUseDispatchPermit {
|
|
@@ -316,10 +324,12 @@ function createRequestExecutionBudgetWithLedger(
|
|
|
316
324
|
const changesTarget = lastTargetKey !== undefined && lastTargetKey !== intent.targetKey;
|
|
317
325
|
const isAlternateTarget = changesTarget || intent.sendClass === "account-failover"
|
|
318
326
|
|| intent.sendClass === "combo-failover";
|
|
319
|
-
|
|
327
|
+
const chargesTransition = changesTarget && intent.rebasedTarget !== true;
|
|
328
|
+
const chargesAlternateTarget = isAlternateTarget && intent.rebasedTarget !== true;
|
|
329
|
+
if (isAlternateTarget && chargesTransition && targetTransitions >= policy.maxTargetTransitions) {
|
|
320
330
|
return { allowed: false, reason: "target-transition-exhausted" };
|
|
321
331
|
}
|
|
322
|
-
if (
|
|
332
|
+
if (chargesAlternateTarget && alternateTargetSends >= policy.maxAlternateTargetSends) {
|
|
323
333
|
return { allowed: false, reason: "alternate-target-exhausted" };
|
|
324
334
|
}
|
|
325
335
|
|
|
@@ -349,8 +359,8 @@ function createRequestExecutionBudgetWithLedger(
|
|
|
349
359
|
counter.spent += 1;
|
|
350
360
|
if (intent.countedExternally === true) counter.pendingExternalSends += 1;
|
|
351
361
|
if (drawsReserve) reserveSpent = true;
|
|
352
|
-
if (
|
|
353
|
-
if (
|
|
362
|
+
if (chargesAlternateTarget) alternateTargetSends += 1;
|
|
363
|
+
if (chargesTransition) targetTransitions += 1;
|
|
354
364
|
lastTargetKey = intent.targetKey;
|
|
355
365
|
|
|
356
366
|
let settled: "open" | "used" | "released" = "open";
|
|
@@ -386,8 +396,8 @@ function createRequestExecutionBudgetWithLedger(
|
|
|
386
396
|
counter.spent -= 1;
|
|
387
397
|
observer?.refund();
|
|
388
398
|
if (drawsReserve) reserveSpent = false;
|
|
389
|
-
if (
|
|
390
|
-
if (
|
|
399
|
+
if (chargesAlternateTarget) alternateTargetSends -= 1;
|
|
400
|
+
if (chargesTransition) targetTransitions -= 1;
|
|
391
401
|
lastTargetKey = previousTargetKey;
|
|
392
402
|
},
|
|
393
403
|
},
|