@runuai/host 0.9.171 → 0.9.172

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.
@@ -0,0 +1,299 @@
1
+ /** ADR-164: compile one validated, hard-pinned connection into a CLI launch.
2
+ * Only the runner calls these builders. No caller may supply arbitrary TOML,
3
+ * environment keys, credential-helper commands or a second destination. */
4
+ import { createHash } from "node:crypto";
5
+
6
+ import {
7
+ customConnectionEnabled,
8
+ isCustomConnectionReason,
9
+ parseCustomConnectionLease,
10
+ parseCustomConnectionMaterial,
11
+ validConnectionModels,
12
+ type CustomConnectionCombination,
13
+ type CustomConnectionLease,
14
+ type CustomConnectionMaterial,
15
+ type CustomConnectionReason,
16
+ } from "../src/custom-connections";
17
+ import { isOwnedInternalConnectionRuntime } from "./internal-connections";
18
+ import { isPublicAddress, isInternalConnectionAddress } from "./secrets-proxy/address";
19
+
20
+ export class CustomConnectionLaunchError extends Error {
21
+ constructor(readonly reason: CustomConnectionReason) {
22
+ super(reason);
23
+ this.name = "CustomConnectionLaunchError";
24
+ }
25
+ }
26
+
27
+ /** JSON string escaping is also valid for TOML basic string values. Never
28
+ * interpolate an unquoted model id, URL or label into a -c argument. */
29
+ export function tomlString(value: string): string {
30
+ return JSON.stringify(value).replace(/\u007f/g, "\\u007f");
31
+ }
32
+
33
+ /** A native account must not inherit a previous connection's route, token or
34
+ * model aliases. Native credentials are applied AFTER these blanks. AWS's
35
+ * ordinary identity is isolated below only for a connection launch. */
36
+ const CONNECTION_ENV_NAMES = [
37
+ "ANTHROPIC_BASE_URL", "ANTHROPIC_CUSTOM_HEADERS", "ANTHROPIC_MODEL",
38
+ "CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST",
39
+ "ANTHROPIC_DEFAULT_MODEL", "ANTHROPIC_DEFAULT_FABLE_MODEL",
40
+ "ANTHROPIC_DEFAULT_OPUS_MODEL", "ANTHROPIC_DEFAULT_SONNET_MODEL",
41
+ "ANTHROPIC_DEFAULT_HAIKU_MODEL", "ANTHROPIC_SMALL_FAST_MODEL",
42
+ "ANTHROPIC_SMALL_FAST_MODEL_AWS_REGION", "CLAUDE_CODE_SUBAGENT_MODEL",
43
+ "CLAUDE_CODE_SUBAGENT_MODEL_FORCE", "CLAUDE_CODE_EXTRA_BODY",
44
+ "CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_MANTLE", "CLAUDE_CODE_USE_GATEWAY",
45
+ "CLAUDE_CODE_USE_ANTHROPIC_GOOGLE_CLOUD", "CLAUDE_CODE_API_BASE_URL",
46
+ "ANTHROPIC_UNIX_SOCKET", "_CLAUDE_CODE_ASSUME_FIRST_PARTY_BASE_URL",
47
+ "CLAUDE_CODE_USE_VERTEX", "CLAUDE_CODE_USE_FOUNDRY",
48
+ "CLAUDE_CODE_USE_ANTHROPIC_AWS", "CLAUDE_CODE_SKIP_BEDROCK_AUTH",
49
+ "CLAUDE_CODE_SKIP_MANTLE_AUTH", "CLAUDE_CODE_SKIP_VERTEX_AUTH",
50
+ "CLAUDE_CODE_SKIP_FOUNDRY_AUTH", "ANTHROPIC_BEDROCK_BASE_URL",
51
+ "ANTHROPIC_BEDROCK_MANTLE_BASE_URL", "ANTHROPIC_BEDROCK_REGION_PREFIX",
52
+ "ANTHROPIC_VERTEX_BASE_URL", "ANTHROPIC_VERTEX_PROJECT_ID",
53
+ "ANTHROPIC_FOUNDRY_BASE_URL", "ANTHROPIC_FOUNDRY_RESOURCE",
54
+ "ANTHROPIC_FOUNDRY_API_KEY", "ANTHROPIC_FOUNDRY_AUTH_TOKEN",
55
+ "ANTHROPIC_AWS_API_KEY", "ANTHROPIC_AWS_BASE_URL", "ANTHROPIC_AWS_WORKSPACE_ID",
56
+ "ANTHROPIC_GOOGLE_CLOUD_BASE_URL", "CLAUDE_CODE_AUTO_MODE_MODEL",
57
+ "CLAUDE_CODE_BG_CLASSIFIER_MODEL", "CLAUDE_CODE_WORKFLOW_SUBAGENT_MODEL",
58
+ "ANTHROPIC_PROFILE", "ANTHROPIC_ORGANIZATION_ID",
59
+ "ANTHROPIC_WORKSPACE_ID", "ANTHROPIC_FEDERATION_RULE_ID",
60
+ "CLAUDE_CODE_DISABLE_MODEL_ACCESS_FALLBACK",
61
+ "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC",
62
+ "OPENAI_API_KEY", "OPENAI_BASE_URL", "OPENAI_ORGANIZATION", "OPENAI_PROJECT",
63
+ "UAI_CUSTOM_API_KEY", "UAI_INFERENCE_TOKEN", "AWS_BEARER_TOKEN_BEDROCK",
64
+ ] as const;
65
+
66
+ const ENGINE_AUTH_BLANKS = {
67
+ CLAUDE_CODE_OAUTH_TOKEN: "", CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR: "", CCR_OAUTH_TOKEN_FILE: "",
68
+ CLAUDE_CODE_HOST_AUTH_ENV_VAR: "", CLAUDE_CODE_HOST_CREDS_FILE: "",
69
+ ANTHROPIC_API_KEY: "", ANTHROPIC_AUTH_TOKEN: "", OPENAI_API_KEY: "",
70
+ };
71
+
72
+ export const CONNECTION_NATIVE_BLANKS: Readonly<Record<string, string>> = Object.freeze(
73
+ Object.fromEntries(CONNECTION_ENV_NAMES.map(name => [name, ""])),
74
+ );
75
+
76
+ export function connectionNativeBlanks(): Record<string, string> {
77
+ return { ...CONNECTION_NATIVE_BLANKS };
78
+ }
79
+
80
+ /** The AWS SDK must never consult an ambient credential provider or endpoint.
81
+ * Empty files, not a nonexistent path, make profile/process lookup inert. */
82
+ export const CONNECTION_AWS_BLANKS: Readonly<Record<string, string>> = Object.freeze(
83
+ Object.fromEntries([
84
+ "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN", "AWS_SECURITY_TOKEN",
85
+ "AWS_PROFILE", "AWS_DEFAULT_PROFILE", "AWS_ROLE_ARN", "AWS_ROLE_SESSION_NAME",
86
+ "AWS_WEB_IDENTITY_TOKEN_FILE", "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI",
87
+ "AWS_CONTAINER_CREDENTIALS_FULL_URI", "AWS_CONTAINER_AUTHORIZATION_TOKEN",
88
+ "AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE", "AWS_ENDPOINT_URL",
89
+ "AWS_ENDPOINT_URL_BEDROCK", "AWS_ENDPOINT_URL_BEDROCK_RUNTIME",
90
+ "AWS_EC2_METADATA_SERVICE_ENDPOINT", "AWS_EC2_METADATA_SERVICE_ENDPOINT_MODE",
91
+ "AWS_REGION", "AWS_DEFAULT_REGION", "AWS_SDK_LOAD_CONFIG",
92
+ ].map(name => [name, ""])),
93
+ );
94
+
95
+ /** Shared by connection launches and the Managed Engine delegation. */
96
+ export function endpointAuthEnv(
97
+ provider: "anthropic" | "openai",
98
+ baseUrl: string,
99
+ token: string,
100
+ authMode: "bearer" | "x-api-key" = "bearer",
101
+ tokenVariable = "UAI_CUSTOM_API_KEY",
102
+ ): Record<string, string> {
103
+ return provider === "anthropic"
104
+ ? { ...connectionNativeBlanks(), ...ENGINE_AUTH_BLANKS, ANTHROPIC_BASE_URL: baseUrl,
105
+ [authMode === "bearer" ? "ANTHROPIC_AUTH_TOKEN" : "ANTHROPIC_API_KEY"]: token }
106
+ : { ...connectionNativeBlanks(), ...ENGINE_AUTH_BLANKS,
107
+ ...(tokenVariable === "UAI_INFERENCE_TOKEN" ? { OPENAI_BASE_URL: baseUrl, OPENAI_API_KEY: token } : {}),
108
+ [tokenVariable]: token };
109
+ }
110
+
111
+ /** baseUrl is already the provider's Responses root, including /v1 where
112
+ * appropriate. Only Managed Engine's caller appends its own /v1. */
113
+ export function codexProviderOverrides(
114
+ baseUrl: string,
115
+ options: { provider?: "uai" | "uai_custom"; name?: string; envKey?: "UAI_INFERENCE_TOKEN" | "UAI_CUSTOM_API_KEY" } = {},
116
+ ): string[] {
117
+ const provider = options.provider ?? "uai_custom";
118
+ return [
119
+ `model_provider=${provider}`,
120
+ `model_providers.${provider}.name=${tomlString(options.name ?? "Custom connection")}`,
121
+ `model_providers.${provider}.base_url=${tomlString(baseUrl)}`,
122
+ `model_providers.${provider}.env_key=${tomlString(options.envKey ?? "UAI_CUSTOM_API_KEY")}`,
123
+ `model_providers.${provider}.wire_api="responses"`,
124
+ `model_providers.${provider}.requires_openai_auth=false`,
125
+ `model_providers.${provider}.supports_websockets=false`,
126
+ ];
127
+ }
128
+
129
+ export interface CustomConnectionLaunch {
130
+ model: string;
131
+ agentEnv: Record<string, string>;
132
+ cliConfigOverrides: string[];
133
+ claudeSettings?: Record<string, unknown>;
134
+ }
135
+
136
+ export function buildCustomConnectionLaunch(args: {
137
+ material: CustomConnectionMaterial;
138
+ connection: CustomConnectionLease;
139
+ model?: string;
140
+ enabled: readonly CustomConnectionCombination[];
141
+ /** A connection-owned home prepared without copying a native account. */
142
+ home: string;
143
+ now?: number;
144
+ /** Actual destination provider, never inferred from sealed settings. */
145
+ runtimeProvider?: string;
146
+ }): CustomConnectionLaunch {
147
+ const material = parseCustomConnectionMaterial(args.material);
148
+ const connection = parseCustomConnectionLease(args.connection, args.material.engine);
149
+ if (!material || !connection || !customConnectionEnabled(material, args.enabled)) throw new CustomConnectionLaunchError("unsupported");
150
+ if (material.internal === true && !isOwnedInternalConnectionRuntime(args.runtimeProvider)) throw new CustomConnectionLaunchError("unsupported");
151
+ if ((material.internal === true) !== (connection.internal === true)) throw new CustomConnectionLaunchError("unsupported");
152
+ if (new URL(material.baseUrl).host !== connection.host || material.providerType !== connection.providerType || material.authMode !== connection.authMode || material.region !== connection.region || material.transport !== connection.transport || (material.expiresAt ?? null) !== (connection.expiresAt ?? null)) throw new CustomConnectionLaunchError("unsupported");
153
+ if (material.expiresAt !== undefined && material.expiresAt !== null && material.expiresAt <= (args.now ?? Date.now())) throw new CustomConnectionLaunchError("expired");
154
+ if (!validConnectionModels(connection.models, connection.defaultModel, connection.backgroundModel, material.engine, material.apiKey)) throw new CustomConnectionLaunchError("invalid-models");
155
+ const model = args.model ?? connection.defaultModel;
156
+ if (!connection.models.includes(model)) throw new CustomConnectionLaunchError("model-not-found");
157
+ assertConnectionHome(args.home);
158
+ const aws = { ...CONNECTION_AWS_BLANKS, AWS_EC2_METADATA_DISABLED: "true",
159
+ AWS_CONFIG_FILE: `${args.home}/aws-config`, AWS_SHARED_CREDENTIALS_FILE: `${args.home}/aws-credentials` };
160
+ const agentEnv: Record<string, string> = {
161
+ ...connectionNativeBlanks(), ...ENGINE_AUTH_BLANKS, ...aws,
162
+ // Bound Claude's SDK attempts as well as its CLI retry loop. The
163
+ // host applies the connection's separate rate-limit recovery backoff.
164
+ AWS_MAX_ATTEMPTS: "1",
165
+ UAI_CUSTOM_CONNECTION: "1",
166
+ };
167
+ if (material.providerType === "bedrock") {
168
+ agentEnv.AWS_REGION = material.region!;
169
+ agentEnv.AWS_BEARER_TOKEN_BEDROCK = material.apiKey;
170
+ if (material.engine === "claude") agentEnv.CLAUDE_CODE_USE_BEDROCK = "1";
171
+ } else {
172
+ Object.assign(agentEnv, endpointAuthEnv(material.engine === "claude" ? "anthropic" : "openai", material.baseUrl, material.apiKey, material.authMode));
173
+ }
174
+ if (material.engine === "claude") {
175
+ Object.assign(agentEnv, {
176
+ // Claude 2.1.312 ignores settings-sourced provider/auth/headers when
177
+ // its embedding host owns routing. Keep project hooks/plugins intact.
178
+ CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST: "1",
179
+ // Verified on 2.1.312: replaces its ten-retry cloud-auth loop while
180
+ // retaining one retry for a transient 429/5xx response.
181
+ CLAUDE_CODE_MAX_RETRIES: "1",
182
+ ANTHROPIC_MODEL: model, ANTHROPIC_DEFAULT_FABLE_MODEL: model,
183
+ ANTHROPIC_DEFAULT_OPUS_MODEL: model, ANTHROPIC_DEFAULT_SONNET_MODEL: model,
184
+ ANTHROPIC_DEFAULT_HAIKU_MODEL: connection.backgroundModel!, CLAUDE_CODE_SUBAGENT_MODEL: model,
185
+ CLAUDE_CODE_AUTO_MODE_MODEL: connection.backgroundModel!, CLAUDE_CODE_BG_CLASSIFIER_MODEL: connection.backgroundModel!,
186
+ CLAUDE_CODE_WORKFLOW_SUBAGENT_MODEL: model,
187
+ CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1", CLAUDE_CODE_DISABLE_MODEL_ACCESS_FALLBACK: "1",
188
+ });
189
+ return { model, agentEnv, cliConfigOverrides: [], claudeSettings: {
190
+ apiKeyHelper: "", awsAuthRefresh: "", awsCredentialExport: "", fallbackModel: [],
191
+ } };
192
+ }
193
+ agentEnv.CODEX_HOME = args.home;
194
+ const cliConfigOverrides = material.providerType === "bedrock"
195
+ ? [`model_provider=${tomlString(material.transport === "runtime" ? "amazon-bedrock-runtime" : "amazon-bedrock")}`,
196
+ `model_providers.${material.transport === "runtime" ? "amazon-bedrock-runtime" : "amazon-bedrock"}.aws.region=${tomlString(material.region!)}`]
197
+ : codexProviderOverrides(material.baseUrl);
198
+ const provider = material.providerType === "bedrock"
199
+ ? material.transport === "runtime" ? "amazon-bedrock-runtime" : "amazon-bedrock"
200
+ : "uai_custom";
201
+ // Codex 0.164 rejects retry overrides on its built-in AWS providers.
202
+ // The connection runner bounds their streamed retries instead.
203
+ const retryOverrides = material.providerType === "bedrock" ? [] : [
204
+ `model_providers.${provider}.request_max_retries=1`,
205
+ `model_providers.${provider}.stream_max_retries=1`,
206
+ ];
207
+ return { model, agentEnv, cliConfigOverrides: [...cliConfigOverrides, ...retryOverrides,
208
+ 'web_search="disabled"'] };
209
+ }
210
+
211
+ const HOME_ROOT = "/home/node/.uai-connection";
212
+ function assertConnectionHome(home: string): void {
213
+ if (!new RegExp(`^${HOME_ROOT.replaceAll(".", "\\.")}/[A-Za-z0-9_-]{1,128}$`).test(home)) throw new CustomConnectionLaunchError("unsupported");
214
+ }
215
+ export function newConnectionHome(credentialId: string, revision: string, agentId: string, settingsHash: string): string {
216
+ const segment = createHash("sha256").update(JSON.stringify([credentialId, revision, agentId, settingsHash])).digest("hex");
217
+ return `${HOME_ROOT}/${segment}`;
218
+ }
219
+
220
+ const HOME_PREPARE = String.raw`
221
+ import os, stat, sys
222
+ home = sys.argv[1]
223
+ parent, name = home.rsplit("/", 1)
224
+ fd = os.open("/", os.O_RDONLY | os.O_DIRECTORY)
225
+ try:
226
+ for part in parent.split("/"):
227
+ if not part: continue
228
+ try: os.mkdir(part, 0o700, dir_fd=fd)
229
+ except FileExistsError: pass
230
+ child = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=fd)
231
+ os.close(fd)
232
+ fd = child
233
+ info = os.fstat(fd)
234
+ if info.st_uid != os.getuid() or (info.st_mode & 0o077): raise PermissionError()
235
+ try: os.mkdir(name, 0o700, dir_fd=fd)
236
+ except FileExistsError: pass
237
+ child = os.open(name, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=fd)
238
+ try:
239
+ info = os.fstat(child)
240
+ if info.st_uid != os.getuid() or (info.st_mode & 0o077): raise PermissionError()
241
+ # This provider authenticates through its scoped env key. A saved
242
+ # interactive login is never accepted when resuming the same home.
243
+ try: os.stat("auth.json", dir_fd=child, follow_symlinks=False)
244
+ except FileNotFoundError: pass
245
+ else: raise PermissionError()
246
+ # Provider/model/tool settings are generated as CLI overrides. The
247
+ # only config.toml we generate is empty; reject planted commands.
248
+ for filename in ["config.toml", "aws-config", "aws-credentials"]:
249
+ file = os.open(filename, os.O_WRONLY | os.O_CREAT | os.O_NOFOLLOW | os.O_NONBLOCK, 0o600, dir_fd=child)
250
+ try:
251
+ info = os.fstat(file)
252
+ if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or info.st_nlink != 1 or info.st_size or (info.st_mode & 0o077): raise PermissionError()
253
+ finally: os.close(file)
254
+ finally: os.close(child)
255
+ finally: os.close(fd)
256
+ `;
257
+
258
+ /** Execute as the task user. Nonzero exit fails the launch; there is no
259
+ * fallback home. Reuses only a private home with the empty generated config
260
+ * and empty AWS credential files. */
261
+ export function connectionHomePreparation(home: string): [string, ...string[]] {
262
+ assertConnectionHome(home);
263
+ return ["/usr/bin/python3", "-I", "-c", HOME_PREPARE, home];
264
+ }
265
+
266
+ const DNS_LOOKUP = String.raw`require("node:dns").lookup(process.argv[1],{all:true,verbatim:true},(error,answers)=>{if(error||!answers.length||answers.length>64){process.exitCode=1;return;}process.stdout.write(JSON.stringify(answers.map(x=>x.address)));});`;
267
+ export function connectionDnsLookupArgv(baseUrl: string): [string, ...string[]] {
268
+ const hostname = new URL(baseUrl).hostname.replace(/^\[|\]$/g, "");
269
+ return ["node", "-e", DNS_LOOKUP, "--", hostname];
270
+ }
271
+ /** Evaluate all DNS answers from inside the task runtime, not the runner's
272
+ * own resolver. The caller bounds exec output/time and rejects nonzero exit. */
273
+ export function validateConnectionAddresses(raw: string, internal = false): void {
274
+ let addresses: unknown;
275
+ try { addresses = JSON.parse(raw); } catch { throw new CustomConnectionLaunchError("unreachable"); }
276
+ if (!Array.isArray(addresses) || !addresses.length || addresses.length > 64 || !addresses.every(x => typeof x === "string")) throw new CustomConnectionLaunchError("unreachable");
277
+ if (!addresses.every(internal ? isInternalConnectionAddress : isPublicAddress)) throw new CustomConnectionLaunchError("private-address");
278
+ }
279
+
280
+ export function redactConnectionText(value: string, key: string): string {
281
+ if (!key) return value;
282
+ return value.split(key).join("[redacted]".includes(key) ? "" : "[redacted]");
283
+ }
284
+ /** A diagnostic may be inspected to choose a reason, never sent back. */
285
+ export function connectionFailureReason(error: unknown): CustomConnectionReason {
286
+ if (error instanceof CustomConnectionLaunchError) return error.reason;
287
+ const o = error && typeof error === "object" ? error as Record<string, unknown> : {};
288
+ if (isCustomConnectionReason(o.reason)) return o.reason;
289
+ const status = o.statusCode ?? o.status;
290
+ const text = typeof error === "string" ? error : error instanceof Error ? error.message : "";
291
+ if (isCustomConnectionReason(text)) return text;
292
+ if (status === 401 || /\b401\b|unauthori[sz]ed|authentication_failed|unrecognized.?client|expired.?token|token.{0,80}(?:expired|revoked)|invalid api.?key/i.test(text)) return "unauthorized";
293
+ if (status === 403 || /\b403\b|forbidden|access.?denied/i.test(text)) return "forbidden";
294
+ if (status === 404 || /\b404\b|model.{0,30}not.found/i.test(text)) return "model-not-found";
295
+ if (status === 429 || /\b429\b|rate.?limit|throttl/i.test(text)) return "rate-limited";
296
+ if (/expired/i.test(text)) return "expired";
297
+ if (status === 408 || /timeout|timed.out/i.test(text)) return "timeout";
298
+ return "upstream-error";
299
+ }
@@ -0,0 +1,70 @@
1
+ import type { AgentEvent } from "./agents/types";
2
+
3
+ import { connectionFailureReason, redactConnectionText } from "./custom-connection-launch";
4
+ export { connectionFailureReason } from "./custom-connection-launch";
5
+
6
+ /** Redact literal secrets recursively, including tool arguments and notices. */
7
+ export function redactConnectionValue<T>(value: T, key: string): T {
8
+ if (typeof value === "string") return redactConnectionText(value, key) as T;
9
+ if (Array.isArray(value)) return value.map(v => redactConnectionValue(v, key)) as T;
10
+ if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([k, v]) => [redactConnectionValue(k, key), redactConnectionValue(v, key)])) as T;
11
+ return value;
12
+ }
13
+
14
+ export function sanitizeConnectionEvent(event: AgentEvent, key: string): AgentEvent {
15
+ const redacted = redactConnectionValue(event, key);
16
+ if (event.type === "error") {
17
+ const diagnostic = event.diagnosticMessage ?? event.message;
18
+ const message = connectionFailureReason(diagnostic);
19
+ let resetsAt = Number.isSafeInteger(event.resetsAt) && event.resetsAt! > 0 ? event.resetsAt : undefined;
20
+ if (message === "rate-limited") {
21
+ // Preserve only a numeric retry deadline, never provider text/headers.
22
+ const retryAfter = /retry-after\s*[:=]\s*([^\r\n]+)/i.exec(diagnostic)?.[1]?.trim();
23
+ const deadline = retryAfter && /^\d+(?:\.\d+)?$/.test(retryAfter)
24
+ ? Date.now() + Math.ceil(Number(retryAfter) * 1000)
25
+ : retryAfter ? Date.parse(retryAfter) : NaN;
26
+ if (Number.isSafeInteger(deadline) && deadline > 0) resetsAt = Math.max(resetsAt ?? 0, deadline);
27
+ }
28
+ return { type: "error", message, aborted: event.aborted, code: event.code,
29
+ ...(message === "rate-limited" && resetsAt !== undefined ? { resetsAt } : {}) };
30
+ }
31
+ if (event.type === "notice") return { type: "notice", message: connectionFailureReason(event.message) };
32
+ if (event.type === "turn_complete" && event.usage) {
33
+ const usage = { ...event.usage };
34
+ delete usage.costUsd;
35
+ return redactConnectionValue({ ...event, usage }, key);
36
+ }
37
+ return redacted;
38
+ }
39
+
40
+ /** A delta may end inside a credential. Retain only a possible credential
41
+ * prefix; no byte of a complete key can cross the output boundary. */
42
+ export class ConnectionEventSanitizer {
43
+ private tail = "";
44
+ constructor(private readonly key: string) {}
45
+
46
+ push(event: AgentEvent): AgentEvent[] {
47
+ if (event.type === "message_delta") {
48
+ const text = this.tail + event.text;
49
+ let keep = 0;
50
+ for (let n = Math.min(this.key.length - 1, text.length); n > 0; n--) {
51
+ if (text.endsWith(this.key.slice(0, n))) { keep = n; break; }
52
+ }
53
+ // Replace first: a complete key may itself end in a key prefix.
54
+ const safe = redactConnectionText(text, this.key);
55
+ keep = Math.min(keep, safe.length);
56
+ if (keep && !safe.endsWith(this.key.slice(0, keep))) keep = 0;
57
+ this.tail = keep ? safe.slice(-keep) : "";
58
+ const emitted = keep ? safe.slice(0, -keep) : safe;
59
+ return emitted ? [{ type: "message_delta", text: emitted }] : [];
60
+ }
61
+ if (event.type === "message_complete") {
62
+ this.tail = ""; // The complete assembled message replaces the deltas.
63
+ return [sanitizeConnectionEvent(event, this.key)];
64
+ }
65
+ const final = event.type === "turn_complete" || event.type === "error" || event.type === "exit";
66
+ const pending: AgentEvent[] = final && this.tail ? [{ type: "message_delta", text: this.tail }] : [];
67
+ if (final) this.tail = "";
68
+ return [...pending, sanitizeConnectionEvent(event, this.key)];
69
+ }
70
+ }
@@ -941,7 +941,7 @@ type PlannedAccount = { ok: false; message: string } | { ok: true; plan: Labeled
941
941
  export function pastedSecretMaterial(
942
942
  kind: string,
943
943
  rawSecret: string,
944
- ): { ok: true; material: EngineCaptureMaterial } | { ok: false; message: string } {
944
+ ): { ok: true; material: Exclude<EngineCaptureMaterial, { authKind: "custom-connection" }> } | { ok: false; message: string } {
945
945
  const secret = rawSecret.trim();
946
946
  if (!secret) return { ok: false, message: "Paste a token or API key." };
947
947
  if (/\s/.test(secret) || secret.length < 12) {
@@ -30,6 +30,8 @@ export const TASK_SECRET_ENV_NAMES: ReadonlySet<string> = new Set([
30
30
  "UAI_INFERENCE_TOKEN",
31
31
  "OPENAI_API_KEY",
32
32
  "ANTHROPIC_AUTH_TOKEN",
33
+ "UAI_CUSTOM_API_KEY",
34
+ "AWS_BEARER_TOKEN_BEDROCK",
33
35
  ]);
34
36
 
35
37
  /** Every name whose value never rides a runner's command line. */
@@ -0,0 +1,5 @@
1
+ /** ADR-164: the workspace switch is cloud-only. The host independently
2
+ * checks the concrete runtime identity; unknown and task machines refuse. */
3
+ export function isOwnedInternalConnectionRuntime(provider: string | null | undefined): boolean {
4
+ return provider === "docker-compose" || provider === "apple-container";
5
+ }
package/lib/obs.ts CHANGED
@@ -41,6 +41,7 @@ import { join } from "node:path";
41
41
  import * as Sentry from "@sentry/node";
42
42
 
43
43
  import { env } from "./env";
44
+ import { redactActiveConnectionSecrets } from "./connection-secret-redaction";
44
45
  import { isDevelopmentLaunch } from "./launch-mode";
45
46
 
46
47
  /**
@@ -216,7 +217,7 @@ const TASK_WORKSPACE_SLUG_RE =
216
217
  /([/\\]tasks[/\\][A-Za-z0-9_-]+[/\\]workspace[/\\])[^/\\\s"']+/g;
217
218
 
218
219
  export function scrubHostString(value: string): string {
219
- let out = value;
220
+ let out = redactActiveConnectionSecrets(value);
220
221
  out = out.replace(URL_CREDENTIALS_RE, "$1[redacted]@");
221
222
  out = out.replace(URL_TAIL_RE, "$1$2[redacted]");
222
223
  out = out.replace(RELATIVE_URL_TAIL_RE, "$1$2$3[redacted]");
@@ -0,0 +1,247 @@
1
+ /** ADR-164: OpenRouter key budgets, never account credit balance or CLI usage.
2
+ * A credential is read only while a task's current lease serves it. Several
3
+ * tasks sharing one credential/revision share one ten-minute poll. */
4
+ import type { CustomConnectionReason, OpenRouterBudget, OpenRouterBudgetFrame, OpenRouterBudgetResult } from "../src/custom-connections";
5
+ import { SafeHttpRefusal, readSafeBody, safeRequest } from "./safe-http";
6
+
7
+ export const OPENROUTER_BUDGET_URL = "https://openrouter.ai/api/v1/key";
8
+ export const OPENROUTER_BUDGET_POLL_MS = 10 * 60_000;
9
+ export const OPENROUTER_BUDGET_TIMEOUT_MS = 20_000;
10
+ export const OPENROUTER_BUDGET_MAX_BYTES = 64 * 1024;
11
+
12
+ function object(value: unknown): Record<string, unknown> | null {
13
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
14
+ }
15
+ function amount(value: unknown): value is number | null {
16
+ return value === null || (typeof value === "number" && Number.isFinite(value) && value >= 0);
17
+ }
18
+ /** Provider timestamps are ISO instants. An interval such as "monthly" is
19
+ * deliberately not converted into an invented reset date. */
20
+ function timestamp(value: unknown): number | null | undefined {
21
+ if (value === null || value === undefined) return null;
22
+ if (typeof value !== "string" || !/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d+)?(?:Z|[+-]\d\d:\d\d)$/.test(value)) return undefined;
23
+ const ms = Date.parse(value);
24
+ return Number.isSafeInteger(ms) && ms > 0 ? ms : undefined;
25
+ }
26
+
27
+ /** Select only the bounded public values from an arbitrary /key response.
28
+ * Required nulls remain meaningful: a missing limit is not "unlimited". */
29
+ export function parseOpenRouterKeyBudget(value: unknown): OpenRouterBudget | null {
30
+ const data = object(object(value)?.data);
31
+ if (!data || !Object.hasOwn(data, "usage") || !Object.hasOwn(data, "limit") || !Object.hasOwn(data, "limit_remaining") || !amount(data.usage) || !amount(data.limit) || !amount(data.limit_remaining)) return null;
32
+ const keyExpiresAt = timestamp(data.expires_at);
33
+ if (keyExpiresAt === undefined) return null;
34
+ let resetsAt: number | null = null;
35
+ // A future API may return a timestamp instead of the documented interval.
36
+ if (typeof data.limit_reset === "string" && data.limit_reset.includes("T")) {
37
+ const reset = timestamp(data.limit_reset);
38
+ if (reset === undefined) return null;
39
+ resetsAt = reset;
40
+ }
41
+ let effectiveBudget: number | null = null;
42
+ if (data.effective_budget !== undefined && data.effective_budget !== null) {
43
+ const budget = object(data.effective_budget);
44
+ if (!budget || !Object.hasOwn(budget, "remaining_usd") || !amount(budget.remaining_usd)) return null;
45
+ effectiveBudget = budget.remaining_usd;
46
+ const reset = timestamp(budget.resets_at);
47
+ if (reset === undefined) return null;
48
+ // This reset belongs to the effective, stricter budget when it binds.
49
+ if (effectiveBudget !== null && (data.limit_remaining === null || effectiveBudget < data.limit_remaining)) resetsAt = reset;
50
+ }
51
+ return { usage: data.usage, limit: data.limit, remaining: data.limit_remaining, effectiveBudget, resetsAt, keyExpiresAt };
52
+ }
53
+
54
+ function responseReason(status: number): CustomConnectionReason {
55
+ if (status === 401) return "unauthorized";
56
+ if (status === 402 || status === 403) return "forbidden";
57
+ if (status === 429) return "rate-limited";
58
+ return status >= 500 ? "upstream-error" : "wrong-protocol";
59
+ }
60
+ function failureReason(error: unknown): CustomConnectionReason {
61
+ if (error instanceof SafeHttpRefusal) {
62
+ if (error.reason === "non_public_address") return "private-address";
63
+ if (error.reason === "timeout") return "timeout";
64
+ if (error.reason === "response_too_large" || error.reason === "url") return "wrong-protocol";
65
+ }
66
+ return "unreachable";
67
+ }
68
+ export interface ReadOpenRouterBudgetOptions {
69
+ signal?: AbortSignal;
70
+ /** Tests inject transport; production always uses the DNS-pinned client. */
71
+ request?: typeof safeRequest;
72
+ now?: () => number;
73
+ }
74
+
75
+ /** Reads neither a caller-provided URL nor any redirect. No provider text,
76
+ * key, arbitrary object or exception escapes this boundary. */
77
+ export async function readOpenRouterBudget(apiKey: string, options: ReadOpenRouterBudgetOptions = {}): Promise<OpenRouterBudgetResult> {
78
+ if (!apiKey || apiKey.length > 8192 || /[\s\u0000-\u001f\u007f]/.test(apiKey)) return { status: "error", reason: "unauthorized" };
79
+ if (options.signal?.aborted) return { status: "error", reason: "timeout" };
80
+ const controller = new AbortController();
81
+ const onAbort = (): void => controller.abort();
82
+ if (options.signal?.aborted) controller.abort();
83
+ else options.signal?.addEventListener("abort", onAbort, { once: true });
84
+ const timeout = setTimeout(() => controller.abort(), OPENROUTER_BUDGET_TIMEOUT_MS);
85
+ timeout.unref();
86
+ try {
87
+ const response = await (options.request ?? safeRequest)({
88
+ url: OPENROUTER_BUDGET_URL,
89
+ method: "GET",
90
+ headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
91
+ signal: controller.signal,
92
+ }, { connectMs: 10_000, headersMs: OPENROUTER_BUDGET_TIMEOUT_MS, idleMs: OPENROUTER_BUDGET_TIMEOUT_MS, maxRequestBodyBytes: 0 });
93
+ if (response.status !== 200) {
94
+ response.body.destroy();
95
+ return { status: "error", reason: responseReason(response.status) };
96
+ }
97
+ const raw = await readSafeBody(response, OPENROUTER_BUDGET_MAX_BYTES);
98
+ const budget = parseOpenRouterKeyBudget(JSON.parse(raw.toString("utf8")) as unknown);
99
+ return budget ? { status: "ok", ...budget } : { status: "error", reason: "wrong-protocol" };
100
+ } catch (error) {
101
+ return { status: "error", reason: controller.signal.aborted ? "timeout" : error instanceof SyntaxError ? "wrong-protocol" : failureReason(error) };
102
+ } finally {
103
+ clearTimeout(timeout);
104
+ options.signal?.removeEventListener("abort", onAbort);
105
+ }
106
+ }
107
+
108
+ export interface OpenRouterBudgetAccount {
109
+ credentialId: string;
110
+ revision: string;
111
+ apiKey: string;
112
+ }
113
+ interface Watcher {
114
+ identity: OpenRouterBudgetAccount;
115
+ tasks: Set<string>;
116
+ controller: AbortController | null;
117
+ timer: ReturnType<typeof setTimeout> | null;
118
+ last: { observedAt: number; result: OpenRouterBudgetResult } | null;
119
+ }
120
+ export interface OpenRouterBudgetPollerOptions {
121
+ emit: (frame: OpenRouterBudgetFrame) => void;
122
+ read?: (apiKey: string, options: ReadOpenRouterBudgetOptions) => Promise<OpenRouterBudgetResult>;
123
+ now?: () => number;
124
+ }
125
+
126
+ function identityKey(account: Pick<OpenRouterBudgetAccount, "credentialId" | "revision">): string {
127
+ return `${account.credentialId}\u0000${account.revision}`;
128
+ }
129
+
130
+ export class OpenRouterBudgetPoller {
131
+ private readonly watchers = new Map<string, Watcher>();
132
+ private readonly tasks = new Map<string, Set<string>>();
133
+ private readonly lastPoll = new Map<string, number>();
134
+ private readonly pending = new Set<Promise<void>>();
135
+ private readonly now: () => number;
136
+ private readonly read: NonNullable<OpenRouterBudgetPollerOptions["read"]>;
137
+
138
+ constructor(private readonly options: OpenRouterBudgetPollerOptions) {
139
+ this.now = options.now ?? Date.now;
140
+ this.read = options.read ?? readOpenRouterBudget;
141
+ }
142
+
143
+ /** The caller passes only OpenRouter accounts from the task's OPEN current
144
+ * lease. Passing [] ends that task's references and aborts unused reads. */
145
+ sync(taskId: string, accounts: readonly OpenRouterBudgetAccount[]): void {
146
+ this.pruneCooldowns();
147
+ const next = new Map(accounts.map(account => [identityKey(account), account]));
148
+ const previous = this.tasks.get(taskId) ?? new Set<string>();
149
+ for (const key of previous) if (!next.has(key)) this.removeTask(key, taskId);
150
+ const keys = new Set<string>();
151
+ for (const [key, identity] of next) {
152
+ keys.add(key);
153
+ let watcher = this.watchers.get(key);
154
+ if (!watcher) {
155
+ watcher = { identity: { ...identity }, tasks: new Set(), controller: null, timer: null, last: null };
156
+ this.watchers.set(key, watcher);
157
+ }
158
+ const added = !watcher.tasks.has(taskId);
159
+ watcher.tasks.add(taskId);
160
+ if (added && watcher.last) this.report(taskId, watcher, watcher.last);
161
+ if (!watcher.timer && !watcher.controller) this.schedule(key, watcher);
162
+ }
163
+ if (keys.size) this.tasks.set(taskId, keys);
164
+ else this.tasks.delete(taskId);
165
+ }
166
+
167
+ /** Removal releases the key, aborts its request when unused, and prevents a
168
+ * late response from publishing under a subsequent run or revision. */
169
+ stop(taskId?: string): void {
170
+ if (taskId !== undefined) {
171
+ for (const key of this.tasks.get(taskId) ?? []) this.removeTask(key, taskId);
172
+ this.tasks.delete(taskId);
173
+ return;
174
+ }
175
+ for (const watcher of this.watchers.values()) {
176
+ if (watcher.timer) clearTimeout(watcher.timer);
177
+ watcher.controller?.abort();
178
+ }
179
+ this.watchers.clear();
180
+ this.tasks.clear();
181
+ }
182
+
183
+ async drain(): Promise<void> {
184
+ await Promise.all([...this.pending]);
185
+ }
186
+
187
+ private removeTask(key: string, taskId: string): void {
188
+ const watcher = this.watchers.get(key);
189
+ if (!watcher) return;
190
+ watcher.tasks.delete(taskId);
191
+ if (watcher.tasks.size) return;
192
+ if (watcher.timer) clearTimeout(watcher.timer);
193
+ watcher.controller?.abort();
194
+ this.watchers.delete(key);
195
+ }
196
+
197
+ private schedule(key: string, watcher: Watcher): void {
198
+ const last = this.lastPoll.get(key);
199
+ const delay = last === undefined ? 0 : Math.max(0, last + OPENROUTER_BUDGET_POLL_MS - this.now());
200
+ if (delay === 0) {
201
+ this.start(key, watcher);
202
+ return;
203
+ }
204
+ watcher.timer = setTimeout(() => {
205
+ watcher.timer = null;
206
+ if (this.watchers.get(key) === watcher) this.start(key, watcher);
207
+ }, delay);
208
+ watcher.timer.unref();
209
+ }
210
+
211
+ private start(key: string, watcher: Watcher): void {
212
+ if (this.watchers.get(key) !== watcher || watcher.controller) return;
213
+ const controller = new AbortController();
214
+ watcher.controller = controller;
215
+ this.lastPoll.set(key, this.now());
216
+ const operation = (async (): Promise<void> => {
217
+ let result: OpenRouterBudgetResult;
218
+ try { result = await this.read(watcher.identity.apiKey, { signal: controller.signal, now: this.now }); }
219
+ catch { result = { status: "error", reason: "unreachable" }; }
220
+ if (this.watchers.get(key) !== watcher || controller.signal.aborted) return;
221
+ const observation = { observedAt: this.now(), result };
222
+ watcher.last = observation;
223
+ for (const taskId of watcher.tasks) this.report(taskId, watcher, observation);
224
+ })();
225
+ this.pending.add(operation);
226
+ void operation.finally(() => {
227
+ this.pending.delete(operation);
228
+ if (this.watchers.get(key) === watcher) {
229
+ watcher.controller = null;
230
+ this.schedule(key, watcher);
231
+ }
232
+ });
233
+ }
234
+
235
+ private report(taskId: string, watcher: Watcher, observation: NonNullable<Watcher["last"]>): void {
236
+ try {
237
+ this.options.emit({ kind: "openrouter.budget", taskId, credentialId: watcher.identity.credentialId, revision: watcher.identity.revision, observedAt: observation.observedAt, ...observation.result });
238
+ } catch {
239
+ // A disconnected transport is not an upstream response and never logs a key.
240
+ }
241
+ }
242
+
243
+ private pruneCooldowns(): void {
244
+ const before = this.now() - OPENROUTER_BUDGET_POLL_MS;
245
+ for (const [key, at] of this.lastPoll) if (at <= before && !this.watchers.has(key)) this.lastPoll.delete(key);
246
+ }
247
+ }