@frontera-sdk/functions 1.50.84 → 1.51.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frontera-sdk/functions",
3
- "version": "1.50.84",
3
+ "version": "1.51.1",
4
4
  "description": "Author Frontera functions: manifest, triggers and the typed handler contract.",
5
5
  "keywords": [
6
6
  "frontera",
@@ -42,7 +42,7 @@
42
42
  "typescript": "^5.9.3"
43
43
  },
44
44
  "dependencies": {
45
- "@frontera-sdk/blueprint": "1.50.84",
45
+ "@frontera-sdk/blueprint": "1.51.1",
46
46
  "cron-parser": "^5.0.6"
47
47
  }
48
48
  }
package/src/index.ts CHANGED
@@ -12,8 +12,12 @@ export {
12
12
  export { createTestContext } from './testing'
13
13
  export type { TestCall, TestContext, TestContextOptions } from './testing'
14
14
  export {
15
+ DEFAULT_MAX_FILES,
16
+ MAX_FILES_CAP,
15
17
  MAX_INPUT_BYTES,
16
18
  checkInputFieldSpec,
19
+ isFileInputType,
20
+ maxFilesOf,
17
21
  redactedInputKeys,
18
22
  sanitizeInputsSchema,
19
23
  validateInputValue,
package/src/inputs.ts CHANGED
@@ -27,20 +27,39 @@ export type InputValidation =
27
27
  * naming a non-empty string `fileId`). It deliberately does NOT authorize the id
28
28
  * or check mime/size — those need the DB and the caller's workspace scope, so the
29
29
  * SERVER (`run-service` via `resolveFile`) authorizes and resolves the reference
30
- * after this passes.
30
+ * after this passes. `files` is a list of the same reference; its count is
31
+ * checked in `validateInputValue`, which knows the field's `maxFiles`.
31
32
  */
33
+ const isFileRef = (v: unknown): boolean =>
34
+ typeof v === 'object' &&
35
+ v !== null &&
36
+ !Array.isArray(v) &&
37
+ typeof (v as { fileId?: unknown }).fileId === 'string' &&
38
+ (v as { fileId: string }).fileId.length > 0
39
+
32
40
  export const TYPE_CHECK: Record<InputFieldSpec['type'], (v: unknown) => boolean> = {
33
41
  string: (v) => typeof v === 'string',
34
42
  number: (v) => typeof v === 'number' && Number.isFinite(v),
35
43
  boolean: (v) => typeof v === 'boolean',
36
44
  object: (v) => typeof v === 'object' && v !== null && !Array.isArray(v),
37
45
  array: Array.isArray,
38
- file: (v) =>
39
- typeof v === 'object' &&
40
- v !== null &&
41
- !Array.isArray(v) &&
42
- typeof (v as { fileId?: unknown }).fileId === 'string' &&
43
- (v as { fileId: string }).fileId.length > 0,
46
+ file: isFileRef,
47
+ files: (v) => Array.isArray(v) && v.every(isFileRef),
48
+ }
49
+
50
+ /** The most files a `files` input holds when its spec names no `maxFiles`. */
51
+ export const DEFAULT_MAX_FILES = 20
52
+ /** The highest `maxFiles` a `files` input may declare. */
53
+ export const MAX_FILES_CAP = 50
54
+
55
+ /** Whether an input of this type holds file references: one, or a list. */
56
+ export function isFileInputType(type: unknown): type is 'file' | 'files' {
57
+ return type === 'file' || type === 'files'
58
+ }
59
+
60
+ /** The most files a `files` input holds. */
61
+ export function maxFilesOf(spec: Pick<InputFieldSpec, 'maxFiles'>): number {
62
+ return spec.maxFiles ?? DEFAULT_MAX_FILES
44
63
  }
45
64
 
46
65
  /** Lowercase kebab, matching `validateManifest`'s automation-`name` grammar. */
@@ -48,7 +67,7 @@ const INPUT_NAME_KEBAB_RE = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/
48
67
  /** Lowercase snake — the one allowance kebab doesn't cover. */
49
68
  const INPUT_NAME_SNAKE_RE = /^[a-z][a-z0-9_]*$/
50
69
 
51
- const INPUT_TYPES = new Set<InputFieldSpec['type']>(['string', 'number', 'boolean', 'object', 'array', 'file'])
70
+ const INPUT_TYPES = new Set<InputFieldSpec['type']>(['string', 'number', 'boolean', 'object', 'array', 'file', 'files'])
52
71
 
53
72
  /** Keys `checkInputFieldSpec` understands on ONE input spec (`inputs.<name>`).
54
73
  * Anything else there is a warning, same philosophy as `manifest.ts`'s
@@ -63,6 +82,7 @@ const INPUT_SPEC_KEYS = new Set([
63
82
  'redact',
64
83
  'accept',
65
84
  'maxBytes',
85
+ 'maxFiles',
66
86
  ])
67
87
 
68
88
  /**
@@ -129,6 +149,7 @@ export function checkInputFieldSpec(key: string, raw: unknown): InputFieldCheck
129
149
  redact?: unknown
130
150
  accept?: unknown
131
151
  maxBytes?: unknown
152
+ maxFiles?: unknown
132
153
  }
133
154
 
134
155
  if (spec.description !== undefined && typeof spec.description !== 'string') {
@@ -136,7 +157,7 @@ export function checkInputFieldSpec(key: string, raw: unknown): InputFieldCheck
136
157
  }
137
158
 
138
159
  if (typeof spec.type !== 'string' || !INPUT_TYPES.has(spec.type as InputFieldSpec['type'])) {
139
- errors.push(`input "${key}": type must be one of string, number, boolean, object, array, file`)
160
+ errors.push(`input "${key}": type must be one of string, number, boolean, object, array, file, files`)
140
161
  return { errors, warnings }
141
162
  }
142
163
  const t = spec.type as InputFieldSpec['type']
@@ -196,12 +217,12 @@ export function checkInputFieldSpec(key: string, raw: unknown): InputFieldCheck
196
217
  }
197
218
  }
198
219
 
199
- // `accept`/`maxBytes` are the file-only constraints — refused on any other
200
- // type so a typo like `accept` on a string field is loud, not silently
201
- // ignored. They constrain the UPLOAD, not the value on the run row (which is
220
+ // `accept`/`maxBytes` are the file-only constraints, for one file or a list
221
+ // of them — refused on any other type so a typo like `accept` on a string
222
+ // field is loud, not silently ignored. They constrain the UPLOAD, not the value on the run row (which is
202
223
  // only a reference), so the server enforces them; here we check well-formedness.
203
224
  if (spec.accept !== undefined) {
204
- if (t !== 'file') {
225
+ if (!isFileInputType(t)) {
205
226
  errors.push(`input "${key}": accept is only valid for file inputs`)
206
227
  } else if (
207
228
  !Array.isArray(spec.accept)
@@ -212,18 +233,30 @@ export function checkInputFieldSpec(key: string, raw: unknown): InputFieldCheck
212
233
  }
213
234
  }
214
235
  if (spec.maxBytes !== undefined) {
215
- if (t !== 'file') {
236
+ if (!isFileInputType(t)) {
216
237
  errors.push(`input "${key}": maxBytes is only valid for file inputs`)
217
238
  } else if (typeof spec.maxBytes !== 'number' || !Number.isFinite(spec.maxBytes) || spec.maxBytes <= 0) {
218
239
  errors.push(`input "${key}": maxBytes must be a positive number`)
219
240
  }
220
241
  }
242
+ if (spec.maxFiles !== undefined) {
243
+ if (t !== 'files') {
244
+ errors.push(`input "${key}": maxFiles is only valid for files inputs`)
245
+ } else if (
246
+ typeof spec.maxFiles !== 'number'
247
+ || !Number.isInteger(spec.maxFiles)
248
+ || spec.maxFiles < 1
249
+ || spec.maxFiles > MAX_FILES_CAP
250
+ ) {
251
+ errors.push(`input "${key}": maxFiles must be a whole number from 1 to ${MAX_FILES_CAP}`)
252
+ }
253
+ }
221
254
 
222
255
  let defaultOk = true
223
256
  if (spec.default !== undefined) {
224
- // A `file` carries a reference to an uploaded item, so a fixed literal
225
- // default is meaningless — refused rather than type-checked.
226
- if (t === 'file') {
257
+ // A `file` or `files` input carries references to uploaded items, so a
258
+ // fixed literal default is meaningless — refused rather than type-checked.
259
+ if (isFileInputType(t)) {
227
260
  errors.push(`input "${key}": a file input cannot have a default`)
228
261
  defaultOk = false
229
262
  } else if (!TYPE_CHECK[t](spec.default)) {
@@ -290,9 +323,23 @@ export function validateInputValue(
290
323
  continue
291
324
  }
292
325
  if (!check(v)) {
293
- errors.push(`"${key}" must be of type ${spec.type}`)
326
+ errors.push(
327
+ spec.type === 'files' ? `"${key}" must be a list of files` : `"${key}" must be of type ${spec.type}`,
328
+ )
294
329
  continue
295
330
  }
331
+ if (spec.type === 'files') {
332
+ const count = (v as unknown[]).length
333
+ // An empty list says "no files", which a required input does not accept.
334
+ if (count === 0 && spec.required === true) {
335
+ errors.push(`"${key}" is required — add at least one file`)
336
+ continue
337
+ }
338
+ if (count > maxFilesOf(spec)) {
339
+ errors.push(`"${key}" holds ${count} files — the most it takes is ${maxFilesOf(spec)}`)
340
+ continue
341
+ }
342
+ }
296
343
  if (spec.enum && !spec.enum.includes(v as string | number)) {
297
344
  errors.push(`"${key}" must be one of ${spec.enum.join(', ')}`)
298
345
  continue
package/src/manifest.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { CronExpressionParser } from 'cron-parser'
2
- import { MAX_INPUT_BYTES, checkInputFieldSpec } from './inputs'
2
+ import { MAX_INPUT_BYTES, checkInputFieldSpec, isFileInputType } from './inputs'
3
3
 
4
4
  const SEGMENT = '[a-z][a-z0-9]*(?:-[a-z0-9]+)*'
5
5
  const NAME_RE = new RegExp(`^${SEGMENT}$`)
@@ -246,10 +246,11 @@ export function validateManifest(input: unknown): ValidationResult {
246
246
  for (const [key, raw] of Object.entries(inputs as Record<string, unknown>)) {
247
247
  const spec = raw as { type?: unknown; required?: unknown; default?: unknown }
248
248
  if (spec?.required === true && spec.default === undefined) {
249
- // A `file` input can never carry a default (refused above), so the
250
- // "add a default" remedy would send the author straight into the
251
- // next validation error — name the two remedies that actually work.
252
- const remedy = spec.type === 'file'
249
+ // A `file` or `files` input can never carry a default (refused
250
+ // above), so the "add a default" remedy would send the author
251
+ // straight into the next validation error — name the two remedies
252
+ // that actually work.
253
+ const remedy = isFileInputType(spec.type)
253
254
  ? 'A file input cannot have a default — make the input optional or the trigger manual.'
254
255
  : 'Add a default or make the trigger manual.'
255
256
  errors.push(
package/src/messages.ts CHANGED
@@ -143,3 +143,47 @@ export function agentReplayMismatchMessage(agentSlug: string, originalSlug: stri
143
143
  'they are recorded once and replayed.'
144
144
  )
145
145
  }
146
+
147
+ /**
148
+ * A step that returned a `ctx.file` handle.
149
+ *
150
+ * A step's result is kept as JSON and replayed, and JSON cannot hold the
151
+ * handle's readers, so the run would fail one execution later with "text is
152
+ * not a function", far from the step that caused it. Both fixes are named,
153
+ * because which one fits depends on whether the bytes are needed later.
154
+ */
155
+ export function fileHandleInStepMessage(stepName: string): string {
156
+ return (
157
+ `Step "${stepName}" returned a ctx.file handle. A step's result is saved as JSON, which keeps the ` +
158
+ "file's details but drops text(), bytes() and stream(). Read the file inside the step and return " +
159
+ 'what you need from it, or call ctx.file outside the step.'
160
+ )
161
+ }
162
+
163
+ /**
164
+ * Code reading `ctx.file(...).signedUrl`, which a handle no longer has.
165
+ *
166
+ * A bundle deployed before the change still reads it. Without this it gets
167
+ * `undefined`, and fails later in `fetch(undefined)` with nothing pointing
168
+ * back here; the readers are named so the fix is plain.
169
+ */
170
+ export function signedUrlRemovedMessage(): string {
171
+ return (
172
+ 'ctx.file() no longer returns signedUrl: Function code cannot open network connections of its own. ' +
173
+ 'Read the file with the handle instead: await file.text(), await file.bytes() or await file.stream().'
174
+ )
175
+ }
176
+
177
+ /**
178
+ * A `createTestContext` file stub written for the handle's old shape.
179
+ *
180
+ * An existing test passing `{ signedUrl, sizeBytes }` has no `content`, and
181
+ * would otherwise fail inside `ctx.file` as a bare TypeError on `undefined`.
182
+ */
183
+ export function oldFileStubMessage(fileId: string): string {
184
+ return (
185
+ `The file stub for "${fileId}" has no content. ctx.file now returns the file's bytes rather than a signedUrl, ` +
186
+ `so stub it with its content: files: { '${fileId}': { content: '…', mimeType: '…', name: null } }. ` +
187
+ 'sizeBytes is computed from the content.'
188
+ )
189
+ }
@@ -27,6 +27,8 @@ import {
27
27
  lostStepRowMessage as lostStepRowMessageText,
28
28
  duplicateSubmissionMessage as duplicateSubmissionMessageText,
29
29
  emptySubmissionKeyMessage as emptySubmissionKeyMessageText,
30
+ fileHandleInStepMessage,
31
+ signedUrlRemovedMessage,
30
32
  } from './messages'
31
33
  import type {
32
34
  ActionSubmission,
@@ -187,8 +189,9 @@ function brandTranscript(transcript: ConversationTranscript | null): Conversatio
187
189
  return transcript
188
190
  }
189
191
 
190
- const TRANSCRIPT_SEARCH_DEPTH = 4
191
- const TRANSCRIPT_SEARCH_NODES = 200
192
+ /** How far a step result is searched for something ctx handed out: a transcript, or a file handle. */
193
+ const RESULT_SEARCH_DEPTH = 4
194
+ const RESULT_SEARCH_NODES = 200
192
195
 
193
196
  /**
194
197
  * How many handed-out turns a step result carries, or null when it carries
@@ -223,7 +226,7 @@ function transcriptTurnsIn(
223
226
  // Any object is searched, not only plain ones: a class instance serializes
224
227
  // its own fields, and a turn could be one of them.
225
228
  for (const child of objectChildren(value)) {
226
- if (depth >= TRANSCRIPT_SEARCH_DEPTH || queue.length >= TRANSCRIPT_SEARCH_NODES) {
229
+ if (depth >= RESULT_SEARCH_DEPTH || queue.length >= RESULT_SEARCH_NODES) {
227
230
  // Something here is left unsearched. The nodes already queued are
228
231
  // still visited, so a transcript among them is still counted.
229
232
  complete = false
@@ -236,6 +239,54 @@ function transcriptTurnsIn(
236
239
  return complete ? { kind: 'clean' } : { kind: 'not-inspected' }
237
240
  }
238
241
 
242
+ /**
243
+ * Every handle `ctx.file` returned. Branded, like a transcript, so the check
244
+ * below is exact: the author's own `{ fileId, bytes }` is not a handle.
245
+ */
246
+ const fileHandles = new WeakSet<object>()
247
+
248
+ /**
249
+ * Finish a handle `ctx.file` returns: brand it, and make the `signedUrl` it no
250
+ * longer has say where it went. Exported for the test context, which builds
251
+ * its own handles and must behave the same.
252
+ *
253
+ * The getter is non-enumerable, so a step result, a trace summary or
254
+ * `JSON.stringify` never reads it; only code that asks for `signedUrl` does.
255
+ */
256
+ export function asFileHandle<T extends object>(handle: T): T {
257
+ fileHandles.add(handle)
258
+ Object.defineProperty(handle, 'signedUrl', {
259
+ enumerable: false,
260
+ get: () => {
261
+ throw new Error(signedUrlRemovedMessage())
262
+ },
263
+ })
264
+ return handle
265
+ }
266
+
267
+ /**
268
+ * Whether a step result carries a `ctx.file` handle, within the same bounds as
269
+ * `transcriptTurnsIn`.
270
+ *
271
+ * A step's result is kept as JSON and replayed, and JSON cannot hold a
272
+ * function: the handle comes back with its details and without `text()`,
273
+ * `bytes()` or `stream()`, so the run fails one execution later with "text is
274
+ * not a function". Caught here, it fails at the step that caused it.
275
+ */
276
+ export function carriesFileHandle(out: unknown): boolean {
277
+ if (typeof out !== 'object' || out === null) return false
278
+ const queue: Array<{ value: object; depth: number }> = [{ value: out, depth: 0 }]
279
+ for (let next = 0; next < queue.length; next++) {
280
+ const { value, depth } = queue[next]!
281
+ if (fileHandles.has(value)) return true
282
+ for (const child of objectChildren(value)) {
283
+ if (depth >= RESULT_SEARCH_DEPTH || queue.length >= RESULT_SEARCH_NODES) break
284
+ queue.push({ value: child, depth: depth + 1 })
285
+ }
286
+ }
287
+ return false
288
+ }
289
+
239
290
  /** The object-valued children of an array or object, one at a time. */
240
291
  function* objectChildren(value: object): Generator<object> {
241
292
  if (Array.isArray(value)) {
@@ -349,6 +400,32 @@ const AGENT_TURN_FINISHED_EVENT = 'agent/turn.finished'
349
400
  */
350
401
  const KEEPALIVE_HEADER = 'x-ctx-keepalive'
351
402
 
403
+ /**
404
+ * Every header this module sends to the service on a Function's own authority.
405
+ *
406
+ * The runner's forwarder passes on exactly these and drops the rest, so this
407
+ * list is the contract between the two, owned here. The request types below
408
+ * accept no other name: a header added to a call without adding it here fails
409
+ * to compile, instead of being dropped by the forwarder in deployed runs only.
410
+ */
411
+ export const FUNCTION_SERVICE_HEADERS = [
412
+ 'content-type',
413
+ 'x-automation-run-token',
414
+ 'x-workspace-id',
415
+ KEEPALIVE_HEADER,
416
+ ] as const
417
+
418
+ /**
419
+ * Sent only by a process holding the runner's secret (the runner itself, on
420
+ * its own writes). A Function process never has it, so it is not a Function
421
+ * header and the forwarder never passes it on.
422
+ */
423
+ const RUNNER_TOKEN_HEADER = 'x-automation-runner-token'
424
+
425
+ type ServiceCallHeaders = Partial<Record<(typeof FUNCTION_SERVICE_HEADERS)[number] | typeof RUNNER_TOKEN_HEADER, string>>
426
+ /** A request to the service: `RequestInit` with its headers held to the names above. */
427
+ type ServiceCallInit = Omit<RequestInit, 'headers'> & { headers?: ServiceCallHeaders }
428
+
352
429
  type AgentRunOptions = { files?: ReadonlyArray<{ fileId: string }>; timeoutMs?: number }
353
430
 
354
431
  type AgentTurnStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'parked' | 'cancelled'
@@ -416,6 +493,31 @@ interface Deps {
416
493
  * because it has nothing to pass.
417
494
  */
418
495
  runnerToken?: string
496
+ /**
497
+ * The Unix socket the runner's forwarder listens on. Inside the deployed
498
+ * runner a Function process is denied internet sockets and reaches the
499
+ * forwarder only here, so every `ctx` call and `ctx.file` read goes over it.
500
+ * Absent (the CLI's dev worker, tests): calls go straight to `serviceUrl`.
501
+ */
502
+ socketPath?: string
503
+ }
504
+
505
+ /**
506
+ * The path under the forwarder that signed storage reads go to. Owned here and
507
+ * imported by the runner's forwarder, which routes on it, so the two cannot
508
+ * drift apart.
509
+ */
510
+ export const FORWARDER_STORAGE_PATH = '/storage'
511
+
512
+ /**
513
+ * A signed storage URL, pointed at the forwarder's storage path under
514
+ * `forwarderUrl`: same path and signature, forwarder host. The forwarder sends
515
+ * it on to real storage. In a Function process the service URL IS the
516
+ * forwarder, and its host is a placeholder; the Unix socket decides where it goes.
517
+ */
518
+ function readdressToForwarder(forwarderUrl: string, signedUrl: string): string {
519
+ const signed = new URL(signedUrl)
520
+ return `${forwarderUrl}${FORWARDER_STORAGE_PATH}${signed.pathname}${signed.search}`
419
521
  }
420
522
 
421
523
  export {
@@ -443,6 +545,11 @@ class GrantError extends Error {
443
545
 
444
546
  export function buildContext(deps: Deps): AutomationContext {
445
547
  const serviceUrl = deps.serviceUrl ?? SERVICE_URL
548
+ // Every call to the service goes through here. In the runner it rides the
549
+ // forwarder's Unix socket (the one path left open); elsewhere it is a plain
550
+ // fetch. `unix` is a Bun extension to RequestInit, hence the cast.
551
+ const serviceFetch = (path: string, init?: ServiceCallInit): Promise<Response> =>
552
+ fetch(`${serviceUrl}${path}`, deps.socketPath ? { ...init, unix: deps.socketPath } as RequestInit : init)
446
553
  const requireGrant = (grant: string) => {
447
554
  if (!deps.grants.includes(grant)) throw new GrantError(grant)
448
555
  }
@@ -460,10 +567,10 @@ export function buildContext(deps: Deps): AutomationContext {
460
567
  * a PRESENT runner header as an assertion to verify, so a blank one would be
461
568
  * a 401 instead of a fall-through to the run token.
462
569
  */
463
- const runnerHeaders: Record<string, string> = {
570
+ const runnerHeaders: ServiceCallHeaders = {
464
571
  'content-type': 'application/json',
465
572
  'x-automation-run-token': deps.runToken,
466
- ...(deps.runnerToken ? { 'x-automation-runner-token': deps.runnerToken } : {}),
573
+ ...(deps.runnerToken ? { [RUNNER_TOKEN_HEADER]: deps.runnerToken } : {}),
467
574
  }
468
575
 
469
576
  /**
@@ -503,7 +610,7 @@ export function buildContext(deps: Deps): AutomationContext {
503
610
  const recordStep = async (body: Record<string, unknown>): Promise<string> => {
504
611
  const parentStepId = stepScope.getStore()?.stepId
505
612
  try {
506
- const res = await fetch(`${serviceUrl}/v1/automations/runner/runs/${deps.runId}/steps`, {
613
+ const res = await serviceFetch(`/v1/automations/runner/runs/${deps.runId}/steps`, {
507
614
  method: 'POST',
508
615
  headers: runnerHeaders,
509
616
  body: JSON.stringify({
@@ -553,8 +660,8 @@ export function buildContext(deps: Deps): AutomationContext {
553
660
  ...(body.detail === undefined ? {} : { detail: jsonbSafe(body.detail) }),
554
661
  }
555
662
  try {
556
- const res = await fetch(
557
- `${serviceUrl}/v1/automations/runner/runs/${deps.runId}/steps/${stepId}/complete`,
663
+ const res = await serviceFetch(
664
+ `/v1/automations/runner/runs/${deps.runId}/steps/${stepId}/complete`,
558
665
  { method: 'POST', headers: runnerHeaders, body: JSON.stringify(payload) },
559
666
  )
560
667
  if (!res.ok) console.warn(`[ctx] step complete failed (non-fatal): ${res.status}`)
@@ -634,13 +741,13 @@ export function buildContext(deps: Deps): AutomationContext {
634
741
  * token is the entire authority of this call, and a caller that could replace
635
742
  * it could replace the run's scope.
636
743
  */
637
- const scoped = (path: string, init?: RequestInit) =>
744
+ const scoped = (path: string, init?: ServiceCallInit) =>
638
745
  // `/automations/runner` — the ctx endpoints live on `automationRunnerRouter`,
639
746
  // which is prefixed, because they authenticate by run token rather than by
640
747
  // session. Addressing them as `/automations/...` reaches the session-guarded
641
748
  // router instead and 404s. This is only caught end to end: both sides pass
642
749
  // their own tests, and the mismatch is between them.
643
- fetch(`${serviceUrl}/v1/automations/runner${path}`, {
750
+ serviceFetch(`/v1/automations/runner${path}`, {
644
751
  ...init,
645
752
  headers: {
646
753
  ...(init?.headers ?? {}),
@@ -707,6 +814,7 @@ export function buildContext(deps: Deps): AutomationContext {
707
814
  { stepId, stepName: name, submitted: new Set<string>() },
708
815
  fn,
709
816
  )
817
+ if (carriesFileHandle(out)) throw new Error(fileHandleInStepMessage(name))
710
818
  await completeStep(stepId, {
711
819
  status: 'ok',
712
820
  durationMs: Date.now() - t0,
@@ -961,21 +1069,46 @@ export function buildContext(deps: Deps): AutomationContext {
961
1069
  file: (ref: { fileId: string }) =>
962
1070
  step('file', `file:${ref.fileId.slice(0, 8)}`, async () => {
963
1071
  // No grant: this only reads a file the run was GIVEN — the route
964
- // refuses any fileId that is not among this run's own inputs. Returns
965
- // a short-lived signed URL plus the authoritative mime/size/name; in a
966
- // dry dev run it returns null, like every other ctx call.
1072
+ // refuses any fileId that is not among this run's own inputs (a `file`
1073
+ // input, or one item of a `files` input). In a dry dev run it returns
1074
+ // null, like every other ctx call.
967
1075
  const res = await scoped('/ctx/file-resolve', {
968
1076
  method: 'POST',
969
1077
  body: JSON.stringify({ fileId: ref.fileId }),
970
1078
  })
971
1079
  if (!res.ok) throw new Error(`file ${ref.fileId} → ${res.status} ${await refusal(res)}`)
972
- return ((await res.json()) as {
1080
+ const file = ((await res.json()) as {
973
1081
  data: { file: { fileId: string; signedUrl: string; mimeType: string; sizeBytes: number; name: string | null } | null }
974
1082
  }).data.file
1083
+ if (!file) return null
1084
+
1085
+ // The signed URL never leaves this closure. Function code cannot open a
1086
+ // network socket in the runner, and a URL in a step row would be a live
1087
+ // credential — so the SDK does the read, over the same forwarder socket,
1088
+ // and hands back the bytes. `readFile` re-resolves each time it is
1089
+ // called, so a large file need not sit in memory unless the author asks.
1090
+ const readFile = async (): Promise<Response> => {
1091
+ const target = deps.socketPath ? readdressToForwarder(serviceUrl, file.signedUrl) : file.signedUrl
1092
+ const response = await fetch(target, deps.socketPath ? { unix: deps.socketPath } as RequestInit : undefined)
1093
+ if (!response.ok) throw new Error(`file ${ref.fileId} download → ${response.status}`)
1094
+ return response
1095
+ }
1096
+ const handle: import('./types').ResolvedFileHandle = {
1097
+ fileId: file.fileId,
1098
+ mimeType: file.mimeType,
1099
+ sizeBytes: file.sizeBytes,
1100
+ name: file.name,
1101
+ bytes: async () => new Uint8Array(await (await readFile()).arrayBuffer()),
1102
+ text: async () => (await readFile()).text(),
1103
+ stream: async () => {
1104
+ const body = (await readFile()).body
1105
+ if (!body) throw new Error(`file ${ref.fileId} download had no body`)
1106
+ return body
1107
+ },
1108
+ }
1109
+ return asFileHandle(handle)
975
1110
  },
976
- // Everything the handle carries EXCEPT `signedUrl`. That URL is a
977
- // short-lived credential, and a row is exactly the wrong place for one:
978
- // details are rendered verbatim and kept for as long as the run is.
1111
+ // The row records the file's identity, never its bytes or a way to read them.
979
1112
  (out) =>
980
1113
  out
981
1114
  ? { fileId: out.fileId, mimeType: out.mimeType, sizeBytes: out.sizeBytes, name: out.name }
package/src/testing.ts CHANGED
@@ -3,9 +3,12 @@ import {
3
3
  duplicateStepMessage,
4
4
  duplicateSubmissionMessage,
5
5
  emptySubmissionKeyMessage,
6
+ fileHandleInStepMessage,
6
7
  missingGrantMessage,
8
+ oldFileStubMessage,
7
9
  submitOutsideStepMessage,
8
10
  } from './messages'
11
+ import { asFileHandle, carriesFileHandle } from './runtime-context'
9
12
  import type {
10
13
  ActionSubmission,
11
14
  ActionSubmitResult,
@@ -77,7 +80,7 @@ export interface TestContextOptions {
77
80
  agents?: Record<string, (prompt: string) => Promise<{ text: string }> | { text: string }>
78
81
  /** Per-fileId answers for `ctx.file`: what the resolved handle should carry.
79
82
  * An unstubbed fileId throws — a fabricated handle is a false pass. */
80
- files?: Record<string, { signedUrl: string; mimeType: string; sizeBytes: number; name: string | null }>
83
+ files?: Record<string, { content: string | Uint8Array; mimeType: string; name: string | null }>
81
84
  /**
82
85
  * Per-install, per-capability plugin answers: `{ crm: { create_ticket: (input) => ({ data }) } }`.
83
86
  * An unstubbed capability throws rather than answering — a fabricated
@@ -163,6 +166,9 @@ export function createTestContext(options: TestContextOptions = {}): TestContext
163
166
  return await stepScope.run({ stepName: name, submitted: new Set<string>() }, async () => {
164
167
  try {
165
168
  const out = await fn()
169
+ // Nothing here replays a step, so the handle would keep working in
170
+ // a test and break in a deployed run.
171
+ if (carriesFileHandle(out)) throw new Error(fileHandleInStepMessage(name))
166
172
  calls.push({ kind: 'step', label: name, status: 'ok' })
167
173
  return out
168
174
  } catch (err) {
@@ -194,11 +200,25 @@ export function createTestContext(options: TestContextOptions = {}): TestContext
194
200
  // stubbed would otherwise pass on made-up bytes.
195
201
  if (!stub) {
196
202
  throw new Error(
197
- `No file stub for "${ref.fileId}". Pass files: { '${ref.fileId}': { signedUrl: '…', ` +
198
- "mimeType: '…', sizeBytes: 0, name: null } } to createTestContext.",
203
+ `No file stub for "${ref.fileId}". Pass files: { '${ref.fileId}': { content: '…', ` +
204
+ "mimeType: '…', name: null } } to createTestContext.",
199
205
  )
200
206
  }
201
- return { fileId: ref.fileId, ...stub }
207
+ // A stub written for the handle's old shape ({ signedUrl, sizeBytes })
208
+ // has no content; without this, reading it fails as a bare TypeError.
209
+ if (typeof stub.content !== 'string' && !(stub.content instanceof Uint8Array)) {
210
+ throw new Error(oldFileStubMessage(ref.fileId))
211
+ }
212
+ const bytes = typeof stub.content === 'string' ? new TextEncoder().encode(stub.content) : stub.content
213
+ return asFileHandle({
214
+ fileId: ref.fileId,
215
+ mimeType: stub.mimeType,
216
+ name: stub.name,
217
+ sizeBytes: bytes.byteLength,
218
+ bytes: async () => bytes,
219
+ text: async () => new TextDecoder().decode(bytes),
220
+ stream: async () => new ReadableStream<Uint8Array>({ start(controller) { controller.enqueue(bytes); controller.close() } }),
221
+ })
202
222
  },
203
223
 
204
224
  agent(slug: string) {
package/src/types.ts CHANGED
@@ -78,21 +78,26 @@ export type Grant =
78
78
  * philosophy as the grant grammar: small enough that a wrong shape is
79
79
  * refusable with a sentence, wide enough for real parameters. */
80
80
  export interface InputFieldSpec {
81
- type: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'file'
81
+ type: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'file' | 'files'
82
82
  /** Refused at run start when absent. Mutually exclusive with `default`. */
83
83
  required?: boolean
84
84
  /** Applied at run start when the field is absent. Cron runs rely on these.
85
- * Not permitted on a `file` field — a file has no meaningful literal default. */
85
+ * Not permitted on a `file` or `files` field — a file has no meaningful literal default. */
86
86
  default?: unknown
87
87
  description?: string
88
88
  /** Allowed values — string and number types only. */
89
89
  enum?: readonly (string | number)[]
90
- /** `file` type only. Allowed MIME patterns, e.g. `['image/*', 'application/pdf']`.
90
+ /** `file` and `files` types only. Allowed MIME patterns, e.g. `['image/*', 'application/pdf']`.
91
+ * On a `files` field it applies to each file.
91
92
  * A declaration aid: the value on the run row is only a reference, so this is
92
93
  * enforced server-side at upload and at run start, never against the value here. */
93
94
  accept?: readonly string[]
94
- /** `file` type only. Maximum upload size in bytes, enforced server-side. */
95
+ /** `file` and `files` types only. Maximum upload size in bytes, enforced server-side.
96
+ * On a `files` field it applies to each file. */
95
97
  maxBytes?: number
98
+ /** `files` type only. The most files the field holds: a whole number from 1
99
+ * to 50, 20 when absent. */
100
+ maxFiles?: number
96
101
  /**
97
102
  * Mask this field's VALUE wherever a person reads the run.
98
103
  *
@@ -233,15 +238,31 @@ export interface AgentHandle {
233
238
  }
234
239
 
235
240
  /**
236
- * What `ctx.file(ref)` resolves to: a short-lived signed URL plus the
237
- * authoritative mime/size resolved at upload. `null` only in a dry dev run.
241
+ * What `ctx.file(ref)` resolves to: the file's identity plus readers that fetch
242
+ * its bytes. `null` only in a dry dev run.
243
+ *
244
+ * There is no URL. In the deployed runner a Function cannot open a network
245
+ * socket, and a signed URL would be a live credential; the readers download
246
+ * over the runner's forwarder inside the SDK. Each reader fetches afresh, so a
247
+ * large file need not stay in memory.
238
248
  */
239
249
  export interface ResolvedFileHandle {
240
250
  fileId: string
241
- signedUrl: string
242
251
  mimeType: string
243
252
  sizeBytes: number
244
253
  name: string | null
254
+ /**
255
+ * The whole file as bytes.
256
+ *
257
+ * Each reader downloads the file again, through a link that expires about an
258
+ * hour after `ctx.file` returned the handle. Read it soon after, or call
259
+ * `ctx.file` again rather than keeping a handle across a long wait.
260
+ */
261
+ bytes(): Promise<Uint8Array>
262
+ /** The whole file decoded as UTF-8 text. */
263
+ text(): Promise<string>
264
+ /** The file as a stream, for reading large files without holding them in memory. */
265
+ stream(): Promise<ReadableStream<Uint8Array>>
245
266
  }
246
267
 
247
268
  /**
@@ -472,9 +493,10 @@ export interface AutomationContext {
472
493
  log(message: string, data?: Record<string, unknown>): Promise<void>
473
494
  agent(slug: string): AgentHandle
474
495
  /**
475
- * Resolve one of THIS run's `file` inputs to a readable form (signed URL +
476
- * authoritative mime/size). No grant — it only reads files the run was given;
477
- * any other fileId is refused. `null` in a dry dev run.
496
+ * Resolve one of THIS run's files to a readable form (signed URL +
497
+ * authoritative mime/size): the value of a `file` input, or one item of a
498
+ * `files` input. No grant — it only reads files the run was given; any other
499
+ * fileId is refused. `null` in a dry dev run.
478
500
  */
479
501
  file(ref: FileRef): Promise<ResolvedFileHandle | null>
480
502
  /** One Plugin install, by the name `frontera plugin list` shows. Needs `plugin:<install>:<capability>` per call. */