@runuai/host 0.9.170 → 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.
- package/lib/agents/claude.ts +13 -0
- package/lib/agents/codex.ts +11 -5
- package/lib/agents/session-state.ts +77 -0
- package/lib/agents/transport.ts +2 -0
- package/lib/agents/types.ts +3 -0
- package/lib/agents/uai.ts +3 -25
- package/lib/assignment-evidence.ts +2 -1
- package/lib/capture-flow.ts +14 -1
- package/lib/cloud-credential-leases.ts +9 -3
- package/lib/cloud-credential-runtime.ts +8 -0
- package/lib/connection-secret-redaction.ts +25 -0
- package/lib/credential-cleanup-inventory.ts +2 -1
- package/lib/credential-consumers/engines.ts +29 -5
- package/lib/credentials-activation-authority.ts +158 -10
- package/lib/custom-connection-capture.ts +395 -0
- package/lib/custom-connection-launch.ts +299 -0
- package/lib/custom-connection-runtime.ts +70 -0
- package/lib/engine-accounts.ts +1 -1
- package/lib/engine-credential-env.ts +2 -0
- package/lib/internal-connections.ts +5 -0
- package/lib/legacy-runtime-retirement.ts +43 -8
- package/lib/obs.ts +2 -1
- package/lib/openrouter-budget.ts +247 -0
- package/lib/orchestrator.ts +308 -26
- package/lib/secrets-proxy/address.ts +23 -0
- package/lib/task-credential-cleanup.ts +13 -3
- package/package.json +1 -1
- package/runner/runner.mjs +231 -3
- package/src/capture-wire.ts +102 -0
- package/src/cloud-credentials.ts +36 -372
- package/src/credential-location.ts +248 -0
- package/src/credential-wire-validation.ts +25 -0
- package/src/custom-connections.ts +207 -0
- package/src/main.ts +116 -3
- package/src/protocol.ts +111 -0
|
@@ -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
|
+
}
|
package/lib/engine-accounts.ts
CHANGED
|
@@ -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
|
+
}
|
|
@@ -22,7 +22,12 @@
|
|
|
22
22
|
* isolation), and which the operator removes or renames. A row with no
|
|
23
23
|
* runtime (a task that went down keeps its row, its runtime fields
|
|
24
24
|
* cleared) neither blocks nor vouches for anything. The plan records the
|
|
25
|
-
* engine it was made against.
|
|
25
|
+
* engine it was made against. Its rejection is a `RetirementBlockedError`:
|
|
26
|
+
* each blocker apart, and every unproven project named, so the cloud can
|
|
27
|
+
* show the operator all of them with the command that clears each
|
|
28
|
+
* (ADR-154 §13 build notes, 2026-10-27). Such a project is never removed
|
|
29
|
+
* here: a Compose label proves neither who made a stack nor that nothing
|
|
30
|
+
* uses it now (another runner may share this Docker and workspace root).
|
|
26
31
|
* - `retireLegacyTaskRuntimes` carries a plan out, only after the runner has
|
|
27
32
|
* committed to the switch. Each task's check, Stop and removal share its
|
|
28
33
|
* lifecycle slot: before its assignment closes and again before anything
|
|
@@ -117,6 +122,26 @@ export interface RetirementDeps extends RetirementPlanDeps {
|
|
|
117
122
|
log: (line: string) => void;
|
|
118
123
|
}
|
|
119
124
|
|
|
125
|
+
/**
|
|
126
|
+
* The plan's refusal: every blocker apart (`blockers`), and apart from them
|
|
127
|
+
* the `task-*` Compose projects nothing proves Uai's (all of them, for the
|
|
128
|
+
* operator to remove or rename). The message names both, as the log has.
|
|
129
|
+
* `transient`: nothing is wrong yet, the runner only cannot look now (its
|
|
130
|
+
* container runtime is still preparing); the next attempt looks again.
|
|
131
|
+
*/
|
|
132
|
+
export class RetirementBlockedError extends Error {
|
|
133
|
+
readonly blockers: string[];
|
|
134
|
+
readonly unprovenProjects: string[];
|
|
135
|
+
readonly transient: boolean;
|
|
136
|
+
constructor(message: string, options: { blockers?: string[]; unprovenProjects?: string[]; transient?: boolean } = {}) {
|
|
137
|
+
super(message);
|
|
138
|
+
this.name = "RetirementBlockedError";
|
|
139
|
+
this.blockers = options.blockers ?? [];
|
|
140
|
+
this.unprovenProjects = options.unprovenProjects ?? [];
|
|
141
|
+
this.transient = options.transient === true;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
120
145
|
export interface RetirementReport {
|
|
121
146
|
/** Tasks whose runtime is gone (or was already). */
|
|
122
147
|
retired: string[];
|
|
@@ -136,8 +161,9 @@ export async function planLegacyTaskRuntimeRetirement(deps: RetirementPlanDeps):
|
|
|
136
161
|
const blockers: string[] = [];
|
|
137
162
|
const snapshot = deps.snapshot();
|
|
138
163
|
if (snapshot.runtimeProblem !== null || snapshot.machine === null) {
|
|
139
|
-
throw new
|
|
164
|
+
throw new RetirementBlockedError(
|
|
140
165
|
`task runtimes cannot be retired now: the container runtime is not ready (${snapshot.runtimeProblem ?? "no engine is pinned"})`,
|
|
166
|
+
{ transient: true },
|
|
141
167
|
);
|
|
142
168
|
}
|
|
143
169
|
if (!snapshot.fence.ok) blockers.push(snapshot.fence.detail ?? "the task rows do not match the pinned container runtime");
|
|
@@ -183,6 +209,7 @@ export async function planLegacyTaskRuntimeRetirement(deps: RetirementPlanDeps):
|
|
|
183
209
|
}
|
|
184
210
|
|
|
185
211
|
const appleOrphans: string[] = [];
|
|
212
|
+
const unprovenProjects: string[] = [];
|
|
186
213
|
try {
|
|
187
214
|
if (deps.apple) {
|
|
188
215
|
const named = new Set(rows.map((task) => `task-${task.taskId}-app`));
|
|
@@ -193,18 +220,26 @@ export async function planLegacyTaskRuntimeRetirement(deps: RetirementPlanDeps):
|
|
|
193
220
|
const named = new Set(rows.map((task) => `task-${task.taskId}`));
|
|
194
221
|
for (const project of await listTaskProjects(deps.docker)) {
|
|
195
222
|
if (named.has(project)) continue;
|
|
196
|
-
|
|
197
|
-
`Compose project ${project} has containers but no task on this runner: nothing proves it Uai's, so it is not removed. ` +
|
|
198
|
-
`If it was a task's, remove it (docker compose -p ${project} down); otherwise rename it`,
|
|
199
|
-
);
|
|
223
|
+
unprovenProjects.push(project);
|
|
200
224
|
}
|
|
201
225
|
}
|
|
202
226
|
} catch (error) {
|
|
203
227
|
blockers.push(`the task runtime inventory failed: ${message(error)}`);
|
|
204
228
|
}
|
|
205
229
|
|
|
206
|
-
if (blockers.length > 0) {
|
|
207
|
-
|
|
230
|
+
if (blockers.length > 0 || unprovenProjects.length > 0) {
|
|
231
|
+
const all = [
|
|
232
|
+
...blockers,
|
|
233
|
+
...unprovenProjects.map(
|
|
234
|
+
(project) =>
|
|
235
|
+
`Compose project ${project} has containers but no task on this runner: nothing proves it Uai's, so it is not removed. ` +
|
|
236
|
+
`If it was a task's, remove it (docker compose -p ${project} down); otherwise rename it`,
|
|
237
|
+
),
|
|
238
|
+
];
|
|
239
|
+
throw new RetirementBlockedError(`task runtimes cannot all be retired: ${all.join("; ").slice(0, 1500)}`, {
|
|
240
|
+
blockers,
|
|
241
|
+
unprovenProjects,
|
|
242
|
+
});
|
|
208
243
|
}
|
|
209
244
|
return { machine: snapshot.machine, removals, appleOrphans };
|
|
210
245
|
}
|
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]");
|