@frontera-sdk/functions 1.50.84 → 1.51.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.
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.0",
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.0",
46
46
  "cron-parser": "^5.0.6"
47
47
  }
48
48
  }
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,45 @@ 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. In a dry
1073
+ // dev run it returns null, like every other ctx call.
967
1074
  const res = await scoped('/ctx/file-resolve', {
968
1075
  method: 'POST',
969
1076
  body: JSON.stringify({ fileId: ref.fileId }),
970
1077
  })
971
1078
  if (!res.ok) throw new Error(`file ${ref.fileId} → ${res.status} ${await refusal(res)}`)
972
- return ((await res.json()) as {
1079
+ const file = ((await res.json()) as {
973
1080
  data: { file: { fileId: string; signedUrl: string; mimeType: string; sizeBytes: number; name: string | null } | null }
974
1081
  }).data.file
1082
+ if (!file) return null
1083
+
1084
+ // The signed URL never leaves this closure. Function code cannot open a
1085
+ // network socket in the runner, and a URL in a step row would be a live
1086
+ // credential — so the SDK does the read, over the same forwarder socket,
1087
+ // and hands back the bytes. `readFile` re-resolves each time it is
1088
+ // called, so a large file need not sit in memory unless the author asks.
1089
+ const readFile = async (): Promise<Response> => {
1090
+ const target = deps.socketPath ? readdressToForwarder(serviceUrl, file.signedUrl) : file.signedUrl
1091
+ const response = await fetch(target, deps.socketPath ? { unix: deps.socketPath } as RequestInit : undefined)
1092
+ if (!response.ok) throw new Error(`file ${ref.fileId} download → ${response.status}`)
1093
+ return response
1094
+ }
1095
+ const handle: import('./types').ResolvedFileHandle = {
1096
+ fileId: file.fileId,
1097
+ mimeType: file.mimeType,
1098
+ sizeBytes: file.sizeBytes,
1099
+ name: file.name,
1100
+ bytes: async () => new Uint8Array(await (await readFile()).arrayBuffer()),
1101
+ text: async () => (await readFile()).text(),
1102
+ stream: async () => {
1103
+ const body = (await readFile()).body
1104
+ if (!body) throw new Error(`file ${ref.fileId} download had no body`)
1105
+ return body
1106
+ },
1107
+ }
1108
+ return asFileHandle(handle)
975
1109
  },
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.
1110
+ // The row records the file's identity, never its bytes or a way to read them.
979
1111
  (out) =>
980
1112
  out
981
1113
  ? { 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
@@ -233,15 +233,31 @@ export interface AgentHandle {
233
233
  }
234
234
 
235
235
  /**
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.
236
+ * What `ctx.file(ref)` resolves to: the file's identity plus readers that fetch
237
+ * its bytes. `null` only in a dry dev run.
238
+ *
239
+ * There is no URL. In the deployed runner a Function cannot open a network
240
+ * socket, and a signed URL would be a live credential; the readers download
241
+ * over the runner's forwarder inside the SDK. Each reader fetches afresh, so a
242
+ * large file need not stay in memory.
238
243
  */
239
244
  export interface ResolvedFileHandle {
240
245
  fileId: string
241
- signedUrl: string
242
246
  mimeType: string
243
247
  sizeBytes: number
244
248
  name: string | null
249
+ /**
250
+ * The whole file as bytes.
251
+ *
252
+ * Each reader downloads the file again, through a link that expires about an
253
+ * hour after `ctx.file` returned the handle. Read it soon after, or call
254
+ * `ctx.file` again rather than keeping a handle across a long wait.
255
+ */
256
+ bytes(): Promise<Uint8Array>
257
+ /** The whole file decoded as UTF-8 text. */
258
+ text(): Promise<string>
259
+ /** The file as a stream, for reading large files without holding them in memory. */
260
+ stream(): Promise<ReadableStream<Uint8Array>>
245
261
  }
246
262
 
247
263
  /**