antpath 0.6.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,11 +4,16 @@
4
4
  * Concepts (mirrored in references/architecture-decisions.md):
5
5
  *
6
6
  * - `SkillRef` is the wire-level reference to a skill — either an
7
- * `skl_*` id pointing at a workspace-uploaded bundle, or a
8
- * `{vendor, skillId, version}` reference to a provider built-in.
9
- * The two shapes are discriminated by `kind` so consumers branch
10
- * mechanically and providers can never accidentally be looked up
11
- * in `skill_bundles`.
7
+ * `skl_*` id pointing at a workspace-uploaded bundle, a
8
+ * `{vendor, skillId, version}` reference to a provider built-in, or
9
+ * a `{slot, name, contentHash}` reference to per-run bytes attached
10
+ * as a multipart part on the submitRun call (and torn down at run
11
+ * terminal). The three shapes are discriminated by `kind` so
12
+ * consumers branch mechanically and providers can never accidentally
13
+ * be looked up in `skill_bundles`. Transient refs do NOT round-trip
14
+ * through JSON (bytes can't be serialised back); `parseBlueprint`
15
+ * therefore rejects them while the BFF multipart submission parser
16
+ * accepts them.
12
17
  *
13
18
  * - `McpServerRef` is the non-secret part of an MCP server declaration:
14
19
  * `name` and `url`. Bearer / cookie / per-request headers travel in
@@ -73,7 +78,7 @@ export declare const SKILL_BUNDLE_LIMITS: {
73
78
  /** Stored directory mode. */
74
79
  readonly defaultDirMode: 493;
75
80
  };
76
- export type SkillRef = WorkspaceSkillRef | ProviderSkillRef;
81
+ export type SkillRef = WorkspaceSkillRef | ProviderSkillRef | TransientSkillRef;
77
82
  export interface WorkspaceSkillRef {
78
83
  readonly kind: "workspace";
79
84
  readonly id: string;
@@ -84,14 +89,68 @@ export interface ProviderSkillRef {
84
89
  readonly skillId: string;
85
90
  readonly version?: string;
86
91
  }
92
+ /**
93
+ * Per-run, non-persistent skill. The bytes ride alongside the JSON
94
+ * submission as a `name="skill:<slot>"` multipart body part and are
95
+ * uploaded by the BFF directly to the provider's per-run file storage.
96
+ * No `skill_bundles` row, no `run_skill_snapshots` entry, no platform
97
+ * storage stop — the BFF tears the file down at run terminal.
98
+ *
99
+ * `slot` is an SDK-assigned local id that pairs this ref with its
100
+ * multipart part within a single `submitRun` call. The BFF rejects any
101
+ * submission where the set of `kind:"transient"` refs doesn't have a
102
+ * strict 1:1 match against `name="skill:<slot>"` body parts.
103
+ *
104
+ * `contentHash` is a client-side advisory hash of the canonicalised zip
105
+ * (`sha256:<64 hex>`). The BFF recomputes server-side and rejects on
106
+ * mismatch; the value is also used by the janitor for orphan
107
+ * reconciliation. It MUST be deterministic across retries so the
108
+ * idempotency hash converges.
109
+ *
110
+ * Transient refs DO NOT round-trip safely through JSON — the bytes are
111
+ * not in the JSON and cannot be reconstructed. `parseBlueprint` rejects
112
+ * them (Blueprints are JSON-persistable and bytes are gone by the time
113
+ * `run.json` is loaded). `parseFlatSkills` accepts them (the BFF
114
+ * multipart parser is the only place that carries the matching bytes).
115
+ */
116
+ export interface TransientSkillRef {
117
+ readonly kind: "transient";
118
+ readonly slot: string;
119
+ readonly name: string;
120
+ readonly contentHash: string;
121
+ }
122
+ /**
123
+ * Slot id format: `^[a-z][a-z0-9_-]{0,63}$`. The SDK generates these
124
+ * positionally as `transient-0`, `transient-1`, … but any client may
125
+ * supply an explicit slot as long as it matches the pattern and is
126
+ * unique within the submission.
127
+ */
128
+ export declare const TRANSIENT_SLOT_PATTERN: RegExp;
129
+ /**
130
+ * Content-hash format: `^sha256:[0-9a-f]{64}$` (lowercase hex). The
131
+ * BFF accepts only this exact prefix; uppercase, base64, or any other
132
+ * digest is rejected so the wire form has one stable representation.
133
+ */
134
+ export declare const TRANSIENT_CONTENT_HASH_PATTERN: RegExp;
87
135
  export declare function isWorkspaceSkillRef(ref: SkillRef): ref is WorkspaceSkillRef;
88
136
  export declare function isProviderSkillRef(ref: SkillRef): ref is ProviderSkillRef;
137
+ export declare function isTransientSkillRef(ref: SkillRef): ref is TransientSkillRef;
138
+ /**
139
+ * Options accepted by `parseSkillRef`. The default (`allowTransient: true`)
140
+ * is the BFF submission parser path. The Blueprint parser passes
141
+ * `allowTransient: false` because Blueprints can be persisted to disk
142
+ * (`antpath run --config run.json`) and the bytes carrying the
143
+ * transient slot would already be gone.
144
+ */
145
+ export interface ParseSkillRefOptions {
146
+ readonly allowTransient?: boolean;
147
+ }
89
148
  /**
90
149
  * Parse a `SkillRef` from untrusted input. Used by the BFF run parser
91
150
  * and by the operations module when deserialising API responses. Throws
92
151
  * with a precise path so the caller can surface a usable error.
93
152
  */
94
- export declare function parseSkillRef(input: unknown, path: string): SkillRef;
153
+ export declare function parseSkillRef(input: unknown, path: string, options?: ParseSkillRefOptions): SkillRef;
95
154
  /**
96
155
  * Manifest entry persisted in `skill_bundles.manifest` and
97
156
  * `run_skill_snapshots.manifest`. `path` is forward-slash, relative,
@@ -4,11 +4,16 @@
4
4
  * Concepts (mirrored in references/architecture-decisions.md):
5
5
  *
6
6
  * - `SkillRef` is the wire-level reference to a skill — either an
7
- * `skl_*` id pointing at a workspace-uploaded bundle, or a
8
- * `{vendor, skillId, version}` reference to a provider built-in.
9
- * The two shapes are discriminated by `kind` so consumers branch
10
- * mechanically and providers can never accidentally be looked up
11
- * in `skill_bundles`.
7
+ * `skl_*` id pointing at a workspace-uploaded bundle, a
8
+ * `{vendor, skillId, version}` reference to a provider built-in, or
9
+ * a `{slot, name, contentHash}` reference to per-run bytes attached
10
+ * as a multipart part on the submitRun call (and torn down at run
11
+ * terminal). The three shapes are discriminated by `kind` so
12
+ * consumers branch mechanically and providers can never accidentally
13
+ * be looked up in `skill_bundles`. Transient refs do NOT round-trip
14
+ * through JSON (bytes can't be serialised back); `parseBlueprint`
15
+ * therefore rejects them while the BFF multipart submission parser
16
+ * accepts them.
12
17
  *
13
18
  * - `McpServerRef` is the non-secret part of an MCP server declaration:
14
19
  * `name` and `url`. Bearer / cookie / per-request headers travel in
@@ -78,18 +83,34 @@ export const SKILL_BUNDLE_LIMITS = {
78
83
  /** Stored directory mode. */
79
84
  defaultDirMode: 0o755
80
85
  };
86
+ /**
87
+ * Slot id format: `^[a-z][a-z0-9_-]{0,63}$`. The SDK generates these
88
+ * positionally as `transient-0`, `transient-1`, … but any client may
89
+ * supply an explicit slot as long as it matches the pattern and is
90
+ * unique within the submission.
91
+ */
92
+ export const TRANSIENT_SLOT_PATTERN = /^[a-z][a-z0-9_-]{0,63}$/;
93
+ /**
94
+ * Content-hash format: `^sha256:[0-9a-f]{64}$` (lowercase hex). The
95
+ * BFF accepts only this exact prefix; uppercase, base64, or any other
96
+ * digest is rejected so the wire form has one stable representation.
97
+ */
98
+ export const TRANSIENT_CONTENT_HASH_PATTERN = /^sha256:[0-9a-f]{64}$/;
81
99
  export function isWorkspaceSkillRef(ref) {
82
100
  return ref.kind === "workspace";
83
101
  }
84
102
  export function isProviderSkillRef(ref) {
85
103
  return ref.kind === "provider";
86
104
  }
105
+ export function isTransientSkillRef(ref) {
106
+ return ref.kind === "transient";
107
+ }
87
108
  /**
88
109
  * Parse a `SkillRef` from untrusted input. Used by the BFF run parser
89
110
  * and by the operations module when deserialising API responses. Throws
90
111
  * with a precise path so the caller can surface a usable error.
91
112
  */
92
- export function parseSkillRef(input, path) {
113
+ export function parseSkillRef(input, path, options = {}) {
93
114
  if (input === null || typeof input !== "object" || Array.isArray(input)) {
94
115
  throw new Error(`${path} must be a SkillRef object`);
95
116
  }
@@ -132,7 +153,31 @@ export function parseSkillRef(input, path) {
132
153
  ...(version !== undefined ? { version } : {})
133
154
  };
134
155
  }
135
- throw new Error(`${path}.kind must be 'workspace' or 'provider'`);
156
+ if (kind === "transient") {
157
+ if (options.allowTransient === false) {
158
+ throw new Error(`${path} carries a transient SkillRef, which cannot round-trip through JSON — ` +
159
+ `transient skills must be supplied at submitRun time with their bytes attached`);
160
+ }
161
+ for (const key of Object.keys(record)) {
162
+ if (key !== "kind" && key !== "slot" && key !== "name" && key !== "contentHash") {
163
+ throw new Error(`${path} contains unexpected field for transient SkillRef: ${key}`);
164
+ }
165
+ }
166
+ const slot = record.slot;
167
+ if (typeof slot !== "string" || !TRANSIENT_SLOT_PATTERN.test(slot)) {
168
+ throw new Error(`${path}.slot must match ${TRANSIENT_SLOT_PATTERN.source}`);
169
+ }
170
+ const name = record.name;
171
+ if (typeof name !== "string" || !SKILL_NAME_PATTERN.test(name)) {
172
+ throw new Error(`${path}.name must match ${SKILL_NAME_PATTERN.source}`);
173
+ }
174
+ const contentHash = record.contentHash;
175
+ if (typeof contentHash !== "string" || !TRANSIENT_CONTENT_HASH_PATTERN.test(contentHash)) {
176
+ throw new Error(`${path}.contentHash must match ${TRANSIENT_CONTENT_HASH_PATTERN.source}`);
177
+ }
178
+ return { kind: "transient", slot, name, contentHash };
179
+ }
180
+ throw new Error(`${path}.kind must be 'workspace', 'provider', or 'transient'`);
136
181
  }
137
182
  export class SkillBundleValidationError extends Error {
138
183
  constructor(message) {
@@ -466,7 +511,7 @@ function parseBlueprintSkills(value) {
466
511
  if (!Array.isArray(value)) {
467
512
  throw new Error("Blueprint.skills must be an array");
468
513
  }
469
- return value.map((item, index) => parseSkillRef(item, `Blueprint.skills[${index}]`));
514
+ return value.map((item, index) => parseSkillRef(item, `Blueprint.skills[${index}]`, { allowTransient: false }));
470
515
  }
471
516
  function parseBlueprintMcpServers(value) {
472
517
  if (value === undefined) {
@@ -22,7 +22,39 @@ export declare function createOutputLink(http: HttpClient, runId: string, output
22
22
  export declare function cancelRun(http: HttpClient, runId: string): Promise<void>;
23
23
  export declare function deleteRun(http: HttpClient, runId: string): Promise<void>;
24
24
  export declare function whoami(http: HttpClient): Promise<WhoAmI>;
25
+ /**
26
+ * Stream the per-run archive zip from the BFF. Returns the raw
27
+ * `Response` so callers can pipe the body to disk without buffering
28
+ * the whole archive in memory.
29
+ *
30
+ * The archive lifecycle contract lives in
31
+ * `apps/dashboard/src/server/run-archive.ts` and
32
+ * `packages/sdk/docs/outputs.md`. Pre-session runs reject with HTTP
33
+ * 409 `run_not_started`; mid-session and terminal both produce the
34
+ * same archive layout and differ only in `manifest.json`'s `source`
35
+ * + `partial` fields.
36
+ */
37
+ export declare function downloadRunArchive(http: HttpClient, runId: string): Promise<Response>;
25
38
  export declare function submitRunFlat(http: HttpClient, request: PlatformFlatRunSubmissionInput): Promise<Run>;
39
+ /**
40
+ * Multipart variant of `submitRunFlat` for runs that carry transient
41
+ * (per-run) skill bundles. The JSON submission travels as the
42
+ * `submission` part; each `TransientSkillRef.slot` in
43
+ * `request.submission.skills` MUST be mirrored by exactly one
44
+ * `skill:<slot>` part with the bundle bytes.
45
+ *
46
+ * The BFF re-canonicalises and re-hashes each bundle; a `contentHash`
47
+ * mismatch between the JSON ref and the recomputed hash of the bytes
48
+ * is rejected with a deterministic error.
49
+ *
50
+ * Bytes never persist on antpath storage; the dashboard BFF uploads
51
+ * them straight to Anthropic Files for the lifetime of the run.
52
+ */
53
+ export declare function submitRunFlatMultipart(http: HttpClient, request: PlatformFlatRunSubmissionInput, bundles: ReadonlyArray<{
54
+ readonly slot: string;
55
+ readonly bytes: Uint8Array;
56
+ readonly filename: string;
57
+ }>): Promise<Run>;
26
58
  /**
27
59
  * Upload a workspace skill bundle as a zip blob. The dashboard BFF runs
28
60
  * the two-phase flow internally (insert pending row, stream bytes into
@@ -40,3 +72,23 @@ export declare function createSkillBundle(http: HttpClient, args: {
40
72
  export declare function listSkills(http: HttpClient): Promise<readonly Skill[]>;
41
73
  export declare function getSkill(http: HttpClient, skillId: string): Promise<Skill>;
42
74
  export declare function deleteSkill(http: HttpClient, skillId: string): Promise<void>;
75
+ /**
76
+ * Lookup a live workspace skill by `(name, contentHash)`. Returns the
77
+ * matching `Skill` record or null when no live row carries that hash.
78
+ *
79
+ * `contentHash` is the wire format `sha256:<hex>` as returned by
80
+ * `hashSkillBundle`. This powers `Skill.uploadIfChanged` — the SDK
81
+ * computes the hash locally and calls this function to skip the upload
82
+ * when the bytes already exist.
83
+ */
84
+ export declare function findSkillByHash(http: HttpClient, args: {
85
+ readonly name: string;
86
+ readonly contentHash: string;
87
+ }): Promise<Skill | null>;
88
+ /**
89
+ * Lookup a live workspace skill by `name`. Returns the matching `Skill`
90
+ * record or null when no live row carries that name. Implemented as a
91
+ * list-and-filter on the existing `/api/skills` endpoint — the
92
+ * indexed by-hash route is reserved for `uploadIfChanged`.
93
+ */
94
+ export declare function findSkillByName(http: HttpClient, name: string): Promise<Skill | null>;
@@ -41,6 +41,22 @@ export async function deleteRun(http, runId) {
41
41
  export async function whoami(http) {
42
42
  return http.request("/api/whoami");
43
43
  }
44
+ /**
45
+ * Stream the per-run archive zip from the BFF. Returns the raw
46
+ * `Response` so callers can pipe the body to disk without buffering
47
+ * the whole archive in memory.
48
+ *
49
+ * The archive lifecycle contract lives in
50
+ * `apps/dashboard/src/server/run-archive.ts` and
51
+ * `packages/sdk/docs/outputs.md`. Pre-session runs reject with HTTP
52
+ * 409 `run_not_started`; mid-session and terminal both produce the
53
+ * same archive layout and differ only in `manifest.json`'s `source`
54
+ * + `partial` fields.
55
+ */
56
+ export async function downloadRunArchive(http, runId) {
57
+ const { response } = await http.download(`/api/runs/${encodeURIComponent(runId)}/download`);
58
+ return response;
59
+ }
44
60
  // ===========================================================================
45
61
  // Flat (Skill / McpServer / Blueprint) operations
46
62
  // ===========================================================================
@@ -50,6 +66,46 @@ export async function submitRunFlat(http, request) {
50
66
  body: JSON.stringify(request)
51
67
  });
52
68
  }
69
+ /**
70
+ * Multipart variant of `submitRunFlat` for runs that carry transient
71
+ * (per-run) skill bundles. The JSON submission travels as the
72
+ * `submission` part; each `TransientSkillRef.slot` in
73
+ * `request.submission.skills` MUST be mirrored by exactly one
74
+ * `skill:<slot>` part with the bundle bytes.
75
+ *
76
+ * The BFF re-canonicalises and re-hashes each bundle; a `contentHash`
77
+ * mismatch between the JSON ref and the recomputed hash of the bytes
78
+ * is rejected with a deterministic error.
79
+ *
80
+ * Bytes never persist on antpath storage; the dashboard BFF uploads
81
+ * them straight to Anthropic Files for the lifetime of the run.
82
+ */
83
+ export async function submitRunFlatMultipart(http, request, bundles) {
84
+ if (!Array.isArray(bundles) || bundles.length === 0) {
85
+ throw new Error("submitRunFlatMultipart: bundles must be a non-empty array");
86
+ }
87
+ const form = new FormData();
88
+ // Submission rides as a typed JSON Blob so the BFF reads
89
+ // `multipart["submission"]` with the right content-type and never
90
+ // has to re-detect the body shape.
91
+ form.append("submission", new Blob([JSON.stringify(request)], { type: "application/json" }), "submission.json");
92
+ const seen = new Set();
93
+ for (const bundle of bundles) {
94
+ if (typeof bundle.slot !== "string" || !bundle.slot) {
95
+ throw new Error("submitRunFlatMultipart: each bundle must have a non-empty slot id");
96
+ }
97
+ if (seen.has(bundle.slot)) {
98
+ throw new Error(`submitRunFlatMultipart: duplicate transient skill slot "${bundle.slot}"`);
99
+ }
100
+ seen.add(bundle.slot);
101
+ const blob = toBlob(bundle.bytes, "application/zip");
102
+ form.append(`skill:${bundle.slot}`, blob, bundle.filename);
103
+ }
104
+ return http.request("/api/runs", {
105
+ method: "POST",
106
+ body: form
107
+ });
108
+ }
53
109
  /**
54
110
  * Upload a workspace skill bundle as a zip blob. The dashboard BFF runs
55
111
  * the two-phase flow internally (insert pending row, stream bytes into
@@ -85,6 +141,33 @@ export async function deleteSkill(http, skillId) {
85
141
  method: "DELETE"
86
142
  });
87
143
  }
144
+ /**
145
+ * Lookup a live workspace skill by `(name, contentHash)`. Returns the
146
+ * matching `Skill` record or null when no live row carries that hash.
147
+ *
148
+ * `contentHash` is the wire format `sha256:<hex>` as returned by
149
+ * `hashSkillBundle`. This powers `Skill.uploadIfChanged` — the SDK
150
+ * computes the hash locally and calls this function to skip the upload
151
+ * when the bytes already exist.
152
+ */
153
+ export async function findSkillByHash(http, args) {
154
+ const params = new URLSearchParams({
155
+ name: args.name,
156
+ content_hash: args.contentHash
157
+ });
158
+ const result = await http.request(`/api/skills/by-hash?${params.toString()}`);
159
+ return result.skill ?? null;
160
+ }
161
+ /**
162
+ * Lookup a live workspace skill by `name`. Returns the matching `Skill`
163
+ * record or null when no live row carries that name. Implemented as a
164
+ * list-and-filter on the existing `/api/skills` endpoint — the
165
+ * indexed by-hash route is reserved for `uploadIfChanged`.
166
+ */
167
+ export async function findSkillByName(http, name) {
168
+ const skills = await listSkills(http);
169
+ return skills.find((skill) => skill.name === name) ?? null;
170
+ }
88
171
  function unwrapSkill(result) {
89
172
  if (result && typeof result === "object" && "skill" in result) {
90
173
  return result.skill;
@@ -67,8 +67,16 @@ export interface ProxyIndexEntry {
67
67
  * The actual auth value lives in the run's Vault bundle under
68
68
  * `secrets.proxyEndpointAuth[i].value` and is never reflected back
69
69
  * into the container or index file.
70
+ *
71
+ * The `none` variant declares an upstream that takes no auth (public
72
+ * APIs like Wikimedia Commons or NASA Images). It still routes through
73
+ * the proxy for unified egress, audit, and budget enforcement, but
74
+ * carries no `proxyEndpointAuth[]` entry and the BFF injects no
75
+ * header or query value.
70
76
  */
71
77
  export type ProxyAuthShape = {
78
+ readonly type: "none";
79
+ } | {
72
80
  readonly type: "bearer";
73
81
  } | {
74
82
  readonly type: "basic";
@@ -82,7 +90,7 @@ export type ProxyAuthShape = {
82
90
  export type ProxyAuthType = ProxyAuthShape["type"];
83
91
  /**
84
92
  * Header name (lowercase) that an upstream auth shape uses as its
85
- * carrier. Returns `undefined` for query-based auth.
93
+ * carrier. Returns `undefined` for query-based and keyless auth.
86
94
  *
87
95
  * Used by the submission parser to forbid `allowHeaders` from listing
88
96
  * the auth header (avoids leaks via caller-supplied headers), and by
@@ -92,7 +100,7 @@ export type ProxyAuthType = ProxyAuthShape["type"];
92
100
  export declare function authShapeHeaderName(shape: ProxyAuthShape): string | undefined;
93
101
  /**
94
102
  * Query-string key that an upstream query-based auth shape uses as its
95
- * carrier. Returns `undefined` for non-query shapes.
103
+ * carrier. Returns `undefined` for non-query shapes (including "none").
96
104
  */
97
105
  export declare function authShapeQueryName(shape: ProxyAuthShape): string | undefined;
98
106
  /**
@@ -66,7 +66,7 @@ export const PROXY_ERROR_CODES = [
66
66
  ];
67
67
  /**
68
68
  * Header name (lowercase) that an upstream auth shape uses as its
69
- * carrier. Returns `undefined` for query-based auth.
69
+ * carrier. Returns `undefined` for query-based and keyless auth.
70
70
  *
71
71
  * Used by the submission parser to forbid `allowHeaders` from listing
72
72
  * the auth header (avoids leaks via caller-supplied headers), and by
@@ -81,12 +81,13 @@ export function authShapeHeaderName(shape) {
81
81
  case "header":
82
82
  return shape.name.toLowerCase();
83
83
  case "query":
84
+ case "none":
84
85
  return undefined;
85
86
  }
86
87
  }
87
88
  /**
88
89
  * Query-string key that an upstream query-based auth shape uses as its
89
- * carrier. Returns `undefined` for non-query shapes.
90
+ * carrier. Returns `undefined` for non-query shapes (including "none").
90
91
  */
91
92
  export function authShapeQueryName(shape) {
92
93
  return shape.type === "query" ? shape.name : undefined;
@@ -71,6 +71,25 @@ export interface WhoAmI {
71
71
  readonly tokenId?: string;
72
72
  readonly tokenName?: string | null;
73
73
  readonly scopes?: readonly string[];
74
+ /**
75
+ * Workspace-level caps the BFF will enforce on subsequent calls.
76
+ * Surfaced so consumers (e.g. broll's app-side admission gate) can
77
+ * decide whether to keep their own gate or rely on platform headers.
78
+ * All fields optional — older BFFs may omit. Numbers are concrete
79
+ * snapshots at the time of the `whoami` call.
80
+ */
81
+ readonly caps?: {
82
+ /** Token-bucket cap on POST /api/runs per minute, per workspace. */
83
+ readonly runSubmitPerMinute?: number;
84
+ /** Hard cap on concurrent non-terminal runs the workspace may hold. */
85
+ readonly maxConcurrentRuns?: number;
86
+ /** Storage cap (bytes) on captured output objects, workspace-wide. */
87
+ readonly storageCapBytes?: number;
88
+ /** Current captured-output usage in bytes. */
89
+ readonly storageUsedBytes?: number;
90
+ /** Wall-clock ceiling on a single run before forced termination. */
91
+ readonly maxRunDurationMs?: number;
92
+ };
74
93
  readonly [key: string]: unknown;
75
94
  }
76
95
  /**
@@ -175,6 +175,27 @@ export interface PlatformFlatSubmission {
175
175
  readonly mcpServers: readonly McpServerRef[];
176
176
  readonly environment?: PlatformTemplateEnvironment;
177
177
  readonly metadata?: Record<string, JsonValue>;
178
+ /**
179
+ * Opt-in container paths to capture as `output_objects` at session
180
+ * terminal. When omitted, the worker still persists run metadata
181
+ * (status, events, snapshots, cleanup state) but does not capture
182
+ * any container file bytes. When present, the worker drives a
183
+ * synthetic agent turn at session terminal that instructs the agent
184
+ * to register every file under these paths via the Anthropic Files
185
+ * API, then walks the resulting list and copies bytes into private
186
+ * Supabase Storage.
187
+ *
188
+ * Validation:
189
+ * - Absolute UNIX paths only (starts with `/`).
190
+ * - No `..` segments, no NUL bytes, no embedded newlines.
191
+ * - Max 32 entries.
192
+ * - Max 512 bytes per entry.
193
+ *
194
+ * Entries are normalised (collapse `/+`, drop trailing `/` except
195
+ * for `/`) and deduplicated. The normalised list is what travels in
196
+ * the idempotency hash and the run snapshot.
197
+ */
198
+ readonly outputDirs?: readonly string[];
178
199
  }
179
200
  export interface PlatformFlatRunSubmissionRequest {
180
201
  readonly workspaceId: string;
@@ -272,6 +272,9 @@ function parseProxyAuthShape(input, field) {
272
272
  const value = requireRecord(input, field);
273
273
  const type = requireString(value.type, `${field}.type`);
274
274
  switch (type) {
275
+ case "none":
276
+ assertOnlyKeys(value, field, ["type"]);
277
+ return { type: "none" };
275
278
  case "bearer":
276
279
  assertOnlyKeys(value, field, ["type"]);
277
280
  return { type: "bearer" };
@@ -293,7 +296,7 @@ function parseProxyAuthShape(input, field) {
293
296
  return { type: "query", name };
294
297
  }
295
298
  default:
296
- throw new Error(`${field}.type must be one of: bearer, basic, header, query`);
299
+ throw new Error(`${field}.type must be one of: none, bearer, basic, header, query`);
297
300
  }
298
301
  }
299
302
  function parseProxyMethods(input, field) {
@@ -382,6 +385,16 @@ function crossValidateProxyEndpointsAndAuth(endpoints, auth) {
382
385
  const authByName = new Map(authList.map((a) => [a.name, a]));
383
386
  for (const endpoint of endpointsList) {
384
387
  const authEntry = authByName.get(endpoint.name);
388
+ if (endpoint.authShape.type === "none") {
389
+ // Keyless endpoints carry no auth value. Reject any matching
390
+ // auth entry so callers don't accidentally ship a secret bound
391
+ // to a "none" endpoint (which would be silently ignored at
392
+ // request time — confusing and a leak risk).
393
+ if (authEntry) {
394
+ throw new Error(`proxyEndpoints[${endpoint.name}] has authShape "none" but a matching secrets.proxyEndpointAuth entry was supplied; remove the auth entry`);
395
+ }
396
+ continue;
397
+ }
385
398
  if (!authEntry) {
386
399
  throw new Error(`proxyEndpoints[${endpoint.name}] has no matching secrets.proxyEndpointAuth entry`);
387
400
  }
@@ -742,7 +755,8 @@ function parseFlatSubmission(input) {
742
755
  "skills",
743
756
  "mcpServers",
744
757
  "environment",
745
- "metadata"
758
+ "metadata",
759
+ "outputDirs"
746
760
  ]);
747
761
  for (const key of Object.keys(value)) {
748
762
  if (!allowed.has(key)) {
@@ -756,6 +770,7 @@ function parseFlatSubmission(input) {
756
770
  const mcpServers = parseFlatMcpServers(value.mcpServers);
757
771
  const environment = parseTemplateEnvironment(value.environment);
758
772
  const metadata = optionalJsonRecord(value.metadata, "submission.metadata");
773
+ const outputDirs = parseOutputDirs(value.outputDirs);
759
774
  return {
760
775
  model,
761
776
  ...(system ? { system } : {}),
@@ -763,9 +778,84 @@ function parseFlatSubmission(input) {
763
778
  skills,
764
779
  mcpServers,
765
780
  ...(environment ? { environment } : {}),
766
- ...(metadata ? { metadata } : {})
781
+ ...(metadata ? { metadata } : {}),
782
+ ...(outputDirs ? { outputDirs } : {})
767
783
  };
768
784
  }
785
+ /**
786
+ * Maximum number of `outputDirs` entries accepted per submission.
787
+ *
788
+ * 32 is enough room for the typical "one or two capture roots" pattern
789
+ * plus a generous margin for legitimate multi-root use cases (per-tool
790
+ * output directory + scratch state + logs, repeated across a few
791
+ * subdirectories), without inviting abuse of the synthetic-turn path
792
+ * the worker drives at session terminal.
793
+ */
794
+ const MAX_OUTPUT_DIRS = 32;
795
+ /**
796
+ * Maximum byte length of a single `outputDirs` entry (after UTF-8
797
+ * encoding). 512 bytes comfortably covers `/very/long/nested/path`
798
+ * style entries without letting a misuse smuggle large blobs through
799
+ * the field.
800
+ */
801
+ const MAX_OUTPUT_DIR_BYTES = 512;
802
+ function parseOutputDirs(input) {
803
+ if (input === undefined) {
804
+ return undefined;
805
+ }
806
+ if (!Array.isArray(input)) {
807
+ throw new Error("submission.outputDirs must be an array of absolute UNIX paths");
808
+ }
809
+ if (input.length === 0) {
810
+ // Treat an empty array as omission so the idempotency hash matches
811
+ // the "no outputDirs" case.
812
+ return undefined;
813
+ }
814
+ if (input.length > MAX_OUTPUT_DIRS) {
815
+ throw new Error(`submission.outputDirs has ${input.length} entries; max is ${MAX_OUTPUT_DIRS}`);
816
+ }
817
+ const seen = new Set();
818
+ const normalised = [];
819
+ for (let i = 0; i < input.length; i++) {
820
+ const item = input[i];
821
+ if (typeof item !== "string") {
822
+ throw new Error(`submission.outputDirs[${i}] must be a string`);
823
+ }
824
+ if (item.length === 0) {
825
+ throw new Error(`submission.outputDirs[${i}] must be a non-empty absolute UNIX path`);
826
+ }
827
+ const bytes = new TextEncoder().encode(item).length;
828
+ if (bytes > MAX_OUTPUT_DIR_BYTES) {
829
+ throw new Error(`submission.outputDirs[${i}] exceeds ${MAX_OUTPUT_DIR_BYTES} bytes (got ${bytes})`);
830
+ }
831
+ if (!item.startsWith("/")) {
832
+ throw new Error(`submission.outputDirs[${i}] must be an absolute UNIX path (start with '/')`);
833
+ }
834
+ if (item.includes("\0")) {
835
+ throw new Error(`submission.outputDirs[${i}] must not contain NUL bytes`);
836
+ }
837
+ if (item.includes("\n") || item.includes("\r")) {
838
+ throw new Error(`submission.outputDirs[${i}] must not contain newline characters`);
839
+ }
840
+ const segments = item.split("/");
841
+ if (segments.includes("..")) {
842
+ throw new Error(`submission.outputDirs[${i}] must not contain '..' segments`);
843
+ }
844
+ const collapsed = segments
845
+ .filter((seg, idx) => seg.length > 0 || idx === 0)
846
+ .join("/");
847
+ const stripped = collapsed.length > 1 && collapsed.endsWith("/")
848
+ ? collapsed.slice(0, -1)
849
+ : collapsed;
850
+ const canonical = stripped.length === 0 ? "/" : stripped;
851
+ if (seen.has(canonical)) {
852
+ continue;
853
+ }
854
+ seen.add(canonical);
855
+ normalised.push(canonical);
856
+ }
857
+ return normalised;
858
+ }
769
859
  function parseFlatPrompt(input) {
770
860
  if (typeof input === "string") {
771
861
  if (input.length === 0) {
@@ -795,6 +885,7 @@ function parseFlatSkills(input) {
795
885
  }
796
886
  const seenWorkspace = new Set();
797
887
  const seenProvider = new Set();
888
+ const seenTransientSlot = new Set();
798
889
  return input.map((item, index) => {
799
890
  const ref = parseSkillRef(item, `submission.skills[${index}]`);
800
891
  if (ref.kind === "workspace") {
@@ -803,13 +894,20 @@ function parseFlatSkills(input) {
803
894
  }
804
895
  seenWorkspace.add(ref.id);
805
896
  }
806
- else {
897
+ else if (ref.kind === "provider") {
807
898
  const key = `${ref.vendor}:${ref.skillId}:${ref.version ?? ""}`;
808
899
  if (seenProvider.has(key)) {
809
900
  throw new Error(`submission.skills duplicate provider skill: ${ref.vendor}:${ref.skillId}${ref.version ? `:${ref.version}` : ""}`);
810
901
  }
811
902
  seenProvider.add(key);
812
903
  }
904
+ else {
905
+ // transient
906
+ if (seenTransientSlot.has(ref.slot)) {
907
+ throw new Error(`submission.skills duplicate transient slot: ${ref.slot}`);
908
+ }
909
+ seenTransientSlot.add(ref.slot);
910
+ }
813
911
  return ref;
814
912
  });
815
913
  }