@aexhq/sdk 0.13.6
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/LICENSE +201 -0
- package/README.md +160 -0
- package/dist/_contracts/connection-ticket.d.ts +21 -0
- package/dist/_contracts/connection-ticket.js +49 -0
- package/dist/_contracts/event-envelope.d.ts +276 -0
- package/dist/_contracts/event-envelope.js +324 -0
- package/dist/_contracts/event-stream-client.d.ts +47 -0
- package/dist/_contracts/event-stream-client.js +141 -0
- package/dist/_contracts/http.d.ts +35 -0
- package/dist/_contracts/http.js +114 -0
- package/dist/_contracts/index.d.ts +28 -0
- package/dist/_contracts/index.js +29 -0
- package/dist/_contracts/managed-key.d.ts +74 -0
- package/dist/_contracts/managed-key.js +110 -0
- package/dist/_contracts/operations.d.ts +237 -0
- package/dist/_contracts/operations.js +632 -0
- package/dist/_contracts/provider-support.d.ts +220 -0
- package/dist/_contracts/provider-support.js +90 -0
- package/dist/_contracts/proxy-protocol.d.ts +257 -0
- package/dist/_contracts/proxy-protocol.js +234 -0
- package/dist/_contracts/proxy-validation.d.ts +19 -0
- package/dist/_contracts/proxy-validation.js +51 -0
- package/dist/_contracts/run-artifacts.d.ts +47 -0
- package/dist/_contracts/run-artifacts.js +101 -0
- package/dist/_contracts/run-config.d.ts +304 -0
- package/dist/_contracts/run-config.js +659 -0
- package/dist/_contracts/run-cost.d.ts +125 -0
- package/dist/_contracts/run-cost.js +616 -0
- package/dist/_contracts/run-custody.d.ts +226 -0
- package/dist/_contracts/run-custody.js +465 -0
- package/dist/_contracts/run-record.d.ts +127 -0
- package/dist/_contracts/run-record.js +177 -0
- package/dist/_contracts/run-retention.d.ts +213 -0
- package/dist/_contracts/run-retention.js +484 -0
- package/dist/_contracts/run-unit.d.ts +194 -0
- package/dist/_contracts/run-unit.js +215 -0
- package/dist/_contracts/runner-event.d.ts +114 -0
- package/dist/_contracts/runner-event.js +187 -0
- package/dist/_contracts/runtime-manifest.d.ts +106 -0
- package/dist/_contracts/runtime-manifest.js +98 -0
- package/dist/_contracts/runtime-security-profile.d.ts +27 -0
- package/dist/_contracts/runtime-security-profile.js +82 -0
- package/dist/_contracts/runtime-sizes.d.ts +144 -0
- package/dist/_contracts/runtime-sizes.js +136 -0
- package/dist/_contracts/runtime-types.d.ts +212 -0
- package/dist/_contracts/runtime-types.js +2 -0
- package/dist/_contracts/sdk-errors.d.ts +34 -0
- package/dist/_contracts/sdk-errors.js +52 -0
- package/dist/_contracts/sdk-secrets.d.ts +31 -0
- package/dist/_contracts/sdk-secrets.js +220 -0
- package/dist/_contracts/side-effect-audit.d.ts +129 -0
- package/dist/_contracts/side-effect-audit.js +494 -0
- package/dist/_contracts/sse.d.ts +74 -0
- package/dist/_contracts/sse.js +0 -0
- package/dist/_contracts/stable.d.ts +26 -0
- package/dist/_contracts/stable.js +44 -0
- package/dist/_contracts/status.d.ts +19 -0
- package/dist/_contracts/status.js +61 -0
- package/dist/_contracts/submission.d.ts +383 -0
- package/dist/_contracts/submission.js +1380 -0
- package/dist/agents-md.d.ts +46 -0
- package/dist/agents-md.js +83 -0
- package/dist/agents-md.js.map +1 -0
- package/dist/asset-upload.d.ts +66 -0
- package/dist/asset-upload.js +168 -0
- package/dist/asset-upload.js.map +1 -0
- package/dist/bundle.d.ts +33 -0
- package/dist/bundle.js +89 -0
- package/dist/bundle.js.map +1 -0
- package/dist/cli.mjs +4140 -0
- package/dist/cli.mjs.sha256 +1 -0
- package/dist/client.d.ts +460 -0
- package/dist/client.js +857 -0
- package/dist/client.js.map +1 -0
- package/dist/fetch-archive.d.ts +16 -0
- package/dist/fetch-archive.js +170 -0
- package/dist/fetch-archive.js.map +1 -0
- package/dist/file.d.ts +57 -0
- package/dist/file.js +153 -0
- package/dist/file.js.map +1 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.js +34 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp-server.d.ts +84 -0
- package/dist/mcp-server.js +114 -0
- package/dist/mcp-server.js.map +1 -0
- package/dist/node-fs.d.ts +12 -0
- package/dist/node-fs.js +44 -0
- package/dist/node-fs.js.map +1 -0
- package/dist/proxy-endpoint.d.ts +131 -0
- package/dist/proxy-endpoint.js +147 -0
- package/dist/proxy-endpoint.js.map +1 -0
- package/dist/skill.d.ts +117 -0
- package/dist/skill.js +169 -0
- package/dist/skill.js.map +1 -0
- package/dist/version.d.ts +9 -0
- package/dist/version.js +10 -0
- package/dist/version.js.map +1 -0
- package/docs/cleanup.md +38 -0
- package/docs/credentials.md +153 -0
- package/docs/events.md +76 -0
- package/docs/mcp.md +47 -0
- package/docs/outputs.md +157 -0
- package/docs/product-boundaries.md +57 -0
- package/docs/provider-runtime-capabilities.md +103 -0
- package/docs/quickstart.md +110 -0
- package/docs/release.md +99 -0
- package/docs/run-config.md +53 -0
- package/docs/run-record.md +39 -0
- package/docs/skills.md +139 -0
- package/docs/testing.md +29 -0
- package/package.json +47 -0
|
@@ -0,0 +1,1380 @@
|
|
|
1
|
+
import { authShapeHeaderName, PROXY_ALLOWED_METHODS, PROXY_ENDPOINT_DEFAULTS, PROXY_RESPONSE_MODES } from "./proxy-protocol.js";
|
|
2
|
+
// Re-exported from the protocol module (its canonical home, alongside the
|
|
3
|
+
// index-file shape the builder fills). Kept on the submission surface so
|
|
4
|
+
// existing `@aexhq/contracts` consumers of `PROXY_ENDPOINT_DEFAULTS` are
|
|
5
|
+
// unaffected by the move.
|
|
6
|
+
export { PROXY_ENDPOINT_DEFAULTS };
|
|
7
|
+
import { parseAssetRefFields, parseMcpServerRef, parseSkillRef } from "./run-config.js";
|
|
8
|
+
import { parseRunTimeout, parseRuntimeSize } from "./runtime-sizes.js";
|
|
9
|
+
import { parseRuntimeSecurityProfile } from "./runtime-security-profile.js";
|
|
10
|
+
import { assertManagedKeyAdmissionAllowed, parseCredentialMode } from "./managed-key.js";
|
|
11
|
+
/**
|
|
12
|
+
* Reserved prefix for aex-set runtime env vars (`AEX_CLI`,
|
|
13
|
+
* `AEX_RUNTIME_JSON`, …). Customer `environment.envVars` keys carrying this
|
|
14
|
+
* prefix are rejected at submission parse time so platform-set values
|
|
15
|
+
* cannot be silently overwritten.
|
|
16
|
+
*/
|
|
17
|
+
export const AEX_RESERVED_ENV_PREFIX = "AEX_";
|
|
18
|
+
/**
|
|
19
|
+
* Maximum number of `environment.envVars` entries accepted per
|
|
20
|
+
* submission. Picked to be generous for real customer config bags
|
|
21
|
+
* (the broll case ships a handful — `BROLL_STORE`, `BROLL_OUTPUTS`,
|
|
22
|
+
* `BROLL_MODE`, …) while still bounding the size of every RUNTIME
|
|
23
|
+
* file we mount into the container.
|
|
24
|
+
*/
|
|
25
|
+
export const ENV_VARS_MAX_ENTRIES = 64;
|
|
26
|
+
/** Maximum byte length of a single `environment.envVars` value. */
|
|
27
|
+
export const ENV_VARS_MAX_VALUE_BYTES = 4096;
|
|
28
|
+
/** Maximum total byte length of all `environment.envVars` keys+values combined. */
|
|
29
|
+
export const ENV_VARS_MAX_TOTAL_BYTES = 65536;
|
|
30
|
+
/**
|
|
31
|
+
* POSIX-shell-portable env var key: starts with `A-Z` or `_`, body is
|
|
32
|
+
* `A-Z`, `0-9`, `_`. We deliberately reject lowercase to keep
|
|
33
|
+
* `RUNTIME.env` readable and consistent with platform conventions; if
|
|
34
|
+
* a customer has lowercase keys today, they uppercase them at the
|
|
35
|
+
* call site.
|
|
36
|
+
*/
|
|
37
|
+
const ENV_VAR_KEY_PATTERN = /^[A-Z_][A-Z0-9_]*$/;
|
|
38
|
+
/**
|
|
39
|
+
* Package-manager ecosystems accepted by the public submission schema.
|
|
40
|
+
* The customer encodes the target manager as a `name` prefix
|
|
41
|
+
* `"<eco>:<pkg>"` (e.g. "pip:pandas", "npm:express", "apt:ffmpeg"); an
|
|
42
|
+
* UNPREFIXED name defaults to `apt`. After parsing, `PlatformPackage.name`
|
|
43
|
+
* is the bare package and `PlatformPackage.ecosystem` is the resolved
|
|
44
|
+
* manager.
|
|
45
|
+
*/
|
|
46
|
+
export const PLATFORM_PACKAGE_ECOSYSTEMS = ["apt", "npm", "pip"];
|
|
47
|
+
/**
|
|
48
|
+
* Render a parsed {@link PlatformPackage} as the version-embedded install
|
|
49
|
+
* string used by runtime materialization. The join differs per manager:
|
|
50
|
+
* - pip → `name==version`
|
|
51
|
+
* - npm → `name@version`
|
|
52
|
+
* - apt → `name=version`
|
|
53
|
+
* With no `version`, just the bare `name`. Pure; used by the managed runner
|
|
54
|
+
* package installer.
|
|
55
|
+
*/
|
|
56
|
+
export function packageInstallString(pkg) {
|
|
57
|
+
if (pkg.version === undefined) {
|
|
58
|
+
return pkg.name;
|
|
59
|
+
}
|
|
60
|
+
switch (pkg.ecosystem) {
|
|
61
|
+
case "pip":
|
|
62
|
+
return `${pkg.name}==${pkg.version}`;
|
|
63
|
+
case "npm":
|
|
64
|
+
return `${pkg.name}@${pkg.version}`;
|
|
65
|
+
case "apt":
|
|
66
|
+
return `${pkg.name}=${pkg.version}`;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Run-time provider selector. Aex exposes one customer interface
|
|
71
|
+
* for every provider. All new submissions execute through the managed
|
|
72
|
+
* runtime; provider selection only decides which upstream model route
|
|
73
|
+
* the managed provider-proxy uses.
|
|
74
|
+
*/
|
|
75
|
+
export const RUN_PROVIDERS = [
|
|
76
|
+
"anthropic",
|
|
77
|
+
"deepseek",
|
|
78
|
+
"openai",
|
|
79
|
+
"gemini",
|
|
80
|
+
"mistral"
|
|
81
|
+
];
|
|
82
|
+
export const DEFAULT_RUN_PROVIDER = "anthropic";
|
|
83
|
+
/**
|
|
84
|
+
* Customer-facing runtime selector. Optional on the wire; absent resolves
|
|
85
|
+
* to the same managed runtime as `"managed"`. `"native"` is no longer an
|
|
86
|
+
* accepted submission value and fails schema validation.
|
|
87
|
+
*/
|
|
88
|
+
export const RUNTIME_KINDS = ["managed"];
|
|
89
|
+
/**
|
|
90
|
+
* Centralized runtime-support validator. Native is removed from the public
|
|
91
|
+
* runtime enum, so an absent runtime and `"managed"` are the only supported
|
|
92
|
+
* inputs. Schema parsing rejects other runtime strings before this helper is
|
|
93
|
+
* reached, but the result type remains for SDK preflight checks.
|
|
94
|
+
*/
|
|
95
|
+
export function checkRuntimeSupported(provider, runtime) {
|
|
96
|
+
void provider;
|
|
97
|
+
return { ok: true };
|
|
98
|
+
}
|
|
99
|
+
const SECRETS_KEY = "secrets";
|
|
100
|
+
const PROXY_ENDPOINT_NAME_PATTERN = /^[a-z][a-z0-9_-]{0,62}$/;
|
|
101
|
+
const RESERVED_PROXY_ENDPOINT_NAMES = new Set(["proxy", "aex", "internal", "admin"]);
|
|
102
|
+
/**
|
|
103
|
+
* Headers the proxy never lets through, regardless of policy. Lowercase.
|
|
104
|
+
* Anything that could re-introduce credentials, cookies, or routing
|
|
105
|
+
* primitives. Kept in lockstep with the proxy route's reject list.
|
|
106
|
+
*/
|
|
107
|
+
const PROXY_DENY_HEADER_LIST = new Set([
|
|
108
|
+
"authorization",
|
|
109
|
+
"cookie",
|
|
110
|
+
"set-cookie",
|
|
111
|
+
"proxy-authorization",
|
|
112
|
+
"host",
|
|
113
|
+
"content-length",
|
|
114
|
+
"transfer-encoding",
|
|
115
|
+
"connection",
|
|
116
|
+
"upgrade",
|
|
117
|
+
"expect",
|
|
118
|
+
"x-forwarded-for",
|
|
119
|
+
"x-forwarded-host",
|
|
120
|
+
"x-forwarded-proto",
|
|
121
|
+
"x-real-ip"
|
|
122
|
+
]);
|
|
123
|
+
const deniedSecretFields = new Set([
|
|
124
|
+
"providerApiKey",
|
|
125
|
+
"anthropicApiKey",
|
|
126
|
+
"apiKey",
|
|
127
|
+
"accessToken",
|
|
128
|
+
"refreshToken",
|
|
129
|
+
"password",
|
|
130
|
+
"mcpCredentials",
|
|
131
|
+
"credentials"
|
|
132
|
+
]);
|
|
133
|
+
function parseEnvironment(input) {
|
|
134
|
+
if (input === undefined) {
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
const value = requireRecord(input, "submission.environment");
|
|
138
|
+
const allowed = new Set(["networking", "packages", "envVars"]);
|
|
139
|
+
for (const key of Object.keys(value)) {
|
|
140
|
+
if (!allowed.has(key)) {
|
|
141
|
+
throw new Error(`submission.environment.${key} is not an allowed field; permitted: networking, packages, envVars`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const networking = parseNetworking(value.networking);
|
|
145
|
+
const packages = parsePackages(value.packages);
|
|
146
|
+
const envVars = parseEnvVars(value.envVars);
|
|
147
|
+
if (!networking && !packages && !envVars) {
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
...(networking ? { networking } : {}),
|
|
152
|
+
...(packages ? { packages } : {}),
|
|
153
|
+
...(envVars ? { envVars } : {})
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Validate a customer-supplied `environment.envVars` map. Returns a
|
|
158
|
+
* frozen copy with keys in insertion order, or `undefined` when the
|
|
159
|
+
* input is absent / an empty object (treated as not supplied so the
|
|
160
|
+
* worker can omit the field from the parsed snapshot).
|
|
161
|
+
*
|
|
162
|
+
* Rules:
|
|
163
|
+
* - Must be a JSON object whose values are all strings.
|
|
164
|
+
* - Keys match `[A-Z_][A-Z0-9_]*` (POSIX-shell portable, uppercase
|
|
165
|
+
* only — keeps RUNTIME.env readable, matches platform convention).
|
|
166
|
+
* - Keys MUST NOT start with the reserved `AEX_` prefix; that
|
|
167
|
+
* prefix is owned by platform-set runtime keys and a collision
|
|
168
|
+
* would silently mask `__AEX_CLI__` etc. substitution
|
|
169
|
+
* targets.
|
|
170
|
+
* - Bounded: max ENV_VARS_MAX_ENTRIES entries, max
|
|
171
|
+
* ENV_VARS_MAX_VALUE_BYTES per value, max ENV_VARS_MAX_TOTAL_BYTES
|
|
172
|
+
* overall. The caps stop a runaway customer from making the
|
|
173
|
+
* mounted RUNTIME files unbounded.
|
|
174
|
+
* - Values are arbitrary UTF-8 strings, EXCEPT NUL bytes are
|
|
175
|
+
* rejected (NUL terminates C-strings and breaks env-var
|
|
176
|
+
* transport even inside the container).
|
|
177
|
+
*/
|
|
178
|
+
function parseEnvVars(input) {
|
|
179
|
+
if (input === undefined) {
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
const value = requireRecord(input, "submission.environment.envVars");
|
|
183
|
+
const keys = Object.keys(value);
|
|
184
|
+
if (keys.length === 0) {
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
if (keys.length > ENV_VARS_MAX_ENTRIES) {
|
|
188
|
+
throw new Error(`submission.environment.envVars has ${keys.length} entries; maximum is ${ENV_VARS_MAX_ENTRIES}`);
|
|
189
|
+
}
|
|
190
|
+
const out = {};
|
|
191
|
+
let totalBytes = 0;
|
|
192
|
+
for (const key of keys) {
|
|
193
|
+
if (!ENV_VAR_KEY_PATTERN.test(key)) {
|
|
194
|
+
throw new Error(`submission.environment.envVars.${key} key must match /^[A-Z_][A-Z0-9_]*$/`);
|
|
195
|
+
}
|
|
196
|
+
if (key.startsWith(AEX_RESERVED_ENV_PREFIX)) {
|
|
197
|
+
throw new Error(`submission.environment.envVars.${key} uses reserved prefix "${AEX_RESERVED_ENV_PREFIX}" (set by aex runtime)`);
|
|
198
|
+
}
|
|
199
|
+
const raw = value[key];
|
|
200
|
+
if (typeof raw !== "string") {
|
|
201
|
+
throw new Error(`submission.environment.envVars.${key} must be a string`);
|
|
202
|
+
}
|
|
203
|
+
if (raw.includes("\0")) {
|
|
204
|
+
throw new Error(`submission.environment.envVars.${key} must not contain NUL bytes`);
|
|
205
|
+
}
|
|
206
|
+
const valueBytes = Buffer.byteLength(raw, "utf8");
|
|
207
|
+
if (valueBytes > ENV_VARS_MAX_VALUE_BYTES) {
|
|
208
|
+
throw new Error(`submission.environment.envVars.${key} value is ${valueBytes} bytes; maximum is ${ENV_VARS_MAX_VALUE_BYTES}`);
|
|
209
|
+
}
|
|
210
|
+
totalBytes += Buffer.byteLength(key, "utf8") + valueBytes;
|
|
211
|
+
if (totalBytes > ENV_VARS_MAX_TOTAL_BYTES) {
|
|
212
|
+
throw new Error(`submission.environment.envVars total byte size exceeds maximum ${ENV_VARS_MAX_TOTAL_BYTES}`);
|
|
213
|
+
}
|
|
214
|
+
out[key] = raw;
|
|
215
|
+
}
|
|
216
|
+
return Object.freeze(out);
|
|
217
|
+
}
|
|
218
|
+
function parseNetworking(input) {
|
|
219
|
+
if (input === undefined) {
|
|
220
|
+
return undefined;
|
|
221
|
+
}
|
|
222
|
+
const value = requireRecord(input, "submission.environment.networking");
|
|
223
|
+
const allowed = new Set(["mode", "allowedHosts"]);
|
|
224
|
+
for (const key of Object.keys(value)) {
|
|
225
|
+
if (!allowed.has(key)) {
|
|
226
|
+
throw new Error(`submission.environment.networking.${key} is not an allowed field; permitted: mode, allowedHosts`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
const mode = optionalEnum(value.mode, "submission.environment.networking.mode", ["limited", "open"]);
|
|
230
|
+
if (!mode) {
|
|
231
|
+
throw new Error("submission.environment.networking.mode is required when networking is provided");
|
|
232
|
+
}
|
|
233
|
+
const allowedHosts = parseAllowedHosts(value.allowedHosts);
|
|
234
|
+
return allowedHosts ? { mode, allowedHosts } : { mode };
|
|
235
|
+
}
|
|
236
|
+
function parseAllowedHosts(input) {
|
|
237
|
+
if (input === undefined) {
|
|
238
|
+
return undefined;
|
|
239
|
+
}
|
|
240
|
+
if (!Array.isArray(input)) {
|
|
241
|
+
throw new Error("submission.environment.networking.allowedHosts must be an array of strings");
|
|
242
|
+
}
|
|
243
|
+
const seen = new Set();
|
|
244
|
+
return input.map((entry, index) => {
|
|
245
|
+
if (typeof entry !== "string" || entry.length === 0) {
|
|
246
|
+
throw new Error(`submission.environment.networking.allowedHosts[${index}] must be a non-empty string`);
|
|
247
|
+
}
|
|
248
|
+
const lower = entry.toLowerCase();
|
|
249
|
+
if (seen.has(lower)) {
|
|
250
|
+
throw new Error(`submission.environment.networking.allowedHosts duplicate entry: ${entry}`);
|
|
251
|
+
}
|
|
252
|
+
seen.add(lower);
|
|
253
|
+
return lower;
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
function parsePackages(input) {
|
|
257
|
+
if (input === undefined) {
|
|
258
|
+
return undefined;
|
|
259
|
+
}
|
|
260
|
+
if (!Array.isArray(input)) {
|
|
261
|
+
throw new Error("submission.environment.packages must be an array");
|
|
262
|
+
}
|
|
263
|
+
return input.map((entry, index) => {
|
|
264
|
+
const value = requireRecord(entry, `submission.environment.packages[${index}]`);
|
|
265
|
+
const allowed = new Set(["name", "version"]);
|
|
266
|
+
for (const key of Object.keys(value)) {
|
|
267
|
+
if (!allowed.has(key)) {
|
|
268
|
+
throw new Error(`submission.environment.packages[${index}].${key} is not an allowed field; permitted: name, version`);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
const rawName = requireString(value.name, `submission.environment.packages[${index}].name`);
|
|
272
|
+
const version = optionalString(value.version, `submission.environment.packages[${index}].version`);
|
|
273
|
+
// The ecosystem is encoded as a `name` prefix `"<eco>:<pkg>"`; an
|
|
274
|
+
// unprefixed name defaults to `apt`. A colon-delimited prefix that is
|
|
275
|
+
// NOT a known ecosystem is rejected (rather than silently folded into
|
|
276
|
+
// the package name) so a typo'd manager fails closed.
|
|
277
|
+
let ecosystem = "apt";
|
|
278
|
+
let name = rawName;
|
|
279
|
+
const colon = rawName.indexOf(":");
|
|
280
|
+
if (colon > 0) {
|
|
281
|
+
const prefix = rawName.slice(0, colon);
|
|
282
|
+
if (!PLATFORM_PACKAGE_ECOSYSTEMS.includes(prefix)) {
|
|
283
|
+
throw new Error(`submission.environment.packages[${index}].name has unknown ecosystem prefix "${prefix}:"; permitted: ${PLATFORM_PACKAGE_ECOSYSTEMS.join(", ")}`);
|
|
284
|
+
}
|
|
285
|
+
ecosystem = prefix;
|
|
286
|
+
name = rawName.slice(colon + 1);
|
|
287
|
+
}
|
|
288
|
+
if (name.length === 0) {
|
|
289
|
+
throw new Error(`submission.environment.packages[${index}].name resolves to an empty package after stripping the "${ecosystem}:" ecosystem prefix`);
|
|
290
|
+
}
|
|
291
|
+
return version ? { name, version, ecosystem } : { name, ecosystem };
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
function parseProxyEndpoints(input) {
|
|
295
|
+
if (input === undefined) {
|
|
296
|
+
return undefined;
|
|
297
|
+
}
|
|
298
|
+
if (!Array.isArray(input)) {
|
|
299
|
+
throw new Error("proxyEndpoints must be an array");
|
|
300
|
+
}
|
|
301
|
+
if (input.length === 0) {
|
|
302
|
+
return undefined;
|
|
303
|
+
}
|
|
304
|
+
const seen = new Set();
|
|
305
|
+
return input.map((entry, index) => {
|
|
306
|
+
const endpoint = parseProxyEndpoint(entry, `proxyEndpoints[${index}]`);
|
|
307
|
+
if (seen.has(endpoint.name)) {
|
|
308
|
+
throw new Error(`proxyEndpoints duplicate name: ${endpoint.name}`);
|
|
309
|
+
}
|
|
310
|
+
seen.add(endpoint.name);
|
|
311
|
+
return endpoint;
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
function parseProxyEndpoint(input, path) {
|
|
315
|
+
const value = requireRecord(input, path);
|
|
316
|
+
const allowed = new Set([
|
|
317
|
+
"name",
|
|
318
|
+
"baseUrl",
|
|
319
|
+
"authShape",
|
|
320
|
+
"allowMethods",
|
|
321
|
+
"allowPathPrefixes",
|
|
322
|
+
"allowHeaders",
|
|
323
|
+
"responseMode",
|
|
324
|
+
"maxRequestBytes",
|
|
325
|
+
"maxResponseBytes",
|
|
326
|
+
"timeoutMs",
|
|
327
|
+
"perCallBudget",
|
|
328
|
+
"responseByteBudget"
|
|
329
|
+
]);
|
|
330
|
+
for (const key of Object.keys(value)) {
|
|
331
|
+
if (!allowed.has(key)) {
|
|
332
|
+
throw new Error(`${path}.${key} is not an allowed field`);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
const name = requireString(value.name, `${path}.name`);
|
|
336
|
+
if (!PROXY_ENDPOINT_NAME_PATTERN.test(name)) {
|
|
337
|
+
throw new Error(`${path}.name must match ${PROXY_ENDPOINT_NAME_PATTERN} (lowercase letters, digits, '_' and '-'; <=63 chars)`);
|
|
338
|
+
}
|
|
339
|
+
if (RESERVED_PROXY_ENDPOINT_NAMES.has(name)) {
|
|
340
|
+
throw new Error(`${path}.name is reserved: ${name}`);
|
|
341
|
+
}
|
|
342
|
+
const baseUrl = parseProxyBaseUrl(value.baseUrl, `${path}.baseUrl`);
|
|
343
|
+
const authShape = parseProxyAuthShape(value.authShape, `${path}.authShape`);
|
|
344
|
+
const allowMethods = parseProxyMethods(value.allowMethods, `${path}.allowMethods`);
|
|
345
|
+
const allowPathPrefixes = parseProxyPathPrefixes(value.allowPathPrefixes, `${path}.allowPathPrefixes`);
|
|
346
|
+
const allowHeaders = parseProxyAllowedHeaders(value.allowHeaders, `${path}.allowHeaders`, authShape);
|
|
347
|
+
const responseMode = optionalEnum(value.responseMode, `${path}.responseMode`, PROXY_RESPONSE_MODES);
|
|
348
|
+
const maxRequestBytes = optionalPositiveInt(value.maxRequestBytes, `${path}.maxRequestBytes`);
|
|
349
|
+
const maxResponseBytes = optionalPositiveInt(value.maxResponseBytes, `${path}.maxResponseBytes`);
|
|
350
|
+
const timeoutMs = optionalPositiveInt(value.timeoutMs, `${path}.timeoutMs`);
|
|
351
|
+
const perCallBudget = optionalPositiveInt(value.perCallBudget, `${path}.perCallBudget`);
|
|
352
|
+
const responseByteBudget = optionalPositiveInt(value.responseByteBudget, `${path}.responseByteBudget`);
|
|
353
|
+
return {
|
|
354
|
+
name,
|
|
355
|
+
baseUrl,
|
|
356
|
+
authShape,
|
|
357
|
+
allowMethods,
|
|
358
|
+
allowPathPrefixes,
|
|
359
|
+
...(allowHeaders ? { allowHeaders } : {}),
|
|
360
|
+
...(responseMode ? { responseMode } : {}),
|
|
361
|
+
...(maxRequestBytes !== undefined ? { maxRequestBytes } : {}),
|
|
362
|
+
...(maxResponseBytes !== undefined ? { maxResponseBytes } : {}),
|
|
363
|
+
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
|
364
|
+
...(perCallBudget !== undefined ? { perCallBudget } : {}),
|
|
365
|
+
...(responseByteBudget !== undefined ? { responseByteBudget } : {})
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
function parseProxyBaseUrl(input, field) {
|
|
369
|
+
const raw = requireString(input, field);
|
|
370
|
+
let parsed;
|
|
371
|
+
try {
|
|
372
|
+
parsed = new URL(raw);
|
|
373
|
+
}
|
|
374
|
+
catch {
|
|
375
|
+
throw new Error(`${field} must be a valid absolute URL`);
|
|
376
|
+
}
|
|
377
|
+
if (parsed.protocol !== "https:") {
|
|
378
|
+
throw new Error(`${field} must use https://`);
|
|
379
|
+
}
|
|
380
|
+
if (parsed.username || parsed.password) {
|
|
381
|
+
throw new Error(`${field} must not embed credentials`);
|
|
382
|
+
}
|
|
383
|
+
if (parsed.search || parsed.hash) {
|
|
384
|
+
throw new Error(`${field} must not include a query string or fragment`);
|
|
385
|
+
}
|
|
386
|
+
// Normalize: strip trailing slash so prefix matching is predictable.
|
|
387
|
+
const normalized = `${parsed.origin}${parsed.pathname.replace(/\/+$/, "")}`;
|
|
388
|
+
return normalized;
|
|
389
|
+
}
|
|
390
|
+
function parseProxyAuthShape(input, field) {
|
|
391
|
+
const value = requireRecord(input, field);
|
|
392
|
+
const type = requireString(value.type, `${field}.type`);
|
|
393
|
+
switch (type) {
|
|
394
|
+
case "none":
|
|
395
|
+
assertOnlyKeys(value, field, ["type"]);
|
|
396
|
+
return { type: "none" };
|
|
397
|
+
case "bearer":
|
|
398
|
+
assertOnlyKeys(value, field, ["type"]);
|
|
399
|
+
return { type: "bearer" };
|
|
400
|
+
case "basic":
|
|
401
|
+
assertOnlyKeys(value, field, ["type"]);
|
|
402
|
+
return { type: "basic" };
|
|
403
|
+
case "header": {
|
|
404
|
+
assertOnlyKeys(value, field, ["type", "name"]);
|
|
405
|
+
const name = requireString(value.name, `${field}.name`);
|
|
406
|
+
assertHeaderName(name, `${field}.name`);
|
|
407
|
+
return { type: "header", name };
|
|
408
|
+
}
|
|
409
|
+
case "query": {
|
|
410
|
+
assertOnlyKeys(value, field, ["type", "name"]);
|
|
411
|
+
const name = requireString(value.name, `${field}.name`);
|
|
412
|
+
if (!/^[a-zA-Z0-9_\-.]{1,64}$/.test(name)) {
|
|
413
|
+
throw new Error(`${field}.name must be a URL-safe identifier (<=64 chars)`);
|
|
414
|
+
}
|
|
415
|
+
return { type: "query", name };
|
|
416
|
+
}
|
|
417
|
+
default:
|
|
418
|
+
throw new Error(`${field}.type must be one of: none, bearer, basic, header, query`);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
function parseProxyMethods(input, field) {
|
|
422
|
+
if (!Array.isArray(input) || input.length === 0) {
|
|
423
|
+
throw new Error(`${field} must be a non-empty array of HTTP methods`);
|
|
424
|
+
}
|
|
425
|
+
const seen = new Set();
|
|
426
|
+
for (const entry of input) {
|
|
427
|
+
if (typeof entry !== "string") {
|
|
428
|
+
throw new Error(`${field} entries must be strings`);
|
|
429
|
+
}
|
|
430
|
+
const upper = entry.toUpperCase();
|
|
431
|
+
if (!PROXY_ALLOWED_METHODS.includes(upper)) {
|
|
432
|
+
throw new Error(`${field} contains unsupported method: ${entry}`);
|
|
433
|
+
}
|
|
434
|
+
seen.add(upper);
|
|
435
|
+
}
|
|
436
|
+
return Array.from(seen);
|
|
437
|
+
}
|
|
438
|
+
function parseProxyPathPrefixes(input, field) {
|
|
439
|
+
if (!Array.isArray(input) || input.length === 0) {
|
|
440
|
+
throw new Error(`${field} must be a non-empty array of path prefixes`);
|
|
441
|
+
}
|
|
442
|
+
const seen = new Set();
|
|
443
|
+
for (const entry of input) {
|
|
444
|
+
if (typeof entry !== "string" || !entry.startsWith("/")) {
|
|
445
|
+
throw new Error(`${field} entries must be non-empty strings starting with '/'`);
|
|
446
|
+
}
|
|
447
|
+
// Reject traversal / encoded traversal at config time so we never
|
|
448
|
+
// need to second-guess at request time.
|
|
449
|
+
if (entry.includes("..") || entry.toLowerCase().includes("%2e%2e")) {
|
|
450
|
+
throw new Error(`${field} entry must not contain path traversal: ${entry}`);
|
|
451
|
+
}
|
|
452
|
+
seen.add(entry);
|
|
453
|
+
}
|
|
454
|
+
return Array.from(seen);
|
|
455
|
+
}
|
|
456
|
+
function parseProxyAllowedHeaders(input, field, authShape) {
|
|
457
|
+
if (input === undefined) {
|
|
458
|
+
return undefined;
|
|
459
|
+
}
|
|
460
|
+
if (!Array.isArray(input)) {
|
|
461
|
+
throw new Error(`${field} must be an array of header names`);
|
|
462
|
+
}
|
|
463
|
+
const seen = new Set();
|
|
464
|
+
const result = [];
|
|
465
|
+
for (const entry of input) {
|
|
466
|
+
if (typeof entry !== "string" || entry.length === 0) {
|
|
467
|
+
throw new Error(`${field} entries must be non-empty strings`);
|
|
468
|
+
}
|
|
469
|
+
const lower = entry.toLowerCase();
|
|
470
|
+
assertHeaderName(entry, field);
|
|
471
|
+
if (PROXY_DENY_HEADER_LIST.has(lower)) {
|
|
472
|
+
throw new Error(`${field} contains a forbidden header: ${entry}`);
|
|
473
|
+
}
|
|
474
|
+
const authHeader = authShapeHeaderName(authShape);
|
|
475
|
+
if (authHeader && lower === authHeader) {
|
|
476
|
+
throw new Error(`${field} must not contain the auth header for this endpoint (${authHeader}); the proxy injects it from secrets.proxyEndpointAuth`);
|
|
477
|
+
}
|
|
478
|
+
if (seen.has(lower)) {
|
|
479
|
+
continue;
|
|
480
|
+
}
|
|
481
|
+
seen.add(lower);
|
|
482
|
+
result.push(lower);
|
|
483
|
+
}
|
|
484
|
+
return result;
|
|
485
|
+
}
|
|
486
|
+
function assertHeaderName(value, field) {
|
|
487
|
+
// RFC 7230 token chars, conservative.
|
|
488
|
+
if (!/^[A-Za-z0-9!#$%&'*+\-.^_`|~]{1,64}$/.test(value)) {
|
|
489
|
+
throw new Error(`${field} must be a valid header token (<=64 chars): ${value}`);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
function assertOnlyKeys(value, field, allowed) {
|
|
493
|
+
const permitted = new Set(allowed);
|
|
494
|
+
for (const key of Object.keys(value)) {
|
|
495
|
+
if (!permitted.has(key)) {
|
|
496
|
+
throw new Error(`${field}.${key} is not an allowed field; permitted: ${allowed.join(", ")}`);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
function crossValidateProxyEndpointsAndAuth(endpoints, auth) {
|
|
501
|
+
const endpointsList = endpoints ?? [];
|
|
502
|
+
const authList = auth ?? [];
|
|
503
|
+
const endpointsByName = new Map(endpointsList.map((e) => [e.name, e]));
|
|
504
|
+
const authByName = new Map(authList.map((a) => [a.name, a]));
|
|
505
|
+
for (const endpoint of endpointsList) {
|
|
506
|
+
const authEntry = authByName.get(endpoint.name);
|
|
507
|
+
if (endpoint.authShape.type === "none") {
|
|
508
|
+
// Keyless endpoints carry no auth value. Reject any matching
|
|
509
|
+
// auth entry so callers don't accidentally ship a secret bound
|
|
510
|
+
// to a "none" endpoint (which would be silently ignored at
|
|
511
|
+
// request time — confusing and a leak risk).
|
|
512
|
+
if (authEntry) {
|
|
513
|
+
throw new Error(`proxyEndpoints[${endpoint.name}] has authShape "none" but a matching secrets.proxyEndpointAuth entry was supplied; remove the auth entry`);
|
|
514
|
+
}
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
if (!authEntry) {
|
|
518
|
+
throw new Error(`proxyEndpoints[${endpoint.name}] has no matching secrets.proxyEndpointAuth entry`);
|
|
519
|
+
}
|
|
520
|
+
if (authEntry.value.type !== endpoint.authShape.type) {
|
|
521
|
+
throw new Error(`secrets.proxyEndpointAuth[${endpoint.name}].value.type must equal proxyEndpoints[${endpoint.name}].authShape.type (expected ${endpoint.authShape.type}, got ${authEntry.value.type})`);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
for (const authEntry of authList) {
|
|
525
|
+
if (!endpointsByName.has(authEntry.name)) {
|
|
526
|
+
throw new Error(`secrets.proxyEndpointAuth[${authEntry.name}] has no matching proxyEndpoints entry`);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
const PROVIDER_SECRET_KEYS = ["anthropic", "deepseek", "openai", "gemini", "mistral"];
|
|
531
|
+
function parseInlineSecrets(input) {
|
|
532
|
+
const value = requireRecord(input, "secrets");
|
|
533
|
+
const allowedTopLevel = new Set([
|
|
534
|
+
...PROVIDER_SECRET_KEYS,
|
|
535
|
+
"mcpServers",
|
|
536
|
+
"proxyEndpointAuth"
|
|
537
|
+
]);
|
|
538
|
+
for (const key of Object.keys(value)) {
|
|
539
|
+
if (key.startsWith("__aex_")) {
|
|
540
|
+
// Platform-internal namespace (e.g. __aex_proxy_token). The BFF
|
|
541
|
+
// mutates the vaulted bundle to inject these; inbound submissions
|
|
542
|
+
// are never allowed to set them, to prevent a malicious caller
|
|
543
|
+
// from forging the bearer.
|
|
544
|
+
throw new Error(`secrets.${key} uses the platform-internal __aex_ namespace and may not be set by callers`);
|
|
545
|
+
}
|
|
546
|
+
if (!allowedTopLevel.has(key)) {
|
|
547
|
+
throw new Error(`secrets.${key} is not an allowed field; permitted: ${[...allowedTopLevel].join(", ")}`);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
const anthropic = value.anthropic !== undefined ? parseProviderSecret(value.anthropic, "anthropic") : undefined;
|
|
551
|
+
const deepseek = value.deepseek !== undefined ? parseProviderSecret(value.deepseek, "deepseek") : undefined;
|
|
552
|
+
const openai = value.openai !== undefined ? parseProviderSecret(value.openai, "openai") : undefined;
|
|
553
|
+
const gemini = value.gemini !== undefined ? parseProviderSecret(value.gemini, "gemini") : undefined;
|
|
554
|
+
const mistral = value.mistral !== undefined ? parseProviderSecret(value.mistral, "mistral") : undefined;
|
|
555
|
+
const mcpServers = parseMcpServerSecrets(value.mcpServers);
|
|
556
|
+
const proxyEndpointAuth = parseProxyEndpointAuth(value.proxyEndpointAuth);
|
|
557
|
+
return {
|
|
558
|
+
...(anthropic ? { anthropic } : {}),
|
|
559
|
+
...(deepseek ? { deepseek } : {}),
|
|
560
|
+
...(openai ? { openai } : {}),
|
|
561
|
+
...(gemini ? { gemini } : {}),
|
|
562
|
+
...(mistral ? { mistral } : {}),
|
|
563
|
+
...(mcpServers ? { mcpServers } : {}),
|
|
564
|
+
...(proxyEndpointAuth ? { proxyEndpointAuth } : {})
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
function parseProviderSecret(input, provider) {
|
|
568
|
+
const field = `secrets.${provider}`;
|
|
569
|
+
const value = requireRecord(input, field);
|
|
570
|
+
const allowed = new Set(["apiKey", "baseUrl"]);
|
|
571
|
+
for (const key of Object.keys(value)) {
|
|
572
|
+
if (!allowed.has(key)) {
|
|
573
|
+
throw new Error(`${field}.${key} is not an allowed field; permitted: apiKey, baseUrl`);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
const apiKey = requireString(value.apiKey, `${field}.apiKey`);
|
|
577
|
+
const rawBaseUrl = optionalString(value.baseUrl, `${field}.baseUrl`);
|
|
578
|
+
if (rawBaseUrl === undefined) {
|
|
579
|
+
return { apiKey };
|
|
580
|
+
}
|
|
581
|
+
// Reuse the proxy-endpoint URL guard so provider baseUrl gets the
|
|
582
|
+
// same protection: https-only, no credentials, no query/fragment.
|
|
583
|
+
// The provider-proxy in the dashboard forwards a customer-controlled
|
|
584
|
+
// baseUrl to the upstream — accepting http:// (or a userinfo-laden
|
|
585
|
+
// URL) here is an SSRF / credential-leak vector.
|
|
586
|
+
const baseUrl = parseProxyBaseUrl(rawBaseUrl, `${field}.baseUrl`);
|
|
587
|
+
return { apiKey, baseUrl };
|
|
588
|
+
}
|
|
589
|
+
function parseMcpServerSecrets(input) {
|
|
590
|
+
if (input === undefined) {
|
|
591
|
+
return undefined;
|
|
592
|
+
}
|
|
593
|
+
if (!Array.isArray(input)) {
|
|
594
|
+
throw new Error("secrets.mcpServers must be an array");
|
|
595
|
+
}
|
|
596
|
+
const seen = new Set();
|
|
597
|
+
return input.map((entry, index) => {
|
|
598
|
+
const parsed = parseMcpServerSecret(entry, `secrets.mcpServers[${index}]`);
|
|
599
|
+
if (seen.has(parsed.name)) {
|
|
600
|
+
throw new Error(`secrets.mcpServers duplicate name: ${parsed.name}`);
|
|
601
|
+
}
|
|
602
|
+
seen.add(parsed.name);
|
|
603
|
+
return parsed;
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
function parseMcpServerSecret(input, path) {
|
|
607
|
+
const value = requireRecord(input, path);
|
|
608
|
+
const allowed = new Set(["name", "url", "headers"]);
|
|
609
|
+
for (const key of Object.keys(value)) {
|
|
610
|
+
if (!allowed.has(key)) {
|
|
611
|
+
throw new Error(`${path}.${key} is not an allowed field; permitted: name, url, headers`);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
const name = requireString(value.name, `${path}.name`);
|
|
615
|
+
const url = requireString(value.url, `${path}.url`);
|
|
616
|
+
const headers = optionalStringRecord(value.headers, `${path}.headers`);
|
|
617
|
+
return headers ? { name, url, headers } : { name, url };
|
|
618
|
+
}
|
|
619
|
+
function parseProxyEndpointAuth(input) {
|
|
620
|
+
if (input === undefined) {
|
|
621
|
+
return undefined;
|
|
622
|
+
}
|
|
623
|
+
if (!Array.isArray(input)) {
|
|
624
|
+
throw new Error("secrets.proxyEndpointAuth must be an array");
|
|
625
|
+
}
|
|
626
|
+
if (input.length === 0) {
|
|
627
|
+
return undefined;
|
|
628
|
+
}
|
|
629
|
+
const seen = new Set();
|
|
630
|
+
return input.map((entry, index) => {
|
|
631
|
+
const auth = parseProxyEndpointAuthEntry(entry, `secrets.proxyEndpointAuth[${index}]`);
|
|
632
|
+
if (seen.has(auth.name)) {
|
|
633
|
+
throw new Error(`secrets.proxyEndpointAuth duplicate name: ${auth.name}`);
|
|
634
|
+
}
|
|
635
|
+
seen.add(auth.name);
|
|
636
|
+
return auth;
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
function parseProxyEndpointAuthEntry(input, path) {
|
|
640
|
+
const value = requireRecord(input, path);
|
|
641
|
+
const allowed = new Set(["name", "value"]);
|
|
642
|
+
for (const key of Object.keys(value)) {
|
|
643
|
+
if (!allowed.has(key)) {
|
|
644
|
+
throw new Error(`${path}.${key} is not an allowed field; permitted: name, value`);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
const name = requireString(value.name, `${path}.name`);
|
|
648
|
+
if (!PROXY_ENDPOINT_NAME_PATTERN.test(name)) {
|
|
649
|
+
throw new Error(`${path}.name must match the same pattern as proxyEndpoints[].name (lowercase letters, digits, '_' and '-'; <=63 chars)`);
|
|
650
|
+
}
|
|
651
|
+
const valueField = parseProxyAuthValue(value.value, `${path}.value`);
|
|
652
|
+
return { name, value: valueField };
|
|
653
|
+
}
|
|
654
|
+
function parseProxyAuthValue(input, path) {
|
|
655
|
+
const value = requireRecord(input, path);
|
|
656
|
+
const type = requireString(value.type, `${path}.type`);
|
|
657
|
+
switch (type) {
|
|
658
|
+
case "bearer": {
|
|
659
|
+
assertOnlyKeys(value, path, ["type", "token"]);
|
|
660
|
+
const token = requireSecretValue(value.token, `${path}.token`);
|
|
661
|
+
return { type: "bearer", token };
|
|
662
|
+
}
|
|
663
|
+
case "basic": {
|
|
664
|
+
assertOnlyKeys(value, path, ["type", "username", "password"]);
|
|
665
|
+
// Usernames are not redactable in the strict sense (often public
|
|
666
|
+
// identifiers like an email), so we only enforce non-emptiness.
|
|
667
|
+
// The password is the secret-bearing half.
|
|
668
|
+
const username = requireString(value.username, `${path}.username`);
|
|
669
|
+
const password = requireSecretValue(value.password, `${path}.password`);
|
|
670
|
+
return { type: "basic", username, password };
|
|
671
|
+
}
|
|
672
|
+
case "header": {
|
|
673
|
+
assertOnlyKeys(value, path, ["type", "value"]);
|
|
674
|
+
const headerValue = requireSecretValue(value.value, `${path}.value`);
|
|
675
|
+
return { type: "header", value: headerValue };
|
|
676
|
+
}
|
|
677
|
+
case "query": {
|
|
678
|
+
assertOnlyKeys(value, path, ["type", "value"]);
|
|
679
|
+
const queryValue = requireSecretValue(value.value, `${path}.value`);
|
|
680
|
+
return { type: "query", value: queryValue };
|
|
681
|
+
}
|
|
682
|
+
default:
|
|
683
|
+
throw new Error(`${path}.type must be one of: bearer, basic, header, query`);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
/**
|
|
687
|
+
* The proxy body-redactor refuses to mask any derived target string shorter
|
|
688
|
+
* than this many bytes — masking a 1-byte literal would corrupt the response
|
|
689
|
+
* body. This is the floor for the *derived* redaction targets (e.g.
|
|
690
|
+
* `Bearer <token>`, base64 fragments), used by
|
|
691
|
+
* the hosted proxy redactor, which imports this constant so the two sides can
|
|
692
|
+
* never silently diverge.
|
|
693
|
+
*/
|
|
694
|
+
export const MIN_REDACTION_TARGET_BYTES = 4;
|
|
695
|
+
/**
|
|
696
|
+
* Minimum byte length for an accepted proxy secret *value*. Strictly greater
|
|
697
|
+
* than {@link MIN_REDACTION_TARGET_BYTES}: a secret short enough to fall under
|
|
698
|
+
* the redactor's floor could slip through unmasked, so the submission parser
|
|
699
|
+
* rejects it up front. The `satisfies` check below pins that invariant at
|
|
700
|
+
* compile time.
|
|
701
|
+
*/
|
|
702
|
+
const MIN_PROXY_SECRET_BYTES = 8;
|
|
703
|
+
// Invariant: an accepted secret must always be long enough for the redactor to
|
|
704
|
+
// mask it. If someone lowers MIN_PROXY_SECRET_BYTES below the redaction floor,
|
|
705
|
+
// this errors at compile time.
|
|
706
|
+
const _MIN_PROXY_SECRET_BYTES_OK = (MIN_PROXY_SECRET_BYTES >= MIN_REDACTION_TARGET_BYTES);
|
|
707
|
+
void _MIN_PROXY_SECRET_BYTES_OK;
|
|
708
|
+
function requireSecretValue(input, field) {
|
|
709
|
+
const value = requireString(input, field);
|
|
710
|
+
const byteLen = Buffer.byteLength(value, "utf8");
|
|
711
|
+
if (byteLen < MIN_PROXY_SECRET_BYTES) {
|
|
712
|
+
throw new Error(`${field} must be at least ${MIN_PROXY_SECRET_BYTES} bytes; shorter values cannot be reliably redacted from upstream responses`);
|
|
713
|
+
}
|
|
714
|
+
return value;
|
|
715
|
+
}
|
|
716
|
+
function assertNoSecretBearingFields(input, path) {
|
|
717
|
+
if (Array.isArray(input)) {
|
|
718
|
+
input.forEach((item, index) => assertNoSecretBearingFields(item, [...path, String(index)]));
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
if (!isRecord(input)) {
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
for (const [key, value] of Object.entries(input)) {
|
|
725
|
+
if (deniedSecretFields.has(key)) {
|
|
726
|
+
throw new Error(`Secret-bearing field is not allowed in platform submission: ${[...path, key].join(".")}`);
|
|
727
|
+
}
|
|
728
|
+
assertNoSecretBearingFields(value, [...path, key]);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
function requireRecord(input, field) {
|
|
732
|
+
if (!isRecord(input)) {
|
|
733
|
+
throw new Error(`${field} must be an object`);
|
|
734
|
+
}
|
|
735
|
+
return input;
|
|
736
|
+
}
|
|
737
|
+
function isRecord(input) {
|
|
738
|
+
return typeof input === "object" && input !== null && !Array.isArray(input);
|
|
739
|
+
}
|
|
740
|
+
function requireString(input, field) {
|
|
741
|
+
if (typeof input !== "string" || input.length === 0) {
|
|
742
|
+
throw new Error(`${field} must be a non-empty string`);
|
|
743
|
+
}
|
|
744
|
+
return input;
|
|
745
|
+
}
|
|
746
|
+
function optionalString(input, field) {
|
|
747
|
+
if (input === undefined) {
|
|
748
|
+
return undefined;
|
|
749
|
+
}
|
|
750
|
+
return requireString(input, field);
|
|
751
|
+
}
|
|
752
|
+
function optionalEnum(input, field, allowed) {
|
|
753
|
+
if (input === undefined) {
|
|
754
|
+
return undefined;
|
|
755
|
+
}
|
|
756
|
+
if (typeof input !== "string" || !allowed.includes(input)) {
|
|
757
|
+
throw new Error(`${field} must be one of: ${allowed.join(", ")}`);
|
|
758
|
+
}
|
|
759
|
+
return input;
|
|
760
|
+
}
|
|
761
|
+
function requireStringArray(input, field) {
|
|
762
|
+
if (!Array.isArray(input) || input.length === 0 || input.some((item) => typeof item !== "string" || item.length === 0)) {
|
|
763
|
+
throw new Error(`${field} must be a non-empty string array`);
|
|
764
|
+
}
|
|
765
|
+
return input;
|
|
766
|
+
}
|
|
767
|
+
function optionalStringRecord(input, field) {
|
|
768
|
+
if (input === undefined) {
|
|
769
|
+
return undefined;
|
|
770
|
+
}
|
|
771
|
+
const value = requireRecord(input, field);
|
|
772
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
773
|
+
if (typeof entry !== "string" || entry.length === 0) {
|
|
774
|
+
throw new Error(`${field}.${key} must be a non-empty string`);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
return value;
|
|
778
|
+
}
|
|
779
|
+
function optionalJsonRecord(input, field) {
|
|
780
|
+
if (input === undefined) {
|
|
781
|
+
return undefined;
|
|
782
|
+
}
|
|
783
|
+
const value = requireRecord(input, field);
|
|
784
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
785
|
+
if (!isJsonValue(entry)) {
|
|
786
|
+
throw new Error(`${field}.${key} must be JSON-serializable`);
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
return value;
|
|
790
|
+
}
|
|
791
|
+
function optionalPositiveInt(input, field) {
|
|
792
|
+
if (input === undefined) {
|
|
793
|
+
return undefined;
|
|
794
|
+
}
|
|
795
|
+
if (typeof input !== "number" || !Number.isSafeInteger(input) || input <= 0) {
|
|
796
|
+
throw new Error(`${field} must be a positive safe integer`);
|
|
797
|
+
}
|
|
798
|
+
return input;
|
|
799
|
+
}
|
|
800
|
+
function isJsonValue(input) {
|
|
801
|
+
if (typeof input === "number") {
|
|
802
|
+
return Number.isFinite(input);
|
|
803
|
+
}
|
|
804
|
+
if (input === null || typeof input === "string" || typeof input === "boolean") {
|
|
805
|
+
return true;
|
|
806
|
+
}
|
|
807
|
+
if (Array.isArray(input)) {
|
|
808
|
+
return input.every(isJsonValue);
|
|
809
|
+
}
|
|
810
|
+
if (isRecord(input)) {
|
|
811
|
+
return Object.values(input).every(isJsonValue);
|
|
812
|
+
}
|
|
813
|
+
return false;
|
|
814
|
+
}
|
|
815
|
+
export function parseRunSubmissionRequest(input, options = {}) {
|
|
816
|
+
const value = requireRecord(input, "submission");
|
|
817
|
+
const allowedTopLevelFields = new Set([
|
|
818
|
+
"workspaceId",
|
|
819
|
+
"idempotencyKey",
|
|
820
|
+
"credentialMode",
|
|
821
|
+
"provider",
|
|
822
|
+
"runtime",
|
|
823
|
+
"submission",
|
|
824
|
+
"runtimeSize",
|
|
825
|
+
"timeout",
|
|
826
|
+
"proxyEndpoints",
|
|
827
|
+
SECRETS_KEY
|
|
828
|
+
]);
|
|
829
|
+
for (const key of Object.keys(value)) {
|
|
830
|
+
if (!allowedTopLevelFields.has(key)) {
|
|
831
|
+
throw new Error(`submission.${key} is not an allowed field; permitted: ${[...allowedTopLevelFields].join(", ")}`);
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
// Defence in depth: scan every non-secrets field for credential-named
|
|
835
|
+
// keys. The `secrets` key is
|
|
836
|
+
// the only allow-listed home for credential material.
|
|
837
|
+
for (const [key, fieldValue] of Object.entries(value)) {
|
|
838
|
+
if (key === SECRETS_KEY) {
|
|
839
|
+
continue;
|
|
840
|
+
}
|
|
841
|
+
if (deniedSecretFields.has(key)) {
|
|
842
|
+
throw new Error(`Secret-bearing field is not allowed in platform submission: ${key}`);
|
|
843
|
+
}
|
|
844
|
+
assertNoSecretBearingFields(fieldValue, [key]);
|
|
845
|
+
}
|
|
846
|
+
const provider = parseRunProvider(value.provider);
|
|
847
|
+
const runtime = parseRuntimeKind(value.runtime);
|
|
848
|
+
const credentialMode = parseCredentialMode(value.credentialMode);
|
|
849
|
+
if (credentialMode === "managed") {
|
|
850
|
+
assertManagedKeyAdmissionAllowed(options.managedKeyPolicy);
|
|
851
|
+
}
|
|
852
|
+
// Cross-field validation via the centralized runtime-support validator.
|
|
853
|
+
const runtimeSupport = checkRuntimeSupported(provider, runtime);
|
|
854
|
+
if (!runtimeSupport.ok) {
|
|
855
|
+
throw new Error(runtimeSupport.message ?? "unsupported runtime");
|
|
856
|
+
}
|
|
857
|
+
const runtimeSize = parseRuntimeSize(value.runtimeSize);
|
|
858
|
+
const timeoutMs = parseRunTimeout(value.timeout);
|
|
859
|
+
const proxyEndpoints = parseProxyEndpoints(value.proxyEndpoints);
|
|
860
|
+
const secrets = parseInlineSecrets(value.secrets);
|
|
861
|
+
enforceCredentialSecretPolicy(provider, credentialMode, secrets);
|
|
862
|
+
crossValidateProxyEndpointsAndAuth(proxyEndpoints, secrets.proxyEndpointAuth);
|
|
863
|
+
const submission = parseSubmission(value.submission);
|
|
864
|
+
// mcpServers names must agree across the submission half and the
|
|
865
|
+
// secrets half — every secrets.mcpServers[i].name MUST resolve to a
|
|
866
|
+
// submission.mcpServers entry (no orphan secrets) AND the URL must
|
|
867
|
+
// match exactly. The reverse is allowed (an MCP server with no auth
|
|
868
|
+
// headers is a valid public-MCP mode).
|
|
869
|
+
if (secrets.mcpServers !== undefined) {
|
|
870
|
+
const declared = new Map(submission.mcpServers.map((m) => [m.name, m.url]));
|
|
871
|
+
for (const secret of secrets.mcpServers) {
|
|
872
|
+
const declaredUrl = declared.get(secret.name);
|
|
873
|
+
if (declaredUrl === undefined) {
|
|
874
|
+
throw new Error(`secrets.mcpServers[name=${secret.name}] has no matching submission.mcpServers entry`);
|
|
875
|
+
}
|
|
876
|
+
if (declaredUrl !== secret.url) {
|
|
877
|
+
throw new Error(`secrets.mcpServers[name=${secret.name}].url must equal submission.mcpServers[name=${secret.name}].url ` +
|
|
878
|
+
`(got submission=${declaredUrl}, secrets=${secret.url})`);
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
const candidate = {
|
|
883
|
+
workspaceId: "",
|
|
884
|
+
idempotencyKey: "",
|
|
885
|
+
credentialMode,
|
|
886
|
+
provider,
|
|
887
|
+
...(runtime ? { runtime } : {}),
|
|
888
|
+
submission,
|
|
889
|
+
secrets
|
|
890
|
+
};
|
|
891
|
+
const unsupportedManagedFeatures = collectManagedUnsupportedFeatures(candidate);
|
|
892
|
+
if (unsupportedManagedFeatures.length > 0) {
|
|
893
|
+
throw new RuntimeValidationError("feature_runtime_mismatch", `The managed runtime does not support these submission features: ` +
|
|
894
|
+
`${unsupportedManagedFeatures.join(", ")}. Remove them or use inline aex skills.`);
|
|
895
|
+
}
|
|
896
|
+
return {
|
|
897
|
+
workspaceId: requireString(value.workspaceId, "workspaceId"),
|
|
898
|
+
idempotencyKey: requireString(value.idempotencyKey, "idempotencyKey"),
|
|
899
|
+
credentialMode,
|
|
900
|
+
provider,
|
|
901
|
+
...(runtime ? { runtime } : {}),
|
|
902
|
+
submission,
|
|
903
|
+
...(runtimeSize ? { runtimeSize } : {}),
|
|
904
|
+
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
|
905
|
+
...(proxyEndpoints ? { proxyEndpoints } : {}),
|
|
906
|
+
secrets
|
|
907
|
+
};
|
|
908
|
+
}
|
|
909
|
+
function parseRuntimeKind(input) {
|
|
910
|
+
if (input === undefined) {
|
|
911
|
+
return undefined;
|
|
912
|
+
}
|
|
913
|
+
if (typeof input !== "string" || !RUNTIME_KINDS.includes(input)) {
|
|
914
|
+
throw new Error(`runtime must be one of: ${RUNTIME_KINDS.join(", ")} (got ${JSON.stringify(input)})`);
|
|
915
|
+
}
|
|
916
|
+
return input;
|
|
917
|
+
}
|
|
918
|
+
function parseRunProvider(input) {
|
|
919
|
+
if (input === undefined) {
|
|
920
|
+
return DEFAULT_RUN_PROVIDER;
|
|
921
|
+
}
|
|
922
|
+
if (typeof input !== "string" || !RUN_PROVIDERS.includes(input)) {
|
|
923
|
+
throw new Error(`provider must be one of: ${RUN_PROVIDERS.join(", ")} (got ${JSON.stringify(input)})`);
|
|
924
|
+
}
|
|
925
|
+
return input;
|
|
926
|
+
}
|
|
927
|
+
/**
|
|
928
|
+
* Cross-check the chosen provider against the supplied secrets bundle.
|
|
929
|
+
*
|
|
930
|
+
* - The matching provider's apiKey MUST be present.
|
|
931
|
+
* - Every OTHER provider's secret block MUST be absent (cross-provider
|
|
932
|
+
* secrets are explicitly rejected, not silently dropped — they are
|
|
933
|
+
* almost always a copy-paste mistake or a confused caller, and we
|
|
934
|
+
* want to fail loud).
|
|
935
|
+
* - MCP / proxy endpoint auth carry across providers and are not
|
|
936
|
+
* checked here.
|
|
937
|
+
*/
|
|
938
|
+
function enforceCredentialSecretPolicy(provider, credentialMode, secrets) {
|
|
939
|
+
if (credentialMode === "managed") {
|
|
940
|
+
for (const providerKey of PROVIDER_SECRET_KEYS) {
|
|
941
|
+
if (secrets[providerKey] !== undefined) {
|
|
942
|
+
throw new Error(`secrets.${providerKey} is not allowed when credentialMode is managed; provider access is resolved by the managed-key policy`);
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
947
|
+
const required = secrets[provider];
|
|
948
|
+
if (!required?.apiKey) {
|
|
949
|
+
throw new Error(`secrets.${provider}.apiKey is required when provider is ${provider}`);
|
|
950
|
+
}
|
|
951
|
+
for (const other of PROVIDER_SECRET_KEYS) {
|
|
952
|
+
if (other === provider) {
|
|
953
|
+
continue;
|
|
954
|
+
}
|
|
955
|
+
if (secrets[other] !== undefined) {
|
|
956
|
+
throw new Error(`secrets.${other} is not allowed when provider is ${provider}; remove it or set provider to ${other}`);
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
function parseSubmission(input) {
|
|
961
|
+
const value = requireRecord(input, "submission.submission");
|
|
962
|
+
const allowed = new Set([
|
|
963
|
+
"model",
|
|
964
|
+
"system",
|
|
965
|
+
"prompt",
|
|
966
|
+
"skills",
|
|
967
|
+
"agentsMd",
|
|
968
|
+
"files",
|
|
969
|
+
"mcpServers",
|
|
970
|
+
"environment",
|
|
971
|
+
"securityProfile",
|
|
972
|
+
"metadata",
|
|
973
|
+
"outputs",
|
|
974
|
+
"builtins",
|
|
975
|
+
"platform"
|
|
976
|
+
]);
|
|
977
|
+
for (const key of Object.keys(value)) {
|
|
978
|
+
if (!allowed.has(key)) {
|
|
979
|
+
throw new Error(`submission.${key} is not an allowed field; permitted: ${[...allowed].join(", ")}`);
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
const model = requireString(value.model, "submission.model");
|
|
983
|
+
const system = optionalString(value.system, "submission.system");
|
|
984
|
+
const prompt = parsePrompt(value.prompt);
|
|
985
|
+
const skills = parseSkills(value.skills);
|
|
986
|
+
const agentsMd = parseAgentsMd(value.agentsMd);
|
|
987
|
+
const files = parseFiles(value.files);
|
|
988
|
+
const mcpServers = parseMcpServers(value.mcpServers);
|
|
989
|
+
const environment = parseEnvironment(value.environment);
|
|
990
|
+
const securityProfile = parseRuntimeSecurityProfile(value.securityProfile);
|
|
991
|
+
const metadata = optionalJsonRecord(value.metadata, "submission.metadata");
|
|
992
|
+
const outputs = parseOutputs(value.outputs);
|
|
993
|
+
const builtins = parseBuiltins(value.builtins);
|
|
994
|
+
const platform = parsePlatformConfig(value.platform);
|
|
995
|
+
return {
|
|
996
|
+
model,
|
|
997
|
+
...(system ? { system } : {}),
|
|
998
|
+
prompt,
|
|
999
|
+
skills,
|
|
1000
|
+
agentsMd,
|
|
1001
|
+
files,
|
|
1002
|
+
mcpServers,
|
|
1003
|
+
...(environment ? { environment } : {}),
|
|
1004
|
+
...(securityProfile ? { securityProfile } : {}),
|
|
1005
|
+
...(metadata ? { metadata } : {}),
|
|
1006
|
+
...(outputs ? { outputs } : {}),
|
|
1007
|
+
...(builtins !== undefined ? { builtins } : {}),
|
|
1008
|
+
...(platform ? { platform } : {})
|
|
1009
|
+
};
|
|
1010
|
+
}
|
|
1011
|
+
function parsePlatformConfig(input) {
|
|
1012
|
+
if (input === undefined || input === null)
|
|
1013
|
+
return undefined;
|
|
1014
|
+
const value = requireRecord(input, "submission.platform");
|
|
1015
|
+
for (const key of Object.keys(value)) {
|
|
1016
|
+
if (key !== "systemPrompt") {
|
|
1017
|
+
throw new Error(`submission.platform.${key} is not an allowed field; permitted: systemPrompt`);
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
if (value.systemPrompt === undefined)
|
|
1021
|
+
return undefined;
|
|
1022
|
+
if (value.systemPrompt !== "default" && value.systemPrompt !== "off") {
|
|
1023
|
+
throw new Error(`submission.platform.systemPrompt must be "default" or "off"`);
|
|
1024
|
+
}
|
|
1025
|
+
return { systemPrompt: value.systemPrompt };
|
|
1026
|
+
}
|
|
1027
|
+
const BUILTIN_NAME_PATTERN = /^[a-z][a-z0-9_-]{0,63}$/;
|
|
1028
|
+
const MAX_BUILTINS = 16;
|
|
1029
|
+
function parseBuiltins(input) {
|
|
1030
|
+
if (input === undefined || input === null)
|
|
1031
|
+
return undefined;
|
|
1032
|
+
if (!Array.isArray(input)) {
|
|
1033
|
+
throw new Error("submission.builtins must be an array of strings");
|
|
1034
|
+
}
|
|
1035
|
+
if (input.length > MAX_BUILTINS) {
|
|
1036
|
+
throw new Error(`submission.builtins exceeds the max of ${MAX_BUILTINS} entries`);
|
|
1037
|
+
}
|
|
1038
|
+
const seen = new Set();
|
|
1039
|
+
const out = [];
|
|
1040
|
+
for (let i = 0; i < input.length; i++) {
|
|
1041
|
+
const v = input[i];
|
|
1042
|
+
if (typeof v !== "string") {
|
|
1043
|
+
throw new Error(`submission.builtins[${i}] must be a string`);
|
|
1044
|
+
}
|
|
1045
|
+
if (!BUILTIN_NAME_PATTERN.test(v)) {
|
|
1046
|
+
throw new Error(`submission.builtins[${i}] (${JSON.stringify(v)}) is not a valid Goose builtin name; expected /^[a-z][a-z0-9_-]{0,63}$/`);
|
|
1047
|
+
}
|
|
1048
|
+
if (seen.has(v))
|
|
1049
|
+
continue; // dedupe silently
|
|
1050
|
+
seen.add(v);
|
|
1051
|
+
out.push(v);
|
|
1052
|
+
}
|
|
1053
|
+
return out;
|
|
1054
|
+
}
|
|
1055
|
+
/**
|
|
1056
|
+
* Maximum number of output capture entries accepted per list.
|
|
1057
|
+
*
|
|
1058
|
+
* 32 is enough room for the typical "one or two capture roots" pattern
|
|
1059
|
+
* plus a generous margin for legitimate multi-root use cases (per-tool
|
|
1060
|
+
* output directory + scratch state + logs, repeated across a few
|
|
1061
|
+
* subdirectories), without inviting abuse of the synthetic-turn path
|
|
1062
|
+
* the worker drives at session terminal.
|
|
1063
|
+
*/
|
|
1064
|
+
const MAX_OUTPUT_DIRS = 32;
|
|
1065
|
+
/**
|
|
1066
|
+
* Maximum byte length of a single output capture entry (after UTF-8
|
|
1067
|
+
* encoding). 512 bytes comfortably covers `/very/long/nested/path`
|
|
1068
|
+
* style entries without letting a misuse smuggle large blobs through
|
|
1069
|
+
* the field.
|
|
1070
|
+
*/
|
|
1071
|
+
const MAX_OUTPUT_DIR_BYTES = 512;
|
|
1072
|
+
function parseOutputs(input) {
|
|
1073
|
+
if (input === undefined || input === null) {
|
|
1074
|
+
return undefined;
|
|
1075
|
+
}
|
|
1076
|
+
const value = requireRecord(input, "submission.outputs");
|
|
1077
|
+
const allowed = new Set(["allowedDirs", "deniedDirs"]);
|
|
1078
|
+
for (const key of Object.keys(value)) {
|
|
1079
|
+
if (!allowed.has(key)) {
|
|
1080
|
+
throw new Error(`submission.outputs.${key} is not an allowed field; permitted: ${[...allowed].join(", ")}`);
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
const allowedDirs = parseOutputAllowedDirs(value.allowedDirs);
|
|
1084
|
+
const deniedDirs = parseOutputDeniedDirs(value.deniedDirs);
|
|
1085
|
+
if (!allowedDirs && !deniedDirs) {
|
|
1086
|
+
return undefined;
|
|
1087
|
+
}
|
|
1088
|
+
return {
|
|
1089
|
+
...(allowedDirs ? { allowedDirs } : {}),
|
|
1090
|
+
...(deniedDirs ? { deniedDirs } : {})
|
|
1091
|
+
};
|
|
1092
|
+
}
|
|
1093
|
+
function parseOutputAllowedDirs(input) {
|
|
1094
|
+
if (input === undefined) {
|
|
1095
|
+
return undefined;
|
|
1096
|
+
}
|
|
1097
|
+
if (!Array.isArray(input)) {
|
|
1098
|
+
throw new Error("submission.outputs.allowedDirs must be an array of absolute UNIX paths");
|
|
1099
|
+
}
|
|
1100
|
+
if (input.length === 0) {
|
|
1101
|
+
// Treat an empty array as omission so the idempotency hash matches
|
|
1102
|
+
// the "no allowedDirs" case.
|
|
1103
|
+
return undefined;
|
|
1104
|
+
}
|
|
1105
|
+
if (input.length > MAX_OUTPUT_DIRS) {
|
|
1106
|
+
throw new Error(`submission.outputs.allowedDirs has ${input.length} entries; max is ${MAX_OUTPUT_DIRS}`);
|
|
1107
|
+
}
|
|
1108
|
+
const seen = new Set();
|
|
1109
|
+
const normalised = [];
|
|
1110
|
+
for (let i = 0; i < input.length; i++) {
|
|
1111
|
+
const item = input[i];
|
|
1112
|
+
if (typeof item !== "string") {
|
|
1113
|
+
throw new Error(`submission.outputs.allowedDirs[${i}] must be a string`);
|
|
1114
|
+
}
|
|
1115
|
+
if (item.length === 0) {
|
|
1116
|
+
throw new Error(`submission.outputs.allowedDirs[${i}] must be a non-empty absolute UNIX path`);
|
|
1117
|
+
}
|
|
1118
|
+
const bytes = new TextEncoder().encode(item).length;
|
|
1119
|
+
if (bytes > MAX_OUTPUT_DIR_BYTES) {
|
|
1120
|
+
throw new Error(`submission.outputs.allowedDirs[${i}] exceeds ${MAX_OUTPUT_DIR_BYTES} bytes (got ${bytes})`);
|
|
1121
|
+
}
|
|
1122
|
+
if (!item.startsWith("/")) {
|
|
1123
|
+
throw new Error(`submission.outputs.allowedDirs[${i}] must be an absolute UNIX path (start with '/')`);
|
|
1124
|
+
}
|
|
1125
|
+
if (item.includes("\0")) {
|
|
1126
|
+
throw new Error(`submission.outputs.allowedDirs[${i}] must not contain NUL bytes`);
|
|
1127
|
+
}
|
|
1128
|
+
if (item.includes("\n") || item.includes("\r")) {
|
|
1129
|
+
throw new Error(`submission.outputs.allowedDirs[${i}] must not contain newline characters`);
|
|
1130
|
+
}
|
|
1131
|
+
const segments = item.split("/");
|
|
1132
|
+
if (segments.includes("..")) {
|
|
1133
|
+
throw new Error(`submission.outputs.allowedDirs[${i}] must not contain '..' segments`);
|
|
1134
|
+
}
|
|
1135
|
+
const collapsed = segments
|
|
1136
|
+
.filter((seg, idx) => seg.length > 0 || idx === 0)
|
|
1137
|
+
.join("/");
|
|
1138
|
+
const stripped = collapsed.length > 1 && collapsed.endsWith("/")
|
|
1139
|
+
? collapsed.slice(0, -1)
|
|
1140
|
+
: collapsed;
|
|
1141
|
+
const canonical = stripped.length === 0 ? "/" : stripped;
|
|
1142
|
+
if (seen.has(canonical)) {
|
|
1143
|
+
continue;
|
|
1144
|
+
}
|
|
1145
|
+
seen.add(canonical);
|
|
1146
|
+
normalised.push(canonical);
|
|
1147
|
+
}
|
|
1148
|
+
return normalised;
|
|
1149
|
+
}
|
|
1150
|
+
function parseOutputDeniedDirs(input) {
|
|
1151
|
+
if (input === undefined) {
|
|
1152
|
+
return undefined;
|
|
1153
|
+
}
|
|
1154
|
+
if (!Array.isArray(input)) {
|
|
1155
|
+
throw new Error("submission.outputs.deniedDirs must be an array of strings");
|
|
1156
|
+
}
|
|
1157
|
+
if (input.length === 0) {
|
|
1158
|
+
return undefined;
|
|
1159
|
+
}
|
|
1160
|
+
if (input.length > MAX_OUTPUT_DIRS) {
|
|
1161
|
+
throw new Error(`submission.outputs.deniedDirs has ${input.length} entries; max is ${MAX_OUTPUT_DIRS}`);
|
|
1162
|
+
}
|
|
1163
|
+
const seen = new Set();
|
|
1164
|
+
const normalised = [];
|
|
1165
|
+
for (let i = 0; i < input.length; i++) {
|
|
1166
|
+
const item = input[i];
|
|
1167
|
+
if (typeof item !== "string") {
|
|
1168
|
+
throw new Error(`submission.outputs.deniedDirs[${i}] must be a string`);
|
|
1169
|
+
}
|
|
1170
|
+
if (item.length === 0) {
|
|
1171
|
+
throw new Error(`submission.outputs.deniedDirs[${i}] must be a non-empty pattern`);
|
|
1172
|
+
}
|
|
1173
|
+
const bytes = new TextEncoder().encode(item).length;
|
|
1174
|
+
if (bytes > MAX_OUTPUT_DIR_BYTES) {
|
|
1175
|
+
throw new Error(`submission.outputs.deniedDirs[${i}] exceeds ${MAX_OUTPUT_DIR_BYTES} bytes (got ${bytes})`);
|
|
1176
|
+
}
|
|
1177
|
+
if (item.includes("\0")) {
|
|
1178
|
+
throw new Error(`submission.outputs.deniedDirs[${i}] must not contain NUL bytes`);
|
|
1179
|
+
}
|
|
1180
|
+
if (item.includes("\n") || item.includes("\r")) {
|
|
1181
|
+
throw new Error(`submission.outputs.deniedDirs[${i}] must not contain newline characters`);
|
|
1182
|
+
}
|
|
1183
|
+
if (item.split("/").includes("..")) {
|
|
1184
|
+
throw new Error(`submission.outputs.deniedDirs[${i}] must not contain '..' segments`);
|
|
1185
|
+
}
|
|
1186
|
+
let canonical = item;
|
|
1187
|
+
if (item.startsWith("/")) {
|
|
1188
|
+
const collapsed = item
|
|
1189
|
+
.split("/")
|
|
1190
|
+
.filter((seg, idx) => seg.length > 0 || idx === 0)
|
|
1191
|
+
.join("/");
|
|
1192
|
+
canonical =
|
|
1193
|
+
collapsed.length > 1 && collapsed.endsWith("/") ? collapsed.slice(0, -1) : collapsed;
|
|
1194
|
+
}
|
|
1195
|
+
if (seen.has(canonical)) {
|
|
1196
|
+
continue;
|
|
1197
|
+
}
|
|
1198
|
+
seen.add(canonical);
|
|
1199
|
+
normalised.push(canonical);
|
|
1200
|
+
}
|
|
1201
|
+
return normalised;
|
|
1202
|
+
}
|
|
1203
|
+
function parsePrompt(input) {
|
|
1204
|
+
if (typeof input === "string") {
|
|
1205
|
+
if (input.length === 0) {
|
|
1206
|
+
throw new Error("submission.prompt must be non-empty");
|
|
1207
|
+
}
|
|
1208
|
+
if (input.trim().length === 0) {
|
|
1209
|
+
throw new Error("submission.prompt must contain non-whitespace text");
|
|
1210
|
+
}
|
|
1211
|
+
return [input];
|
|
1212
|
+
}
|
|
1213
|
+
if (!Array.isArray(input)) {
|
|
1214
|
+
throw new Error("submission.prompt must be a string or an array of strings");
|
|
1215
|
+
}
|
|
1216
|
+
if (input.length === 0) {
|
|
1217
|
+
throw new Error("submission.prompt array must be non-empty");
|
|
1218
|
+
}
|
|
1219
|
+
const parts = input.map((item, index) => {
|
|
1220
|
+
if (typeof item !== "string" || item.length === 0) {
|
|
1221
|
+
throw new Error(`submission.prompt[${index}] must be a non-empty string`);
|
|
1222
|
+
}
|
|
1223
|
+
return item;
|
|
1224
|
+
});
|
|
1225
|
+
if (parts.every((part) => part.trim().length === 0)) {
|
|
1226
|
+
throw new Error("submission.prompt must contain non-whitespace text");
|
|
1227
|
+
}
|
|
1228
|
+
return parts;
|
|
1229
|
+
}
|
|
1230
|
+
function parseSkills(input) {
|
|
1231
|
+
if (input === undefined) {
|
|
1232
|
+
return [];
|
|
1233
|
+
}
|
|
1234
|
+
if (!Array.isArray(input)) {
|
|
1235
|
+
throw new Error("submission.skills must be an array of SkillRef objects");
|
|
1236
|
+
}
|
|
1237
|
+
const seenProvider = new Set();
|
|
1238
|
+
const seenAssetId = new Set();
|
|
1239
|
+
return input.map((item, index) => {
|
|
1240
|
+
const ref = parseSkillRef(item, `submission.skills[${index}]`);
|
|
1241
|
+
if (ref.kind === "provider") {
|
|
1242
|
+
const key = `${ref.vendor}:${ref.skillId}:${ref.version ?? ""}`;
|
|
1243
|
+
if (seenProvider.has(key)) {
|
|
1244
|
+
throw new Error(`submission.skills duplicate provider skill: ${ref.vendor}:${ref.skillId}${ref.version ? `:${ref.version}` : ""}`);
|
|
1245
|
+
}
|
|
1246
|
+
seenProvider.add(key);
|
|
1247
|
+
}
|
|
1248
|
+
else if (ref.kind === "asset") {
|
|
1249
|
+
if (seenAssetId.has(ref.assetId)) {
|
|
1250
|
+
throw new Error(`submission.skills duplicate assetId: ${ref.assetId}`);
|
|
1251
|
+
}
|
|
1252
|
+
seenAssetId.add(ref.assetId);
|
|
1253
|
+
}
|
|
1254
|
+
return ref;
|
|
1255
|
+
});
|
|
1256
|
+
}
|
|
1257
|
+
function parseAgentsMd(input) {
|
|
1258
|
+
if (input === undefined)
|
|
1259
|
+
return [];
|
|
1260
|
+
if (!Array.isArray(input)) {
|
|
1261
|
+
throw new Error("submission.agentsMd must be an array of AgentsMdRef objects");
|
|
1262
|
+
}
|
|
1263
|
+
const seenAssetId = new Set();
|
|
1264
|
+
return input.map((item, index) => {
|
|
1265
|
+
const path = `submission.agentsMd[${index}]`;
|
|
1266
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) {
|
|
1267
|
+
throw new Error(`${path} must be an AgentsMdRef object`);
|
|
1268
|
+
}
|
|
1269
|
+
const raw = item;
|
|
1270
|
+
if (raw.kind !== "asset") {
|
|
1271
|
+
throw new Error(`${path}.kind must be 'asset' (got ${JSON.stringify(raw.kind)})`);
|
|
1272
|
+
}
|
|
1273
|
+
const fields = parseAssetRefFields(raw, path);
|
|
1274
|
+
if (seenAssetId.has(fields.assetId)) {
|
|
1275
|
+
throw new Error(`submission.agentsMd duplicate assetId: ${fields.assetId}`);
|
|
1276
|
+
}
|
|
1277
|
+
seenAssetId.add(fields.assetId);
|
|
1278
|
+
return { kind: "asset", assetId: fields.assetId, name: fields.name };
|
|
1279
|
+
});
|
|
1280
|
+
}
|
|
1281
|
+
function parseFiles(input) {
|
|
1282
|
+
if (input === undefined)
|
|
1283
|
+
return [];
|
|
1284
|
+
if (!Array.isArray(input)) {
|
|
1285
|
+
throw new Error("submission.files must be an array of FileRef objects");
|
|
1286
|
+
}
|
|
1287
|
+
const seenAssetId = new Set();
|
|
1288
|
+
return input.map((item, index) => {
|
|
1289
|
+
const path = `submission.files[${index}]`;
|
|
1290
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) {
|
|
1291
|
+
throw new Error(`${path} must be a FileRef object`);
|
|
1292
|
+
}
|
|
1293
|
+
const raw = item;
|
|
1294
|
+
if (raw.kind !== "asset") {
|
|
1295
|
+
throw new Error(`${path}.kind must be 'asset' (got ${JSON.stringify(raw.kind)})`);
|
|
1296
|
+
}
|
|
1297
|
+
const fields = parseAssetRefFields(raw, path);
|
|
1298
|
+
if (seenAssetId.has(fields.assetId)) {
|
|
1299
|
+
throw new Error(`submission.files duplicate assetId: ${fields.assetId}`);
|
|
1300
|
+
}
|
|
1301
|
+
seenAssetId.add(fields.assetId);
|
|
1302
|
+
if (fields.mountPath !== undefined && !fields.mountPath.startsWith("/")) {
|
|
1303
|
+
throw new Error(`${path}.mountPath must start with '/' if provided`);
|
|
1304
|
+
}
|
|
1305
|
+
return fields.mountPath !== undefined
|
|
1306
|
+
? { kind: "asset", assetId: fields.assetId, name: fields.name, mountPath: fields.mountPath }
|
|
1307
|
+
: { kind: "asset", assetId: fields.assetId, name: fields.name };
|
|
1308
|
+
});
|
|
1309
|
+
}
|
|
1310
|
+
function parseMcpServers(input) {
|
|
1311
|
+
if (input === undefined) {
|
|
1312
|
+
return [];
|
|
1313
|
+
}
|
|
1314
|
+
if (!Array.isArray(input)) {
|
|
1315
|
+
throw new Error("submission.mcpServers must be an array of {name, url} objects");
|
|
1316
|
+
}
|
|
1317
|
+
const seen = new Set();
|
|
1318
|
+
return input.map((item, index) => {
|
|
1319
|
+
const ref = parseMcpServerRef(item, `submission.mcpServers[${index}]`);
|
|
1320
|
+
if (seen.has(ref.name)) {
|
|
1321
|
+
throw new Error(`submission.mcpServers duplicate name: ${ref.name}`);
|
|
1322
|
+
}
|
|
1323
|
+
seen.add(ref.name);
|
|
1324
|
+
return ref;
|
|
1325
|
+
});
|
|
1326
|
+
}
|
|
1327
|
+
// ===========================================================================
|
|
1328
|
+
// Runtime dispatcher
|
|
1329
|
+
// ===========================================================================
|
|
1330
|
+
/**
|
|
1331
|
+
* Codes emitted when a submission contains features the active runtime cannot
|
|
1332
|
+
* serve. Code values are stable so dashboard / SDK error rendering can branch
|
|
1333
|
+
* on them.
|
|
1334
|
+
*/
|
|
1335
|
+
export const RUNTIME_VALIDATION_CODES = [
|
|
1336
|
+
"feature_runtime_mismatch"
|
|
1337
|
+
];
|
|
1338
|
+
/**
|
|
1339
|
+
* Thrown by `parseRunSubmissionRequest` and `selectRuntime` when the submitted
|
|
1340
|
+
* run cannot be served by the active managed runtime. The `code` field is part
|
|
1341
|
+
* of the public contract; keep it stable when phrasing changes.
|
|
1342
|
+
*/
|
|
1343
|
+
export class RuntimeValidationError extends Error {
|
|
1344
|
+
code;
|
|
1345
|
+
constructor(code, message) {
|
|
1346
|
+
super(message);
|
|
1347
|
+
this.name = "RuntimeValidationError";
|
|
1348
|
+
this.code = code;
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
/**
|
|
1352
|
+
* Walk the parsed submission and collect features that the active managed
|
|
1353
|
+
* runtime cannot serve. Provider-hosted skill refs (`Skill.provider(...)`) are
|
|
1354
|
+
* rejected now that new submissions only dispatch through managed runs.
|
|
1355
|
+
*/
|
|
1356
|
+
export function collectManagedUnsupportedFeatures(req) {
|
|
1357
|
+
const features = [];
|
|
1358
|
+
for (const skill of req.submission.skills) {
|
|
1359
|
+
if (skill.kind === "provider") {
|
|
1360
|
+
const versionSuffix = skill.version ? `, "${skill.version}"` : "";
|
|
1361
|
+
features.push(`Skill.provider("${skill.vendor}", "${skill.skillId}"${versionSuffix})`);
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
return features;
|
|
1365
|
+
}
|
|
1366
|
+
/**
|
|
1367
|
+
* Backward-incompatible replacement for the old dual-runtime dispatcher. It is
|
|
1368
|
+
* kept as a pure helper so SDK, CLI, and tests can resolve the runtime without
|
|
1369
|
+
* I/O.
|
|
1370
|
+
*/
|
|
1371
|
+
export function selectRuntime(req) {
|
|
1372
|
+
const unsupported = collectManagedUnsupportedFeatures(req);
|
|
1373
|
+
if (unsupported.length > 0) {
|
|
1374
|
+
throw new RuntimeValidationError("feature_runtime_mismatch", `The managed runtime does not support these submission features: ` +
|
|
1375
|
+
`${unsupported.join(", ")}. Remove them or use inline aex skills.`);
|
|
1376
|
+
}
|
|
1377
|
+
void req;
|
|
1378
|
+
return "managed";
|
|
1379
|
+
}
|
|
1380
|
+
//# sourceMappingURL=submission.js.map
|