antpath 0.10.15 → 0.11.4

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.
Files changed (75) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +16 -8
  3. package/dist/_shared/blueprint.d.ts +93 -108
  4. package/dist/_shared/blueprint.js +144 -78
  5. package/dist/_shared/cleanup-policy.d.ts +2 -2
  6. package/dist/_shared/cleanup-policy.js +2 -5
  7. package/dist/_shared/http.d.ts +2 -2
  8. package/dist/_shared/index.d.ts +5 -1
  9. package/dist/_shared/index.js +5 -1
  10. package/dist/_shared/mcp-proxy-url.d.ts +55 -0
  11. package/dist/_shared/mcp-proxy-url.js +65 -0
  12. package/dist/_shared/operations.d.ts +7 -8
  13. package/dist/_shared/operations.js +14 -20
  14. package/dist/_shared/provider-proxy-url.d.ts +64 -0
  15. package/dist/_shared/provider-proxy-url.js +73 -0
  16. package/dist/_shared/proxy-validation.d.ts +1 -1
  17. package/dist/_shared/proxy-validation.js +2 -2
  18. package/dist/_shared/run-unit.d.ts +23 -36
  19. package/dist/_shared/run-unit.js +30 -46
  20. package/dist/_shared/runner-event.d.ts +120 -0
  21. package/dist/_shared/runner-event.js +193 -0
  22. package/dist/_shared/runner-job.d.ts +159 -0
  23. package/dist/_shared/runner-job.js +54 -0
  24. package/dist/_shared/runtime-manifest.d.ts +191 -0
  25. package/dist/_shared/runtime-manifest.js +221 -0
  26. package/dist/_shared/runtime-types.d.ts +7 -16
  27. package/dist/_shared/stable.d.ts +15 -10
  28. package/dist/_shared/stable.js +15 -10
  29. package/dist/_shared/submission.d.ts +221 -73
  30. package/dist/_shared/submission.js +442 -212
  31. package/dist/_shared/telemetry.d.ts +2 -2
  32. package/dist/_shared/telemetry.js +2 -2
  33. package/dist/_shared/template/index.d.ts +0 -1
  34. package/dist/_shared/template/index.js +0 -1
  35. package/dist/agents-md.d.ts +25 -67
  36. package/dist/agents-md.js +35 -121
  37. package/dist/agents-md.js.map +1 -1
  38. package/dist/asset-upload.d.ts +34 -0
  39. package/dist/asset-upload.js +34 -0
  40. package/dist/asset-upload.js.map +1 -1
  41. package/dist/blueprint.d.ts +3 -3
  42. package/dist/bundle.d.ts +2 -2
  43. package/dist/bundle.js +1 -1
  44. package/dist/cli.mjs +191 -100
  45. package/dist/cli.mjs.sha256 +1 -1
  46. package/dist/client.d.ts +56 -19
  47. package/dist/client.js +147 -125
  48. package/dist/client.js.map +1 -1
  49. package/dist/file.d.ts +28 -94
  50. package/dist/file.js +35 -175
  51. package/dist/file.js.map +1 -1
  52. package/dist/index.d.ts +5 -5
  53. package/dist/index.js +4 -0
  54. package/dist/index.js.map +1 -1
  55. package/dist/mcp-server.d.ts +10 -2
  56. package/dist/mcp-server.js +17 -2
  57. package/dist/mcp-server.js.map +1 -1
  58. package/dist/skill.d.ts +44 -214
  59. package/dist/skill.js +50 -284
  60. package/dist/skill.js.map +1 -1
  61. package/dist/version.d.ts +1 -1
  62. package/dist/version.js +1 -1
  63. package/dist/version.js.map +1 -1
  64. package/docs/cleanup.md +1 -1
  65. package/docs/credentials.md +2 -2
  66. package/docs/events.md +8 -8
  67. package/docs/outputs.md +2 -0
  68. package/docs/quickstart.md +18 -2
  69. package/docs/skills.md +1 -3
  70. package/docs/templates.md +6 -5
  71. package/package.json +3 -2
  72. package/dist/_shared/secrets.d.ts +0 -7
  73. package/dist/_shared/secrets.js +0 -20
  74. package/dist/_shared/template/mapper.d.ts +0 -11
  75. package/dist/_shared/template/mapper.js +0 -70
@@ -1,6 +1,77 @@
1
1
  import { authShapeHeaderName, PROXY_ALLOWED_METHODS, PROXY_RESPONSE_MODES } from "./proxy-protocol.js";
2
- import { parseMcpServerRef, parseSkillRef } from "./blueprint.js";
3
- import { TRANSIENT_CONTENT_HASH_PATTERN, TRANSIENT_SLOT_PATTERN } from "./blueprint.js";
2
+ import { parseMcpServerRef, parseR2RefFields, parseSkillRef } from "./blueprint.js";
3
+ /**
4
+ * Reserved prefix for antpath-set runtime env vars (`ANTPATH_OUTPUTS`,
5
+ * `ANTPATH_CLI`, …). Customer `environment.envVars` keys carrying this
6
+ * prefix are rejected at submission parse time so platform-set values
7
+ * cannot be silently overwritten.
8
+ */
9
+ export const ANTPATH_RESERVED_ENV_PREFIX = "ANTPATH_";
10
+ /**
11
+ * Maximum number of `environment.envVars` entries accepted per
12
+ * submission. Picked to be generous for real customer config bags
13
+ * (the broll case ships a handful — `BROLL_STORE`, `BROLL_OUTPUTS`,
14
+ * `BROLL_MODE`, …) while still bounding the size of every RUNTIME
15
+ * file we mount into the container.
16
+ */
17
+ export const ENV_VARS_MAX_ENTRIES = 64;
18
+ /** Maximum byte length of a single `environment.envVars` value. */
19
+ export const ENV_VARS_MAX_VALUE_BYTES = 4096;
20
+ /** Maximum total byte length of all `environment.envVars` keys+values combined. */
21
+ export const ENV_VARS_MAX_TOTAL_BYTES = 65536;
22
+ /**
23
+ * POSIX-shell-portable env var key: starts with `A-Z` or `_`, body is
24
+ * `A-Z`, `0-9`, `_`. We deliberately reject lowercase to keep
25
+ * `RUNTIME.env` readable and consistent with platform conventions; if
26
+ * a customer has lowercase keys today, they uppercase them at the
27
+ * call site.
28
+ */
29
+ const ENV_VAR_KEY_PATTERN = /^[A-Z_][A-Z0-9_]*$/;
30
+ /**
31
+ * Run-time provider selector. Antpath exposes one customer interface
32
+ * for every provider; the runtime that backs each provider is decided
33
+ * by {@link selectRuntime}.
34
+ *
35
+ * - `anthropic` — defaults to the Anthropic Native runtime (Anthropic
36
+ * Managed Agents API). Customers can opt into the
37
+ * Goose Managed runtime via `runtime: "managed"`.
38
+ * - `deepseek` | `openai` | `gemini` | `mistral` — only the Goose
39
+ * Managed runtime is supported (LiteLLM-gateway'd
40
+ * call into the upstream).
41
+ *
42
+ * See `references/platform-rebuild-2026.md`.
43
+ */
44
+ export const RUN_PROVIDERS = [
45
+ "anthropic",
46
+ "deepseek",
47
+ "openai",
48
+ "gemini",
49
+ "mistral"
50
+ ];
51
+ export const DEFAULT_RUN_PROVIDER = "anthropic";
52
+ /**
53
+ * Customer-facing runtime selector. Optional on the wire — absent means
54
+ * "let the dispatcher route based on provider" ({@link selectRuntime}).
55
+ *
56
+ * - `native` — Anthropic Native runtime. Only valid for
57
+ * `provider: "anthropic"`. Routes through Anthropic's
58
+ * Managed Agents API.
59
+ * - `managed` — Goose Managed runtime. The only option for
60
+ * non-Anthropic providers; also available as an opt-out
61
+ * for `provider: "anthropic"` when the customer wants
62
+ * cross-provider parity.
63
+ *
64
+ * Stored verbatim in `runs.runtime`. See
65
+ * `references/platform-rebuild-2026.md` (Runtime dispatcher logic).
66
+ */
67
+ export const RUNTIME_KINDS = ["native", "managed"];
68
+ /**
69
+ * Providers whose runtime is implemented by Anthropic Native. Every
70
+ * other provider in {@link RUN_PROVIDERS} routes through Goose Managed.
71
+ * Kept as a constant so the dispatcher logic and the validation tests
72
+ * share one source of truth.
73
+ */
74
+ export const NATIVE_RUNTIME_PROVIDERS = ["anthropic"];
4
75
  const SECRETS_KEY = "secrets";
5
76
  /**
6
77
  * Default caps for a proxy endpoint when the submission doesn't specify
@@ -49,86 +120,105 @@ const deniedSecretFields = new Set([
49
120
  "mcpCredentials",
50
121
  "credentials"
51
122
  ]);
52
- export function parseRunSubmissionRequest(input) {
53
- const value = requireRecord(input, "submission");
54
- // The `secrets` block at the top level is the single allowlisted carrier
55
- // for credential material. Every other top-level field is recursively
56
- // scanned for secret-bearing keys; the `secrets` block has its own strict
57
- // schema (no unknown keys, no nested credential leakage).
58
- for (const [key, fieldValue] of Object.entries(value)) {
59
- if (key === SECRETS_KEY) {
60
- continue;
61
- }
62
- if (deniedSecretFields.has(key)) {
63
- throw new Error(`Secret-bearing field is not allowed in platform submission: ${key}`);
64
- }
65
- assertNoSecretBearingFields(fieldValue, [key]);
66
- }
67
- const variables = optionalJsonRecord(value.variables, "variables");
68
- const cleanup = parseCleanupPolicy(value.cleanup);
69
- const proxyEndpoints = parseProxyEndpoints(value.proxyEndpoints);
70
- const secrets = parseInlineSecrets(value.secrets);
71
- crossValidateProxyEndpointsAndAuth(proxyEndpoints, secrets.proxyEndpointAuth);
72
- return {
73
- workspaceId: requireString(value.workspaceId, "workspaceId"),
74
- idempotencyKey: requireString(value.idempotencyKey, "idempotencyKey"),
75
- template: parseTemplate(value.template),
76
- ...(variables ? { variables } : {}),
77
- ...(cleanup ? { cleanup } : {}),
78
- ...(proxyEndpoints ? { proxyEndpoints } : {}),
79
- secrets
80
- };
81
- }
82
- function parseTemplate(input) {
83
- const value = requireRecord(input, "template");
84
- const system = optionalString(value.system, "template.system");
85
- const metadata = optionalJsonRecord(value.metadata, "template.metadata");
86
- const environment = parseTemplateEnvironment(value.environment);
87
- return {
88
- name: requireString(value.name, "template.name"),
89
- model: requireString(value.model, "template.model"),
90
- templateHash: requireString(value.templateHash, "template.templateHash"),
91
- messages: requireStringArray(value.messages, "template.messages"),
92
- ...(system ? { system } : {}),
93
- ...(metadata ? { metadata } : {}),
94
- ...(environment ? { environment } : {})
95
- };
96
- }
97
- function parseTemplateEnvironment(input) {
123
+ function parseEnvironment(input) {
98
124
  if (input === undefined) {
99
125
  return undefined;
100
126
  }
101
- const value = requireRecord(input, "template.environment");
102
- const allowed = new Set(["networking", "packages"]);
127
+ const value = requireRecord(input, "submission.environment");
128
+ const allowed = new Set(["networking", "packages", "envVars"]);
103
129
  for (const key of Object.keys(value)) {
104
130
  if (!allowed.has(key)) {
105
- throw new Error(`template.environment.${key} is not an allowed field; permitted: networking, packages`);
131
+ throw new Error(`submission.environment.${key} is not an allowed field; permitted: networking, packages, envVars`);
106
132
  }
107
133
  }
108
- const networking = parseTemplateNetworking(value.networking);
109
- const packages = parseTemplatePackages(value.packages);
110
- if (!networking && !packages) {
134
+ const networking = parseNetworking(value.networking);
135
+ const packages = parsePackages(value.packages);
136
+ const envVars = parseEnvVars(value.envVars);
137
+ if (!networking && !packages && !envVars) {
111
138
  return undefined;
112
139
  }
113
140
  return {
114
141
  ...(networking ? { networking } : {}),
115
- ...(packages ? { packages } : {})
142
+ ...(packages ? { packages } : {}),
143
+ ...(envVars ? { envVars } : {})
116
144
  };
117
145
  }
118
- function parseTemplateNetworking(input) {
146
+ /**
147
+ * Validate a customer-supplied `environment.envVars` map. Returns a
148
+ * frozen copy with keys in insertion order, or `undefined` when the
149
+ * input is absent / an empty object (treated as not supplied so the
150
+ * worker can omit the field from the parsed snapshot).
151
+ *
152
+ * Rules:
153
+ * - Must be a JSON object whose values are all strings.
154
+ * - Keys match `[A-Z_][A-Z0-9_]*` (POSIX-shell portable, uppercase
155
+ * only — keeps RUNTIME.env readable, matches platform convention).
156
+ * - Keys MUST NOT start with the reserved `ANTPATH_` prefix; that
157
+ * prefix is owned by platform-set runtime keys and a collision
158
+ * would silently mask `__ANTPATH_OUTPUTS__` etc. substitution
159
+ * targets.
160
+ * - Bounded: max ENV_VARS_MAX_ENTRIES entries, max
161
+ * ENV_VARS_MAX_VALUE_BYTES per value, max ENV_VARS_MAX_TOTAL_BYTES
162
+ * overall. The caps stop a runaway customer from making the
163
+ * mounted RUNTIME files unbounded.
164
+ * - Values are arbitrary UTF-8 strings, EXCEPT NUL bytes are
165
+ * rejected (NUL terminates C-strings and breaks env-var
166
+ * transport even inside the container).
167
+ */
168
+ function parseEnvVars(input) {
119
169
  if (input === undefined) {
120
170
  return undefined;
121
171
  }
122
- const value = requireRecord(input, "template.environment.networking");
172
+ const value = requireRecord(input, "submission.environment.envVars");
173
+ const keys = Object.keys(value);
174
+ if (keys.length === 0) {
175
+ return undefined;
176
+ }
177
+ if (keys.length > ENV_VARS_MAX_ENTRIES) {
178
+ throw new Error(`submission.environment.envVars has ${keys.length} entries; maximum is ${ENV_VARS_MAX_ENTRIES}`);
179
+ }
180
+ const out = {};
181
+ let totalBytes = 0;
182
+ for (const key of keys) {
183
+ if (!ENV_VAR_KEY_PATTERN.test(key)) {
184
+ throw new Error(`submission.environment.envVars.${key} key must match /^[A-Z_][A-Z0-9_]*$/`);
185
+ }
186
+ if (key.startsWith(ANTPATH_RESERVED_ENV_PREFIX)) {
187
+ throw new Error(`submission.environment.envVars.${key} uses reserved prefix "${ANTPATH_RESERVED_ENV_PREFIX}" (set by antpath runtime)`);
188
+ }
189
+ const raw = value[key];
190
+ if (typeof raw !== "string") {
191
+ throw new Error(`submission.environment.envVars.${key} must be a string`);
192
+ }
193
+ if (raw.includes("\0")) {
194
+ throw new Error(`submission.environment.envVars.${key} must not contain NUL bytes`);
195
+ }
196
+ const valueBytes = Buffer.byteLength(raw, "utf8");
197
+ if (valueBytes > ENV_VARS_MAX_VALUE_BYTES) {
198
+ throw new Error(`submission.environment.envVars.${key} value is ${valueBytes} bytes; maximum is ${ENV_VARS_MAX_VALUE_BYTES}`);
199
+ }
200
+ totalBytes += Buffer.byteLength(key, "utf8") + valueBytes;
201
+ if (totalBytes > ENV_VARS_MAX_TOTAL_BYTES) {
202
+ throw new Error(`submission.environment.envVars total byte size exceeds maximum ${ENV_VARS_MAX_TOTAL_BYTES}`);
203
+ }
204
+ out[key] = raw;
205
+ }
206
+ return Object.freeze(out);
207
+ }
208
+ function parseNetworking(input) {
209
+ if (input === undefined) {
210
+ return undefined;
211
+ }
212
+ const value = requireRecord(input, "submission.environment.networking");
123
213
  const allowed = new Set(["mode", "allowedHosts"]);
124
214
  for (const key of Object.keys(value)) {
125
215
  if (!allowed.has(key)) {
126
- throw new Error(`template.environment.networking.${key} is not an allowed field; permitted: mode, allowedHosts`);
216
+ throw new Error(`submission.environment.networking.${key} is not an allowed field; permitted: mode, allowedHosts`);
127
217
  }
128
218
  }
129
- const mode = optionalEnum(value.mode, "template.environment.networking.mode", ["limited", "open"]);
219
+ const mode = optionalEnum(value.mode, "submission.environment.networking.mode", ["limited", "open"]);
130
220
  if (!mode) {
131
- throw new Error("template.environment.networking.mode is required when networking is provided");
221
+ throw new Error("submission.environment.networking.mode is required when networking is provided");
132
222
  }
133
223
  const allowedHosts = parseAllowedHosts(value.allowedHosts);
134
224
  return allowedHosts ? { mode, allowedHosts } : { mode };
@@ -138,38 +228,38 @@ function parseAllowedHosts(input) {
138
228
  return undefined;
139
229
  }
140
230
  if (!Array.isArray(input)) {
141
- throw new Error("template.environment.networking.allowedHosts must be an array of strings");
231
+ throw new Error("submission.environment.networking.allowedHosts must be an array of strings");
142
232
  }
143
233
  const seen = new Set();
144
234
  return input.map((entry, index) => {
145
235
  if (typeof entry !== "string" || entry.length === 0) {
146
- throw new Error(`template.environment.networking.allowedHosts[${index}] must be a non-empty string`);
236
+ throw new Error(`submission.environment.networking.allowedHosts[${index}] must be a non-empty string`);
147
237
  }
148
238
  const lower = entry.toLowerCase();
149
239
  if (seen.has(lower)) {
150
- throw new Error(`template.environment.networking.allowedHosts duplicate entry: ${entry}`);
240
+ throw new Error(`submission.environment.networking.allowedHosts duplicate entry: ${entry}`);
151
241
  }
152
242
  seen.add(lower);
153
243
  return lower;
154
244
  });
155
245
  }
156
- function parseTemplatePackages(input) {
246
+ function parsePackages(input) {
157
247
  if (input === undefined) {
158
248
  return undefined;
159
249
  }
160
250
  if (!Array.isArray(input)) {
161
- throw new Error("template.environment.packages must be an array");
251
+ throw new Error("submission.environment.packages must be an array");
162
252
  }
163
253
  return input.map((entry, index) => {
164
- const value = requireRecord(entry, `template.environment.packages[${index}]`);
254
+ const value = requireRecord(entry, `submission.environment.packages[${index}]`);
165
255
  const allowed = new Set(["name", "version"]);
166
256
  for (const key of Object.keys(value)) {
167
257
  if (!allowed.has(key)) {
168
- throw new Error(`template.environment.packages[${index}].${key} is not an allowed field; permitted: name, version`);
258
+ throw new Error(`submission.environment.packages[${index}].${key} is not an allowed field; permitted: name, version`);
169
259
  }
170
260
  }
171
- const name = requireString(value.name, `template.environment.packages[${index}].name`);
172
- const version = optionalString(value.version, `template.environment.packages[${index}].version`);
261
+ const name = requireString(value.name, `submission.environment.packages[${index}].name`);
262
+ const version = optionalString(value.version, `submission.environment.packages[${index}].version`);
173
263
  return version ? { name, version } : { name };
174
264
  });
175
265
  }
@@ -415,23 +505,19 @@ function parseCleanupPolicy(input) {
415
505
  }
416
506
  const value = requireRecord(input, "cleanup");
417
507
  const session = optionalEnum(value.session, "cleanup.session", ["retain", "delete"]);
418
- const claudeSession = optionalEnum(value.claudeSession, "cleanup.claudeSession", ["retain", "delete"]);
419
- if (session !== undefined && claudeSession !== undefined && session !== claudeSession) {
420
- throw new Error("cleanup.session and cleanup.claudeSession must agree; cleanup.claudeSession is deprecated");
421
- }
422
- const resolved = session ?? claudeSession;
423
- if (resolved === undefined) {
508
+ if (session === undefined) {
424
509
  return undefined;
425
510
  }
426
- const policy = { session: resolved };
427
- if (claudeSession !== undefined) {
428
- return { ...policy, claudeSession: resolved };
429
- }
430
- return policy;
511
+ return { session };
431
512
  }
513
+ const PROVIDER_SECRET_KEYS = ["anthropic", "deepseek", "openai", "gemini", "mistral"];
432
514
  function parseInlineSecrets(input) {
433
515
  const value = requireRecord(input, "secrets");
434
- const allowedTopLevel = new Set(["anthropic", "mcpServers", "skills", "proxyEndpointAuth"]);
516
+ const allowedTopLevel = new Set([
517
+ ...PROVIDER_SECRET_KEYS,
518
+ "mcpServers",
519
+ "proxyEndpointAuth"
520
+ ]);
435
521
  for (const key of Object.keys(value)) {
436
522
  if (key.startsWith("__antpath_")) {
437
523
  // Platform-internal namespace (e.g. __antpath_proxy_token). The BFF
@@ -441,33 +527,49 @@ function parseInlineSecrets(input) {
441
527
  throw new Error(`secrets.${key} uses the platform-internal __antpath_ namespace and may not be set by callers`);
442
528
  }
443
529
  if (!allowedTopLevel.has(key)) {
444
- throw new Error(`secrets.${key} is not an allowed field; permitted: anthropic, mcpServers, skills, proxyEndpointAuth`);
530
+ throw new Error(`secrets.${key} is not an allowed field; permitted: ${[...allowedTopLevel].join(", ")}`);
445
531
  }
446
532
  }
447
- const anthropic = parseAnthropicSecret(value.anthropic);
448
- const mcpServers = parseMcpServers(value.mcpServers);
449
- const skills = parseSkills(value.skills);
533
+ const anthropic = value.anthropic !== undefined ? parseProviderSecret(value.anthropic, "anthropic") : undefined;
534
+ const deepseek = value.deepseek !== undefined ? parseProviderSecret(value.deepseek, "deepseek") : undefined;
535
+ const openai = value.openai !== undefined ? parseProviderSecret(value.openai, "openai") : undefined;
536
+ const gemini = value.gemini !== undefined ? parseProviderSecret(value.gemini, "gemini") : undefined;
537
+ const mistral = value.mistral !== undefined ? parseProviderSecret(value.mistral, "mistral") : undefined;
538
+ const mcpServers = parseMcpServerSecrets(value.mcpServers);
450
539
  const proxyEndpointAuth = parseProxyEndpointAuth(value.proxyEndpointAuth);
451
540
  return {
452
- anthropic,
541
+ ...(anthropic ? { anthropic } : {}),
542
+ ...(deepseek ? { deepseek } : {}),
543
+ ...(openai ? { openai } : {}),
544
+ ...(gemini ? { gemini } : {}),
545
+ ...(mistral ? { mistral } : {}),
453
546
  ...(mcpServers ? { mcpServers } : {}),
454
- ...(skills ? { skills } : {}),
455
547
  ...(proxyEndpointAuth ? { proxyEndpointAuth } : {})
456
548
  };
457
549
  }
458
- function parseAnthropicSecret(input) {
459
- const value = requireRecord(input, "secrets.anthropic");
550
+ function parseProviderSecret(input, provider) {
551
+ const field = `secrets.${provider}`;
552
+ const value = requireRecord(input, field);
460
553
  const allowed = new Set(["apiKey", "baseUrl"]);
461
554
  for (const key of Object.keys(value)) {
462
555
  if (!allowed.has(key)) {
463
- throw new Error(`secrets.anthropic.${key} is not an allowed field; permitted: apiKey, baseUrl`);
556
+ throw new Error(`${field}.${key} is not an allowed field; permitted: apiKey, baseUrl`);
464
557
  }
465
558
  }
466
- const apiKey = requireString(value.apiKey, "secrets.anthropic.apiKey");
467
- const baseUrl = optionalString(value.baseUrl, "secrets.anthropic.baseUrl");
468
- return baseUrl ? { apiKey, baseUrl } : { apiKey };
559
+ const apiKey = requireString(value.apiKey, `${field}.apiKey`);
560
+ const rawBaseUrl = optionalString(value.baseUrl, `${field}.baseUrl`);
561
+ if (rawBaseUrl === undefined) {
562
+ return { apiKey };
563
+ }
564
+ // Reuse the proxy-endpoint URL guard so provider baseUrl gets the
565
+ // same protection: https-only, no credentials, no query/fragment.
566
+ // The provider-proxy in the dashboard forwards a customer-controlled
567
+ // baseUrl to the upstream — accepting http:// (or a userinfo-laden
568
+ // URL) here is an SSRF / credential-leak vector.
569
+ const baseUrl = parseProxyBaseUrl(rawBaseUrl, `${field}.baseUrl`);
570
+ return { apiKey, baseUrl };
469
571
  }
470
- function parseMcpServers(input) {
572
+ function parseMcpServerSecrets(input) {
471
573
  if (input === undefined) {
472
574
  return undefined;
473
575
  }
@@ -476,7 +578,7 @@ function parseMcpServers(input) {
476
578
  }
477
579
  const seen = new Set();
478
580
  return input.map((entry, index) => {
479
- const parsed = parseMcpServer(entry, `secrets.mcpServers[${index}]`);
581
+ const parsed = parseMcpServerSecret(entry, `secrets.mcpServers[${index}]`);
480
582
  if (seen.has(parsed.name)) {
481
583
  throw new Error(`secrets.mcpServers duplicate name: ${parsed.name}`);
482
584
  }
@@ -484,7 +586,7 @@ function parseMcpServers(input) {
484
586
  return parsed;
485
587
  });
486
588
  }
487
- function parseMcpServer(input, path) {
589
+ function parseMcpServerSecret(input, path) {
488
590
  const value = requireRecord(input, path);
489
591
  const allowed = new Set(["name", "url", "headers"]);
490
592
  for (const key of Object.keys(value)) {
@@ -497,27 +599,6 @@ function parseMcpServer(input, path) {
497
599
  const headers = optionalStringRecord(value.headers, `${path}.headers`);
498
600
  return headers ? { name, url, headers } : { name, url };
499
601
  }
500
- function parseSkills(input) {
501
- if (input === undefined) {
502
- return undefined;
503
- }
504
- if (!Array.isArray(input)) {
505
- throw new Error("secrets.skills must be an array");
506
- }
507
- return input.map((entry, index) => parseSkill(entry, `secrets.skills[${index}]`));
508
- }
509
- function parseSkill(input, path) {
510
- const value = requireRecord(input, path);
511
- const allowed = new Set(["skillId", "version"]);
512
- for (const key of Object.keys(value)) {
513
- if (!allowed.has(key)) {
514
- throw new Error(`${path}.${key} is not an allowed field; permitted: skillId, version`);
515
- }
516
- }
517
- const skillId = requireString(value.skillId, `${path}.skillId`);
518
- const version = optionalString(value.version, `${path}.version`);
519
- return version ? { skillId, version } : { skillId };
520
- }
521
602
  function parseProxyEndpointAuth(input) {
522
603
  if (input === undefined) {
523
604
  return undefined;
@@ -701,10 +782,10 @@ function isJsonValue(input) {
701
782
  }
702
783
  return false;
703
784
  }
704
- export function parseFlatRunSubmissionRequest(input) {
785
+ export function parseRunSubmissionRequest(input) {
705
786
  const value = requireRecord(input, "submission");
706
787
  // Defence in depth: scan every non-secrets field for credential-named
707
- // keys, exactly like `parseRunSubmissionRequest`. The `secrets` key is
788
+ // keys. The `secrets` key is
708
789
  // the only allow-listed home for credential material.
709
790
  for (const [key, fieldValue] of Object.entries(value)) {
710
791
  if (key === SECRETS_KEY) {
@@ -715,11 +796,19 @@ export function parseFlatRunSubmissionRequest(input) {
715
796
  }
716
797
  assertNoSecretBearingFields(fieldValue, [key]);
717
798
  }
799
+ const provider = parseRunProvider(value.provider);
800
+ const runtime = parseRuntimeKind(value.runtime);
801
+ // Cross-field validation: native is only valid for the
802
+ // Anthropic-native-capable providers.
803
+ if (runtime === "native" && !isNativeRuntimeProvider(provider)) {
804
+ throw new RuntimeValidationError("runtime_native_unsupported", `runtime: "native" is only supported for provider: ${formatProviderList(NATIVE_RUNTIME_PROVIDERS)} (got provider: "${provider}")`);
805
+ }
718
806
  const cleanup = parseCleanupPolicy(value.cleanup);
719
807
  const proxyEndpoints = parseProxyEndpoints(value.proxyEndpoints);
720
808
  const secrets = parseInlineSecrets(value.secrets);
809
+ enforceProviderSecretCoupling(provider, secrets);
721
810
  crossValidateProxyEndpointsAndAuth(proxyEndpoints, secrets.proxyEndpointAuth);
722
- const submission = parseFlatSubmission(value.submission);
811
+ const submission = parseSubmission(value.submission);
723
812
  // mcpServers names must agree across the submission half and the
724
813
  // secrets half — every secrets.mcpServers[i].name MUST resolve to a
725
814
  // submission.mcpServers entry (no orphan secrets) AND the URL must
@@ -738,16 +827,89 @@ export function parseFlatRunSubmissionRequest(input) {
738
827
  }
739
828
  }
740
829
  }
830
+ // Cross-field feature/runtime validation. When the caller explicitly
831
+ // opted into runtime: "managed", they must not also request any
832
+ // native-only feature (provider built-in skills, etc.). Done HERE in
833
+ // the parser so every entry-point (Worker, dashboard BFF, SDK) gets
834
+ // the same fail-closed behaviour. Previously this lived only in the
835
+ // separately-invokable selectRuntime() — and the dashboard BFF
836
+ // forgot to invoke it, letting incoherent runs land in the DB.
837
+ if (runtime === "managed") {
838
+ const candidate = {
839
+ workspaceId: "",
840
+ idempotencyKey: "",
841
+ provider,
842
+ runtime,
843
+ submission,
844
+ secrets
845
+ };
846
+ const nativeOnly = collectNativeOnlyFeatures(candidate);
847
+ if (nativeOnly.length > 0) {
848
+ throw new RuntimeValidationError("feature_runtime_mismatch", `runtime: "managed" rejects the following features: ${nativeOnly.join(", ")}. ` +
849
+ `Remove them or switch to runtime: "native".`);
850
+ }
851
+ }
741
852
  return {
742
853
  workspaceId: requireString(value.workspaceId, "workspaceId"),
743
854
  idempotencyKey: requireString(value.idempotencyKey, "idempotencyKey"),
855
+ provider,
856
+ ...(runtime ? { runtime } : {}),
744
857
  submission,
745
858
  ...(cleanup ? { cleanup } : {}),
746
859
  ...(proxyEndpoints ? { proxyEndpoints } : {}),
747
860
  secrets
748
861
  };
749
862
  }
750
- function parseFlatSubmission(input) {
863
+ function parseRuntimeKind(input) {
864
+ if (input === undefined) {
865
+ return undefined;
866
+ }
867
+ if (typeof input !== "string" || !RUNTIME_KINDS.includes(input)) {
868
+ throw new Error(`runtime must be one of: ${RUNTIME_KINDS.join(", ")} (got ${JSON.stringify(input)})`);
869
+ }
870
+ return input;
871
+ }
872
+ function isNativeRuntimeProvider(provider) {
873
+ return NATIVE_RUNTIME_PROVIDERS.includes(provider);
874
+ }
875
+ function formatProviderList(providers) {
876
+ return providers.map((p) => `"${p}"`).join(", ");
877
+ }
878
+ function parseRunProvider(input) {
879
+ if (input === undefined) {
880
+ return DEFAULT_RUN_PROVIDER;
881
+ }
882
+ if (typeof input !== "string" || !RUN_PROVIDERS.includes(input)) {
883
+ throw new Error(`provider must be one of: ${RUN_PROVIDERS.join(", ")} (got ${JSON.stringify(input)})`);
884
+ }
885
+ return input;
886
+ }
887
+ /**
888
+ * Cross-check the chosen provider against the supplied secrets bundle.
889
+ *
890
+ * - The matching provider's apiKey MUST be present.
891
+ * - Every OTHER provider's secret block MUST be absent (cross-provider
892
+ * secrets are explicitly rejected, not silently dropped — they are
893
+ * almost always a copy-paste mistake or a confused caller, and we
894
+ * want to fail loud).
895
+ * - MCP / proxy endpoint auth carry across providers and are not
896
+ * checked here.
897
+ */
898
+ function enforceProviderSecretCoupling(provider, secrets) {
899
+ const required = secrets[provider];
900
+ if (!required?.apiKey) {
901
+ throw new Error(`secrets.${provider}.apiKey is required when provider is ${provider}`);
902
+ }
903
+ for (const other of PROVIDER_SECRET_KEYS) {
904
+ if (other === provider) {
905
+ continue;
906
+ }
907
+ if (secrets[other] !== undefined) {
908
+ throw new Error(`secrets.${other} is not allowed when provider is ${provider}; remove it or set provider to ${other}`);
909
+ }
910
+ }
911
+ }
912
+ function parseSubmission(input) {
751
913
  const value = requireRecord(input, "submission.submission");
752
914
  const allowed = new Set([
753
915
  "model",
@@ -759,7 +921,8 @@ function parseFlatSubmission(input) {
759
921
  "mcpServers",
760
922
  "environment",
761
923
  "metadata",
762
- "outputDirs"
924
+ "outputDirs",
925
+ "builtins"
763
926
  ]);
764
927
  for (const key of Object.keys(value)) {
765
928
  if (!allowed.has(key)) {
@@ -768,14 +931,15 @@ function parseFlatSubmission(input) {
768
931
  }
769
932
  const model = requireString(value.model, "submission.model");
770
933
  const system = optionalString(value.system, "submission.system");
771
- const prompt = parseFlatPrompt(value.prompt);
772
- const skills = parseFlatSkills(value.skills);
773
- const agentsMd = parseFlatAgentsMd(value.agentsMd);
774
- const files = parseFlatFiles(value.files);
775
- const mcpServers = parseFlatMcpServers(value.mcpServers);
776
- const environment = parseTemplateEnvironment(value.environment);
934
+ const prompt = parsePrompt(value.prompt);
935
+ const skills = parseSkills(value.skills);
936
+ const agentsMd = parseAgentsMd(value.agentsMd);
937
+ const files = parseFiles(value.files);
938
+ const mcpServers = parseMcpServers(value.mcpServers);
939
+ const environment = parseEnvironment(value.environment);
777
940
  const metadata = optionalJsonRecord(value.metadata, "submission.metadata");
778
941
  const outputDirs = parseOutputDirs(value.outputDirs);
942
+ const builtins = parseBuiltins(value.builtins);
779
943
  return {
780
944
  model,
781
945
  ...(system ? { system } : {}),
@@ -786,9 +950,38 @@ function parseFlatSubmission(input) {
786
950
  mcpServers,
787
951
  ...(environment ? { environment } : {}),
788
952
  ...(metadata ? { metadata } : {}),
789
- ...(outputDirs ? { outputDirs } : {})
953
+ ...(outputDirs ? { outputDirs } : {}),
954
+ ...(builtins !== undefined ? { builtins } : {})
790
955
  };
791
956
  }
957
+ const BUILTIN_NAME_PATTERN = /^[a-z][a-z0-9_-]{0,63}$/;
958
+ const MAX_BUILTINS = 16;
959
+ function parseBuiltins(input) {
960
+ if (input === undefined || input === null)
961
+ return undefined;
962
+ if (!Array.isArray(input)) {
963
+ throw new Error("submission.builtins must be an array of strings");
964
+ }
965
+ if (input.length > MAX_BUILTINS) {
966
+ throw new Error(`submission.builtins exceeds the max of ${MAX_BUILTINS} entries`);
967
+ }
968
+ const seen = new Set();
969
+ const out = [];
970
+ for (let i = 0; i < input.length; i++) {
971
+ const v = input[i];
972
+ if (typeof v !== "string") {
973
+ throw new Error(`submission.builtins[${i}] must be a string`);
974
+ }
975
+ if (!BUILTIN_NAME_PATTERN.test(v)) {
976
+ throw new Error(`submission.builtins[${i}] (${JSON.stringify(v)}) is not a valid Goose builtin name; expected /^[a-z][a-z0-9_-]{0,63}$/`);
977
+ }
978
+ if (seen.has(v))
979
+ continue; // dedupe silently
980
+ seen.add(v);
981
+ out.push(v);
982
+ }
983
+ return out;
984
+ }
792
985
  /**
793
986
  * Maximum number of `outputDirs` entries accepted per submission.
794
987
  *
@@ -863,7 +1056,7 @@ function parseOutputDirs(input) {
863
1056
  }
864
1057
  return normalised;
865
1058
  }
866
- function parseFlatPrompt(input) {
1059
+ function parsePrompt(input) {
867
1060
  if (typeof input === "string") {
868
1061
  if (input.length === 0) {
869
1062
  throw new Error("submission.prompt must be non-empty");
@@ -883,138 +1076,87 @@ function parseFlatPrompt(input) {
883
1076
  return item;
884
1077
  });
885
1078
  }
886
- function parseFlatSkills(input) {
1079
+ function parseSkills(input) {
887
1080
  if (input === undefined) {
888
1081
  return [];
889
1082
  }
890
1083
  if (!Array.isArray(input)) {
891
1084
  throw new Error("submission.skills must be an array of SkillRef objects");
892
1085
  }
893
- const seenWorkspace = new Set();
894
1086
  const seenProvider = new Set();
895
- const seenTransientSlot = new Set();
1087
+ const seenR2Path = new Set();
896
1088
  return input.map((item, index) => {
897
1089
  const ref = parseSkillRef(item, `submission.skills[${index}]`);
898
- if (ref.kind === "workspace") {
899
- if (seenWorkspace.has(ref.id)) {
900
- throw new Error(`submission.skills duplicate workspace skill id: ${ref.id}`);
901
- }
902
- seenWorkspace.add(ref.id);
903
- }
904
- else if (ref.kind === "provider") {
1090
+ if (ref.kind === "provider") {
905
1091
  const key = `${ref.vendor}:${ref.skillId}:${ref.version ?? ""}`;
906
1092
  if (seenProvider.has(key)) {
907
1093
  throw new Error(`submission.skills duplicate provider skill: ${ref.vendor}:${ref.skillId}${ref.version ? `:${ref.version}` : ""}`);
908
1094
  }
909
1095
  seenProvider.add(key);
910
1096
  }
911
- else {
912
- // transient
913
- if (seenTransientSlot.has(ref.slot)) {
914
- throw new Error(`submission.skills duplicate transient slot: ${ref.slot}`);
1097
+ else if (ref.kind === "r2") {
1098
+ if (seenR2Path.has(ref.path)) {
1099
+ throw new Error(`submission.skills duplicate r2 path: ${ref.path}`);
915
1100
  }
916
- seenTransientSlot.add(ref.slot);
1101
+ seenR2Path.add(ref.path);
917
1102
  }
918
1103
  return ref;
919
1104
  });
920
1105
  }
921
- // Validation shared between AgentsMd and File refs since the wire
922
- // shape of an inline-supplied ref is identical (slot, name,
923
- // contentHash). The discriminator (`kind`) differs by call site.
924
- const WORKSPACE_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$/;
925
- function parseTransientWireFields(raw, path) {
926
- const slot = raw.slot;
927
- if (typeof slot !== "string" || !TRANSIENT_SLOT_PATTERN.test(slot)) {
928
- throw new Error(`${path}.slot must match ${TRANSIENT_SLOT_PATTERN.source}`);
929
- }
930
- const name = raw.name;
931
- if (typeof name !== "string" || !WORKSPACE_NAME_PATTERN.test(name)) {
932
- throw new Error(`${path}.name must match ${WORKSPACE_NAME_PATTERN.source}`);
933
- }
934
- const contentHash = raw.contentHash;
935
- if (typeof contentHash !== "string" || !TRANSIENT_CONTENT_HASH_PATTERN.test(contentHash)) {
936
- throw new Error(`${path}.contentHash must match ${TRANSIENT_CONTENT_HASH_PATTERN.source}`);
937
- }
938
- return { slot, name, contentHash };
939
- }
940
- function parseFlatAgentsMd(input) {
1106
+ function parseAgentsMd(input) {
941
1107
  if (input === undefined)
942
1108
  return [];
943
1109
  if (!Array.isArray(input)) {
944
1110
  throw new Error("submission.agentsMd must be an array of AgentsMdRef objects");
945
1111
  }
946
- const seenWorkspace = new Set();
947
- const seenTransientSlot = new Set();
1112
+ const seenR2Path = new Set();
948
1113
  return input.map((item, index) => {
949
1114
  const path = `submission.agentsMd[${index}]`;
950
1115
  if (!item || typeof item !== "object" || Array.isArray(item)) {
951
1116
  throw new Error(`${path} must be an AgentsMdRef object`);
952
1117
  }
953
1118
  const raw = item;
954
- if (raw.kind === "workspace_agentsmd") {
955
- if (typeof raw.id !== "string" || raw.id.length === 0) {
956
- throw new Error(`${path}.id must be a non-empty string`);
957
- }
958
- if (seenWorkspace.has(raw.id)) {
959
- throw new Error(`submission.agentsMd duplicate workspace id: ${raw.id}`);
960
- }
961
- seenWorkspace.add(raw.id);
962
- return { kind: "workspace_agentsmd", id: raw.id };
1119
+ if (raw.kind !== "r2") {
1120
+ throw new Error(`${path}.kind must be 'r2' (got ${JSON.stringify(raw.kind)})`);
963
1121
  }
964
- if (raw.kind === "transient_agentsmd") {
965
- const fields = parseTransientWireFields(raw, path);
966
- if (seenTransientSlot.has(fields.slot)) {
967
- throw new Error(`submission.agentsMd duplicate transient slot: ${fields.slot}`);
968
- }
969
- seenTransientSlot.add(fields.slot);
970
- return { kind: "transient_agentsmd", ...fields };
1122
+ const fields = parseR2RefFields(raw, path);
1123
+ if (seenR2Path.has(fields.path)) {
1124
+ throw new Error(`submission.agentsMd duplicate r2 path: ${fields.path}`);
971
1125
  }
972
- throw new Error(`${path}.kind must be 'workspace_agentsmd' or 'transient_agentsmd' (got ${JSON.stringify(raw.kind)})`);
1126
+ seenR2Path.add(fields.path);
1127
+ return { kind: "r2", path: fields.path, hash: fields.hash, sizeBytes: fields.sizeBytes, name: fields.name };
973
1128
  });
974
1129
  }
975
- function parseFlatFiles(input) {
1130
+ function parseFiles(input) {
976
1131
  if (input === undefined)
977
1132
  return [];
978
1133
  if (!Array.isArray(input)) {
979
1134
  throw new Error("submission.files must be an array of FileRef objects");
980
1135
  }
981
- const seenWorkspace = new Set();
982
- const seenTransientSlot = new Set();
1136
+ const seenR2Path = new Set();
983
1137
  return input.map((item, index) => {
984
1138
  const path = `submission.files[${index}]`;
985
1139
  if (!item || typeof item !== "object" || Array.isArray(item)) {
986
1140
  throw new Error(`${path} must be a FileRef object`);
987
1141
  }
988
1142
  const raw = item;
989
- if (raw.kind === "workspace_file") {
990
- if (typeof raw.id !== "string" || raw.id.length === 0) {
991
- throw new Error(`${path}.id must be a non-empty string`);
992
- }
993
- if (seenWorkspace.has(raw.id)) {
994
- throw new Error(`submission.files duplicate workspace id: ${raw.id}`);
995
- }
996
- seenWorkspace.add(raw.id);
997
- return { kind: "workspace_file", id: raw.id };
1143
+ if (raw.kind !== "r2") {
1144
+ throw new Error(`${path}.kind must be 'r2' (got ${JSON.stringify(raw.kind)})`);
998
1145
  }
999
- if (raw.kind === "transient_file") {
1000
- const fields = parseTransientWireFields(raw, path);
1001
- if (seenTransientSlot.has(fields.slot)) {
1002
- throw new Error(`submission.files duplicate transient slot: ${fields.slot}`);
1003
- }
1004
- seenTransientSlot.add(fields.slot);
1005
- const mountPath = raw.mountPath;
1006
- if (mountPath !== undefined) {
1007
- if (typeof mountPath !== "string" || !mountPath.startsWith("/") || mountPath.length === 0) {
1008
- throw new Error(`${path}.mountPath must be a non-empty absolute path starting with '/'`);
1009
- }
1010
- return { kind: "transient_file", ...fields, mountPath };
1011
- }
1012
- return { kind: "transient_file", ...fields };
1146
+ const fields = parseR2RefFields(raw, path);
1147
+ if (seenR2Path.has(fields.path)) {
1148
+ throw new Error(`submission.files duplicate r2 path: ${fields.path}`);
1149
+ }
1150
+ seenR2Path.add(fields.path);
1151
+ if (fields.mountPath !== undefined && !fields.mountPath.startsWith("/")) {
1152
+ throw new Error(`${path}.mountPath must start with '/' if provided`);
1013
1153
  }
1014
- throw new Error(`${path}.kind must be 'workspace_file' or 'transient_file' (got ${JSON.stringify(raw.kind)})`);
1154
+ return fields.mountPath !== undefined
1155
+ ? { kind: "r2", path: fields.path, hash: fields.hash, sizeBytes: fields.sizeBytes, name: fields.name, mountPath: fields.mountPath }
1156
+ : { kind: "r2", path: fields.path, hash: fields.hash, sizeBytes: fields.sizeBytes, name: fields.name };
1015
1157
  });
1016
1158
  }
1017
- function parseFlatMcpServers(input) {
1159
+ function parseMcpServers(input) {
1018
1160
  if (input === undefined) {
1019
1161
  return [];
1020
1162
  }
@@ -1031,4 +1173,92 @@ function parseFlatMcpServers(input) {
1031
1173
  return ref;
1032
1174
  });
1033
1175
  }
1176
+ // ===========================================================================
1177
+ // Runtime dispatcher
1178
+ // ===========================================================================
1179
+ /**
1180
+ * Codes the dispatcher emits when a submission can't be served by the
1181
+ * selected runtime. Surfaced both at parse time (cross-field shape
1182
+ * mismatch) and at dispatch time (feature mismatch). Code values are
1183
+ * stable so dashboard / SDK error rendering can branch on them.
1184
+ */
1185
+ export const RUNTIME_VALIDATION_CODES = [
1186
+ "runtime_native_unsupported",
1187
+ "feature_runtime_mismatch"
1188
+ ];
1189
+ /**
1190
+ * Thrown by `parseRunSubmissionRequest` (wire-shape mismatch) and by
1191
+ * `selectRuntime` (feature mismatch) when the submitted run cannot be
1192
+ * served by the chosen runtime. The `code` field is part of the public
1193
+ * contract — keep it stable when phrasings change.
1194
+ */
1195
+ export class RuntimeValidationError extends Error {
1196
+ code;
1197
+ constructor(code, message) {
1198
+ super(message);
1199
+ this.name = "RuntimeValidationError";
1200
+ this.code = code;
1201
+ }
1202
+ }
1203
+ /**
1204
+ * Walk the parsed submission and collect every feature that **only**
1205
+ * works on the Anthropic Native runtime. Today the only such feature
1206
+ * is a provider built-in skill ref (`Skill.provider(...)` — the
1207
+ * Anthropic Skills API is the resolver). Adding a new native-only
1208
+ * feature means extending this function AND the matching error string
1209
+ * in {@link selectRuntime}.
1210
+ *
1211
+ * Exported because Phase 5's runtime dispatcher route handler reuses
1212
+ * it to format the API error body and the dashboard reuses it to point
1213
+ * the user at the offending submission field.
1214
+ */
1215
+ export function collectNativeOnlyFeatures(req) {
1216
+ const features = [];
1217
+ for (const skill of req.submission.skills) {
1218
+ if (skill.kind === "provider") {
1219
+ const versionSuffix = skill.version ? `, "${skill.version}"` : "";
1220
+ features.push(`Skill.provider("${skill.vendor}", "${skill.skillId}"${versionSuffix})`);
1221
+ }
1222
+ }
1223
+ return features;
1224
+ }
1225
+ /**
1226
+ * The runtime dispatcher. Pure function with no I/O — call it after
1227
+ * `parseRunSubmissionRequest` to decide which Workflow class consumes
1228
+ * the run.
1229
+ *
1230
+ * 1. If the customer set `runtime` explicitly, validate that choice
1231
+ * against the submission's provider and feature set. Reject with
1232
+ * a typed error on mismatch — no silent re-routing.
1233
+ * 2. Otherwise auto-route: `provider: "anthropic"` → `"native"` (the
1234
+ * cheapest + fastest path for Anthropic runs); everything else →
1235
+ * `"managed"`.
1236
+ *
1237
+ * Returns the resolved {@link RuntimeKind}; the Workflow dispatcher
1238
+ * (Phase 5) maps that 1:1 to the matching `WorkflowEntrypoint` class.
1239
+ * See `references/platform-rebuild-2026.md` (Runtime dispatcher logic).
1240
+ */
1241
+ export function selectRuntime(req) {
1242
+ if (req.runtime === "native") {
1243
+ if (!isNativeRuntimeProvider(req.provider)) {
1244
+ throw new RuntimeValidationError("runtime_native_unsupported", `runtime: "native" is only supported for provider: ${formatProviderList(NATIVE_RUNTIME_PROVIDERS)} (got provider: "${req.provider}")`);
1245
+ }
1246
+ return "native";
1247
+ }
1248
+ if (req.runtime === "managed") {
1249
+ const features = collectNativeOnlyFeatures(req);
1250
+ if (features.length > 0) {
1251
+ throw new RuntimeValidationError("feature_runtime_mismatch", `runtime: "managed" rejects the following features: ${features.join(", ")}. ` +
1252
+ `Remove them or switch to runtime: "native".`);
1253
+ }
1254
+ return "managed";
1255
+ }
1256
+ // Auto-routing — no explicit `runtime` on the submission. Anthropic
1257
+ // gets the Native runtime by default (cheaper + faster for Anthropic
1258
+ // runs); everything else routes to the Goose Managed runtime.
1259
+ if (isNativeRuntimeProvider(req.provider)) {
1260
+ return "native";
1261
+ }
1262
+ return "managed";
1263
+ }
1034
1264
  //# sourceMappingURL=submission.js.map