@frontera-sdk/functions 1.49.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/LICENSE +202 -0
- package/README.md +122 -0
- package/package.json +48 -0
- package/src/define.ts +60 -0
- package/src/index.ts +27 -0
- package/src/inputs.ts +340 -0
- package/src/manifest.ts +341 -0
- package/src/messages.ts +108 -0
- package/src/runtime-context.ts +836 -0
- package/src/testing.ts +323 -0
- package/src/types.ts +600 -0
package/src/inputs.ts
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run-input validation — the value-side twin of `validateManifest`.
|
|
3
|
+
*
|
|
4
|
+
* Three callers must agree on the verdict and the wording: the run route
|
|
5
|
+
* (fast 400 before anything queues), `startRun` (authoritative — the runner
|
|
6
|
+
* posts whatever rode the event), and the Console form (client courtesy).
|
|
7
|
+
* Living in the SDK is what keeps them one implementation.
|
|
8
|
+
*/
|
|
9
|
+
import type { InputFieldSpec, InputsSchema } from './types'
|
|
10
|
+
|
|
11
|
+
export type InputValidation =
|
|
12
|
+
| { ok: true; value: Record<string, unknown> }
|
|
13
|
+
| { ok: false; errors: string[] }
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The input types' runtime validators, keyed by `InputFieldSpec['type']`.
|
|
17
|
+
*
|
|
18
|
+
* Single source of truth for "does this value have this type" — `validateInputValue`'s
|
|
19
|
+
* value check and `checkInputFieldSpec`'s default/enum checks all call this instead
|
|
20
|
+
* of re-deriving it, so a tightening here (the `Number.isFinite` guard that excludes
|
|
21
|
+
* `Infinity`/`NaN` from `number`) or a future widening can never drift between
|
|
22
|
+
* deploy-time and run-time again. It drifting once — `manifest.ts`'s old `okDefault`
|
|
23
|
+
* used `typeof spec.default === 'number'` and admitted `default: Infinity` — is why
|
|
24
|
+
* this is exported rather than kept module-private.
|
|
25
|
+
*
|
|
26
|
+
* `file` is shape-only here: it confirms the value is a `FileRef` (a plain object
|
|
27
|
+
* naming a non-empty string `fileId`). It deliberately does NOT authorize the id
|
|
28
|
+
* or check mime/size — those need the DB and the caller's workspace scope, so the
|
|
29
|
+
* SERVER (`run-service` via `resolveFile`) authorizes and resolves the reference
|
|
30
|
+
* after this passes.
|
|
31
|
+
*/
|
|
32
|
+
export const TYPE_CHECK: Record<InputFieldSpec['type'], (v: unknown) => boolean> = {
|
|
33
|
+
string: (v) => typeof v === 'string',
|
|
34
|
+
number: (v) => typeof v === 'number' && Number.isFinite(v),
|
|
35
|
+
boolean: (v) => typeof v === 'boolean',
|
|
36
|
+
object: (v) => typeof v === 'object' && v !== null && !Array.isArray(v),
|
|
37
|
+
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,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Lowercase kebab, matching `validateManifest`'s automation-`name` grammar. */
|
|
47
|
+
const INPUT_NAME_KEBAB_RE = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/
|
|
48
|
+
/** Lowercase snake — the one allowance kebab doesn't cover. */
|
|
49
|
+
const INPUT_NAME_SNAKE_RE = /^[a-z][a-z0-9_]*$/
|
|
50
|
+
|
|
51
|
+
const INPUT_TYPES = new Set<InputFieldSpec['type']>(['string', 'number', 'boolean', 'object', 'array', 'file'])
|
|
52
|
+
|
|
53
|
+
/** Keys `checkInputFieldSpec` understands on ONE input spec (`inputs.<name>`).
|
|
54
|
+
* Anything else there is a warning, same philosophy as `manifest.ts`'s
|
|
55
|
+
* top-level unknown-key warning: a typo like `requred` should be visible,
|
|
56
|
+
* but a field a newer SDK added must not fail an older validator's deploy. */
|
|
57
|
+
const INPUT_SPEC_KEYS = new Set([
|
|
58
|
+
'type',
|
|
59
|
+
'required',
|
|
60
|
+
'default',
|
|
61
|
+
'description',
|
|
62
|
+
'enum',
|
|
63
|
+
'redact',
|
|
64
|
+
'accept',
|
|
65
|
+
'maxBytes',
|
|
66
|
+
])
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Serialized cap on a run's input object — the same 64KB the service enforces
|
|
70
|
+
* when a run starts. Exported so `validateManifest` can refuse a manifest
|
|
71
|
+
* whose *defaults alone* exceed it at deploy time: past that point a cron
|
|
72
|
+
* fire would fail inside run-open with no run row, which is invisible.
|
|
73
|
+
*/
|
|
74
|
+
export const MAX_INPUT_BYTES = 64 * 1024
|
|
75
|
+
|
|
76
|
+
/** Input names that smell like credentials — warned at deploy, never blocked.
|
|
77
|
+
* Tails are anchored so `max_tokens` (a count) and `secretary` stay quiet
|
|
78
|
+
* while `api_key`, `auth-token`, `client_secret`, `secret_key` still warn. */
|
|
79
|
+
const CREDENTIAL_NAME_RE = /([_-]token$|[_-]key$|password|(^|[_-])secret([_-]|$))/i
|
|
80
|
+
|
|
81
|
+
export interface InputFieldCheck {
|
|
82
|
+
errors: string[]
|
|
83
|
+
warnings: string[]
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Structural rules for ONE `{ name: spec }` entry in an inputs schema — name
|
|
88
|
+
* shape, declared type, required/default shape, enum.
|
|
89
|
+
*
|
|
90
|
+
* The shared source for both `validateManifest` (deploy-time; also surfaces
|
|
91
|
+
* the non-fatal warnings) and `sanitizeInputsSchema` (runtime; pass/fail
|
|
92
|
+
* only) so the two can never quietly diverge on what "a well-formed input
|
|
93
|
+
* field" means — which is exactly how `manifest.ts`'s default-type check once
|
|
94
|
+
* drifted from `TYPE_CHECK` and admitted `default: Infinity`.
|
|
95
|
+
*/
|
|
96
|
+
export function checkInputFieldSpec(key: string, raw: unknown): InputFieldCheck {
|
|
97
|
+
const errors: string[] = []
|
|
98
|
+
const warnings: string[] = []
|
|
99
|
+
|
|
100
|
+
if (!INPUT_NAME_KEBAB_RE.test(key) && !INPUT_NAME_SNAKE_RE.test(key)) {
|
|
101
|
+
errors.push(`input "${key}" — names are lowercase snake or kebab`)
|
|
102
|
+
return { errors, warnings }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Inputs land on the run row and in traces permanently; there is no way to
|
|
106
|
+
// detect a secret in a value, but a name that says "credential" is an honest
|
|
107
|
+
// mistake we can flag while the author is still looking at the file.
|
|
108
|
+
if (CREDENTIAL_NAME_RE.test(key)) {
|
|
109
|
+
warnings.push(
|
|
110
|
+
`input "${key}" looks like a credential — inputs are stored on the run row `
|
|
111
|
+
+ 'and visible in traces. Use a `secret:` grant instead.',
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
|
116
|
+
for (const specKey of Object.keys(raw as Record<string, unknown>)) {
|
|
117
|
+
if (!INPUT_SPEC_KEYS.has(specKey)) {
|
|
118
|
+
warnings.push(`unknown key "${specKey}" on input "${key}" — ignored`)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const spec = (raw ?? {}) as {
|
|
124
|
+
type?: unknown
|
|
125
|
+
required?: unknown
|
|
126
|
+
default?: unknown
|
|
127
|
+
enum?: unknown
|
|
128
|
+
description?: unknown
|
|
129
|
+
redact?: unknown
|
|
130
|
+
accept?: unknown
|
|
131
|
+
maxBytes?: unknown
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (spec.description !== undefined && typeof spec.description !== 'string') {
|
|
135
|
+
warnings.push(`input "${key}": description is not a string — ignored`)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
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`)
|
|
140
|
+
return { errors, warnings }
|
|
141
|
+
}
|
|
142
|
+
const t = spec.type as InputFieldSpec['type']
|
|
143
|
+
|
|
144
|
+
// Deploy-side and runtime must share this exact predicate (`=== true`), not
|
|
145
|
+
// a truthy check — `inputs.ts`'s own `validateInputValue` only treats
|
|
146
|
+
// `required` as active when it is literally `true`. Without this, a plain
|
|
147
|
+
// `required: 1` would deploy clean and then never actually be enforced.
|
|
148
|
+
if (spec.required !== undefined && typeof spec.required !== 'boolean') {
|
|
149
|
+
errors.push(`input "${key}": required must be a boolean`)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (spec.required === true && spec.default !== undefined) {
|
|
153
|
+
errors.push(`input "${key}": required and default are mutually exclusive — a default always satisfies required`)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Same `=== true` predicate as `required`, for the same reason: the runtime
|
|
157
|
+
// treats only a literal `true` as active, so a truthy check here would let
|
|
158
|
+
// `redact: 1` deploy clean and then mask nothing.
|
|
159
|
+
if (spec.redact !== undefined && typeof spec.redact !== 'boolean') {
|
|
160
|
+
errors.push(`input "${key}": redact must be a boolean`)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Both of these publish the value the flag claims to hide, so they are
|
|
164
|
+
// refused rather than warned about: a manifest that declares them is a
|
|
165
|
+
// masking guarantee that was never going to hold.
|
|
166
|
+
if (spec.redact === true && spec.default !== undefined) {
|
|
167
|
+
errors.push(
|
|
168
|
+
`input "${key}": redact and default are mutually exclusive — a default is published in the `
|
|
169
|
+
+ 'version manifest, so the value would be readable there',
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
if (spec.redact === true && spec.enum !== undefined) {
|
|
173
|
+
errors.push(
|
|
174
|
+
`input "${key}": redact and enum are mutually exclusive — an enum publishes every allowed `
|
|
175
|
+
+ 'value in the version manifest',
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
let enumOk = true
|
|
180
|
+
if (spec.enum !== undefined) {
|
|
181
|
+
if (t !== 'string' && t !== 'number') {
|
|
182
|
+
errors.push(`input "${key}": enum is only valid for string and number types`)
|
|
183
|
+
enumOk = false
|
|
184
|
+
} else if (!Array.isArray(spec.enum) || spec.enum.length === 0) {
|
|
185
|
+
errors.push(`input "${key}": enum must not be empty`)
|
|
186
|
+
enumOk = false
|
|
187
|
+
} else if (spec.enum.some((e) => typeof e !== t)) {
|
|
188
|
+
errors.push(`input "${key}": enum values must match the declared type`)
|
|
189
|
+
enumOk = false
|
|
190
|
+
} else if (t === 'number' && spec.enum.some((e) => !Number.isFinite(e as number))) {
|
|
191
|
+
// TYPE_CHECK's own `number` check already excludes NaN/Infinity from
|
|
192
|
+
// values — a member of `enum` that no value could ever equal is
|
|
193
|
+
// unreachable and can only be an authoring mistake.
|
|
194
|
+
errors.push(`input "${key}": enum values must be finite numbers`)
|
|
195
|
+
enumOk = false
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
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
|
|
202
|
+
// only a reference), so the server enforces them; here we check well-formedness.
|
|
203
|
+
if (spec.accept !== undefined) {
|
|
204
|
+
if (t !== 'file') {
|
|
205
|
+
errors.push(`input "${key}": accept is only valid for file inputs`)
|
|
206
|
+
} else if (
|
|
207
|
+
!Array.isArray(spec.accept)
|
|
208
|
+
|| spec.accept.length === 0
|
|
209
|
+
|| spec.accept.some((a) => typeof a !== 'string' || a.length === 0)
|
|
210
|
+
) {
|
|
211
|
+
errors.push(`input "${key}": accept must be a non-empty array of MIME patterns`)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (spec.maxBytes !== undefined) {
|
|
215
|
+
if (t !== 'file') {
|
|
216
|
+
errors.push(`input "${key}": maxBytes is only valid for file inputs`)
|
|
217
|
+
} else if (typeof spec.maxBytes !== 'number' || !Number.isFinite(spec.maxBytes) || spec.maxBytes <= 0) {
|
|
218
|
+
errors.push(`input "${key}": maxBytes must be a positive number`)
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
let defaultOk = true
|
|
223
|
+
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') {
|
|
227
|
+
errors.push(`input "${key}": a file input cannot have a default`)
|
|
228
|
+
defaultOk = false
|
|
229
|
+
} else if (!TYPE_CHECK[t](spec.default)) {
|
|
230
|
+
errors.push(`input "${key}": default must match the declared type`)
|
|
231
|
+
defaultOk = false
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (spec.enum !== undefined && spec.default !== undefined && enumOk && defaultOk) {
|
|
236
|
+
if (!(spec.enum as unknown[]).includes(spec.default)) {
|
|
237
|
+
errors.push(`input "${key}": default must be one of the enum values`)
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return { errors, warnings }
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function validateInputValue(
|
|
245
|
+
schema: InputsSchema | undefined,
|
|
246
|
+
value: Record<string, unknown> | undefined | null,
|
|
247
|
+
): InputValidation {
|
|
248
|
+
const given = value ?? {}
|
|
249
|
+
if (!schema || Object.keys(schema).length === 0) {
|
|
250
|
+
return Object.keys(given).length === 0
|
|
251
|
+
? { ok: true, value: {} }
|
|
252
|
+
: { ok: false, errors: ['this automation declares no inputs — remove the input and run again'] }
|
|
253
|
+
}
|
|
254
|
+
const errors: string[] = []
|
|
255
|
+
const out: Record<string, unknown> = {}
|
|
256
|
+
for (const key of Object.keys(given)) {
|
|
257
|
+
// `Object.hasOwn`, not `key in schema`: the `in` operator also sees
|
|
258
|
+
// inherited members — every plain object "has" `toString` via
|
|
259
|
+
// `Object.prototype` — so a value keyed `toString` would slip past an
|
|
260
|
+
// undeclared-field check that used `in`.
|
|
261
|
+
if (!Object.hasOwn(schema, key)) errors.push(`"${key}" is not a declared input`)
|
|
262
|
+
}
|
|
263
|
+
for (const [key, spec] of Object.entries(schema)) {
|
|
264
|
+
// Same reasoning in reverse: plain `given[key]` for key `constructor`
|
|
265
|
+
// resolves to `Object.prototype.constructor` (a function) rather than
|
|
266
|
+
// `undefined` when the caller never supplied one, which would run type
|
|
267
|
+
// checks against Object's own constructor instead of treating the field
|
|
268
|
+
// as absent.
|
|
269
|
+
const v = Object.hasOwn(given, key) ? given[key] : undefined
|
|
270
|
+
if (v === undefined) {
|
|
271
|
+
if (spec.default !== undefined) out[key] = structuredClone(spec.default)
|
|
272
|
+
// `=== true`, not truthy: a legacy/malformed `required: 1` must not be
|
|
273
|
+
// silently enforced here when `validateManifest` already refuses it as
|
|
274
|
+
// "not a boolean" — the two sides share one predicate on purpose.
|
|
275
|
+
else if (spec.required === true) errors.push(`"${key}" is required`)
|
|
276
|
+
continue
|
|
277
|
+
}
|
|
278
|
+
// `Object.hasOwn`, not a plain lookup: `TYPE_CHECK` is an object literal,
|
|
279
|
+
// so `TYPE_CHECK['toString']` resolves to `Object.prototype.toString` —
|
|
280
|
+
// truthy, and callable — rather than `undefined`. A spec of `{ type:
|
|
281
|
+
// 'toString' }` would then pass `check(v)` for ANY `v` instead of being
|
|
282
|
+
// refused as the unknown type it is.
|
|
283
|
+
const check = Object.hasOwn(TYPE_CHECK, spec.type) ? TYPE_CHECK[spec.type as InputFieldSpec['type']] : undefined
|
|
284
|
+
if (!check) {
|
|
285
|
+
// A stored manifest can predate this SDK version and carry a `type`
|
|
286
|
+
// this build has never heard of (pre-input-validation, `inputs` was an
|
|
287
|
+
// unknown key with no shape checking at all). Fail the field, don't
|
|
288
|
+
// crash the run route.
|
|
289
|
+
errors.push(`"${key}" has an unknown declared type "${String(spec.type)}"`)
|
|
290
|
+
continue
|
|
291
|
+
}
|
|
292
|
+
if (!check(v)) {
|
|
293
|
+
errors.push(`"${key}" must be of type ${spec.type}`)
|
|
294
|
+
continue
|
|
295
|
+
}
|
|
296
|
+
if (spec.enum && !spec.enum.includes(v as string | number)) {
|
|
297
|
+
errors.push(`"${key}" must be one of ${spec.enum.join(', ')}`)
|
|
298
|
+
continue
|
|
299
|
+
}
|
|
300
|
+
out[key] = v
|
|
301
|
+
}
|
|
302
|
+
return errors.length > 0 ? { ok: false, errors } : { ok: true, value: out }
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* A stored manifest's `inputs` key, admitted only when structurally valid.
|
|
307
|
+
*
|
|
308
|
+
* Versions deployed before inputs existed could carry ANY value under this
|
|
309
|
+
* key (it was warn-and-store), and `startRun` must not let a stray legacy
|
|
310
|
+
* blob retroactively break a working schedule — an invalid schema is treated
|
|
311
|
+
* as "declares no inputs", never as a refusal.
|
|
312
|
+
*/
|
|
313
|
+
export function sanitizeInputsSchema(inputs: unknown): InputsSchema | undefined {
|
|
314
|
+
if (!inputs || typeof inputs !== 'object' || Array.isArray(inputs)) return undefined
|
|
315
|
+
for (const [key, raw] of Object.entries(inputs as Record<string, unknown>)) {
|
|
316
|
+
if (checkInputFieldSpec(key, raw).errors.length > 0) return undefined
|
|
317
|
+
}
|
|
318
|
+
return inputs as InputsSchema
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* The input names a version declared `redact: true` on.
|
|
324
|
+
*
|
|
325
|
+
* One implementation, so the service, the Console and anything else answer
|
|
326
|
+
* "which fields are masked" the same way — the same argument that keeps
|
|
327
|
+
* `checkInputFieldSpec` shared between deploy and runtime.
|
|
328
|
+
*
|
|
329
|
+
* FAIL CLOSED IS THE CALLER'S JOB, and it matters: `sanitizeInputsSchema`
|
|
330
|
+
* returns `undefined` for the WHOLE schema when any single field is malformed,
|
|
331
|
+
* which a caller deriving this list from its result would read as "nothing is
|
|
332
|
+
* redacted". A caller holding a non-empty raw `inputs` whose sanitized form is
|
|
333
|
+
* `undefined` must mask everything rather than nothing.
|
|
334
|
+
*/
|
|
335
|
+
export function redactedInputKeys(schema: InputsSchema | undefined): string[] {
|
|
336
|
+
if (!schema) return []
|
|
337
|
+
return Object.entries(schema)
|
|
338
|
+
.filter(([, spec]) => spec?.redact === true)
|
|
339
|
+
.map(([key]) => key)
|
|
340
|
+
}
|
package/src/manifest.ts
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import { CronExpressionParser } from 'cron-parser'
|
|
2
|
+
import { MAX_INPUT_BYTES, checkInputFieldSpec } from './inputs'
|
|
3
|
+
|
|
4
|
+
const SEGMENT = '[a-z][a-z0-9]*(?:-[a-z0-9]+)*'
|
|
5
|
+
const NAME_RE = new RegExp(`^${SEGMENT}$`)
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Runtime gate for a grant: `<namespace>:<name>[:<action>]`, every segment
|
|
9
|
+
* sharing NAME_RE's grammar so the whole vocabulary is consistent.
|
|
10
|
+
*
|
|
11
|
+
* WIDER than the `Grant` union on namespaces — a server must not reject
|
|
12
|
+
* `notify:email` merely because this build predates it. NARROWER than the
|
|
13
|
+
* union's `agent:${string}:run` arm on the slug, which admits `agent::run` and
|
|
14
|
+
* `agent:AGENT:run`; both are rejected here. No legitimate slug is affected —
|
|
15
|
+
* this repo's agent slugs are already lowercase-kebab.
|
|
16
|
+
*/
|
|
17
|
+
const GRANT_RE = new RegExp(`^${SEGMENT}:${SEGMENT}(?::${SEGMENT})?$`)
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* `http:<host>` and `secret:<NAME>` need their own grammars, because SEGMENT is
|
|
21
|
+
* lowercase-kebab and neither value is.
|
|
22
|
+
*
|
|
23
|
+
* A host contains DOTS (`api.stripe.com`); a secret name is conventionally
|
|
24
|
+
* SCREAMING_SNAKE_CASE (`STRIPE_KEY`). Validating them with SEGMENT rejected both
|
|
25
|
+
* realistic forms — found by deploying an automation that used them.
|
|
26
|
+
*
|
|
27
|
+
* Deliberately not solved by widening SEGMENT: that governs agent slugs too, and
|
|
28
|
+
* loosening it there would admit `agent:AGENT:run`, which the comment above says
|
|
29
|
+
* is rejected on purpose.
|
|
30
|
+
*/
|
|
31
|
+
const HOST_RE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*$/
|
|
32
|
+
// Matches SECRET_NAME_PATTERN in workspace-secrets-router exactly. Being MORE
|
|
33
|
+
// permissive here would let a manifest declare `secret:myKey`, validate cleanly,
|
|
34
|
+
// and then never be satisfiable — no such secret can be created. A validator that
|
|
35
|
+
// accepts the unsatisfiable is worse than one that is strict.
|
|
36
|
+
const SECRET_NAME_RE = /^[A-Z][A-Z0-9_]*$/
|
|
37
|
+
// Matches `apiNameSchema` in the Blueprint Action definition schema exactly.
|
|
38
|
+
// Same reasoning as SECRET_NAME_RE: a looser grammar here would accept
|
|
39
|
+
// `governed:Approve_Invoice`, validate cleanly, and name an Action that can
|
|
40
|
+
// never exist — no published Action carries that apiName, so the grant is
|
|
41
|
+
// unsatisfiable and the automation fails at its first submit instead of at
|
|
42
|
+
// deploy.
|
|
43
|
+
const ACTION_API_NAME_RE = /^[a-z][A-Za-z0-9]{0,99}$/
|
|
44
|
+
|
|
45
|
+
// `plugin:<install>:<capability>`. The service does NOT validate
|
|
46
|
+
// `app_installs.install_name` — it is `t.String({ minLength: 1 })`, so an
|
|
47
|
+
// admin can name an install "My CRM" and it works fine everywhere except
|
|
48
|
+
// here. This grammar (lowercase, dot/dash/underscore, no spaces — the catalog
|
|
49
|
+
// default is kebab) is what makes an install's name usable from a manifest;
|
|
50
|
+
// one outside it has to be renamed before an automation can grant it. The
|
|
51
|
+
// capability half is deliberately wider: MCP tool names and spec capability
|
|
52
|
+
// names are `create_issue` / `listIssues`, neither of which is a SEGMENT. No
|
|
53
|
+
// wildcard in either half — the manifest is the reviewable list of what the
|
|
54
|
+
// automation can reach, same as `http:`.
|
|
55
|
+
const PLUGIN_GRANT_RE = /^[a-z0-9][a-z0-9._-]{0,63}:[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/
|
|
56
|
+
|
|
57
|
+
/** Namespaces whose value is not a SEGMENT. */
|
|
58
|
+
const TYPED_NAMESPACES: Record<string, { re: RegExp; hint: string }> = {
|
|
59
|
+
http: { re: HOST_RE, hint: 'a hostname, e.g. "http:api.stripe.com" (no scheme, no path, no wildcard)' },
|
|
60
|
+
secret: { re: SECRET_NAME_RE, hint: 'a workspace secret name, e.g. "secret:STRIPE_KEY"' },
|
|
61
|
+
governed: {
|
|
62
|
+
re: ACTION_API_NAME_RE,
|
|
63
|
+
hint: 'one published Action apiName, e.g. "governed:approveInvoice" (camelCase, no wildcard)',
|
|
64
|
+
},
|
|
65
|
+
plugin: {
|
|
66
|
+
re: PLUGIN_GRANT_RE,
|
|
67
|
+
hint:
|
|
68
|
+
'<install>:<capability>, e.g. "plugin:crm:create_ticket" — the install name from '
|
|
69
|
+
+ '`frontera plugin list` (lowercase, no spaces) and one capability name (no wildcard)'
|
|
70
|
+
+ ' — rename the install if its name has capitals or spaces',
|
|
71
|
+
},
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Shortest description an agent-callable automation may carry.
|
|
76
|
+
*
|
|
77
|
+
* Not a round number picked for looks: it is about the length of one honest
|
|
78
|
+
* clause ("Reconcile open invoices against the settlement file"), and it is
|
|
79
|
+
* chosen to be long enough that the slug restated as a sentence — "reconcile
|
|
80
|
+
* invoices" — does not clear it. A description that only repeats the name
|
|
81
|
+
* tells a model nothing it did not already have from the tool name.
|
|
82
|
+
*/
|
|
83
|
+
export const AGENT_DESCRIPTION_MIN_CHARS = 24
|
|
84
|
+
|
|
85
|
+
const KNOWN_KEYS = new Set([
|
|
86
|
+
'name', 'trigger', 'grants', 'inputs', 'concurrency', 'retries', 'description',
|
|
87
|
+
])
|
|
88
|
+
|
|
89
|
+
export interface ValidationResult {
|
|
90
|
+
valid: boolean
|
|
91
|
+
errors: string[]
|
|
92
|
+
/**
|
|
93
|
+
* Non-fatal. An unknown manifest key lands here rather than in `errors`:
|
|
94
|
+
* a newer SDK must be able to add a field without an older service refusing
|
|
95
|
+
* the deploy. The CLI prints these, so a typo like `concurrancy: 100` — which
|
|
96
|
+
* would otherwise deploy "successfully" with the default of 1 — is caught at
|
|
97
|
+
* author time, where the SDK and the manifest are the same version.
|
|
98
|
+
*/
|
|
99
|
+
warnings: string[]
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Takes `unknown`, on purpose.
|
|
104
|
+
*
|
|
105
|
+
* The authoritative call site is the service, validating a manifest that
|
|
106
|
+
* arrived over HTTP — untrusted, and not yet known to have any shape. Typing
|
|
107
|
+
* the parameter as `AutomationManifest` would force every honest caller to
|
|
108
|
+
* launder untrusted input through a cast, which is how a validator ends up
|
|
109
|
+
* trusting the thing it exists to check.
|
|
110
|
+
*
|
|
111
|
+
* The regexes here are deliberately wider than the `Grant` union in `types.ts`:
|
|
112
|
+
* that union is an author-time affordance, this is a runtime gate, and a server
|
|
113
|
+
* must not reject a grant merely because this build predates it.
|
|
114
|
+
*/
|
|
115
|
+
export function validateManifest(input: unknown): ValidationResult {
|
|
116
|
+
const errors: string[] = []
|
|
117
|
+
const warnings: string[] = []
|
|
118
|
+
const m = (input ?? {}) as {
|
|
119
|
+
name?: unknown
|
|
120
|
+
trigger?: unknown
|
|
121
|
+
grants?: unknown
|
|
122
|
+
inputs?: unknown
|
|
123
|
+
concurrency?: unknown
|
|
124
|
+
retries?: unknown
|
|
125
|
+
description?: unknown
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (typeof m.name !== 'string' || !NAME_RE.test(m.name)) {
|
|
129
|
+
errors.push('name must be lowercase kebab-case')
|
|
130
|
+
} else if (m.name.length > 64) {
|
|
131
|
+
errors.push('name must be 64 characters or fewer')
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const trigger = m.trigger as
|
|
135
|
+
| { cron?: string; manual?: boolean; agent?: boolean }
|
|
136
|
+
| undefined
|
|
137
|
+
if (
|
|
138
|
+
!trigger
|
|
139
|
+
|| (trigger.cron === undefined && trigger.manual !== true && trigger.agent !== true)
|
|
140
|
+
) {
|
|
141
|
+
errors.push('trigger must be { cron }, { manual: true }, or { agent: true }')
|
|
142
|
+
} else if (trigger.agent !== undefined && trigger.agent !== true) {
|
|
143
|
+
// Not folded into the arm above: `{ manual: true, agent: false }` is a
|
|
144
|
+
// legal-looking manifest that means nothing. `agent` is a permission, and
|
|
145
|
+
// the way to withhold a permission is to omit it, not to write it false —
|
|
146
|
+
// the same rule the grant list follows.
|
|
147
|
+
errors.push(
|
|
148
|
+
'trigger.agent must be true when present — omit the key to mean "not agent-callable"',
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
// Separate `if`, not the old `else if`: with three arms the cron check has to
|
|
152
|
+
// run whenever a cron is present, including on `{ cron, agent: true }`, and
|
|
153
|
+
// an `else if` chained off the acceptance test above would skip it there.
|
|
154
|
+
if (trigger?.cron !== undefined) {
|
|
155
|
+
if (typeof trigger.cron !== 'string') {
|
|
156
|
+
errors.push('invalid cron expression: must be a string')
|
|
157
|
+
} else {
|
|
158
|
+
// Both field-count branches exist because `cron-parser` accepts an
|
|
159
|
+
// off-count expression rather than throwing, so neither case would ever
|
|
160
|
+
// reach the `catch` below:
|
|
161
|
+
// `* * * * * *` -> reads field 1 as SECONDS and fires sub-minute.
|
|
162
|
+
// `0 7 * *` -> left-pads, scheduling something the author never wrote.
|
|
163
|
+
// Only an exactly-5-field expression means what it looks like it means.
|
|
164
|
+
const fields = trigger.cron.trim().split(/\s+/).length
|
|
165
|
+
if (fields !== 5) {
|
|
166
|
+
// One message shape for one class of fault. Splitting it meant a
|
|
167
|
+
// 7-field expression was told it was "sub-minute" — a diagnosis
|
|
168
|
+
// asserted rather than derived — while a 4-field one got no diagnosis
|
|
169
|
+
// at all.
|
|
170
|
+
errors.push(
|
|
171
|
+
`invalid cron expression: expected 5 fields, got ${fields}` +
|
|
172
|
+
(fields > 5 ? '; sub-minute schedules are not supported' : ''),
|
|
173
|
+
)
|
|
174
|
+
} else {
|
|
175
|
+
try {
|
|
176
|
+
CronExpressionParser.parse(trigger.cron, { tz: 'UTC' })
|
|
177
|
+
} catch (err) {
|
|
178
|
+
errors.push(`invalid cron expression: ${(err as Error).message}`)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (m.grants !== undefined && !Array.isArray(m.grants)) {
|
|
185
|
+
errors.push('grants must be an array')
|
|
186
|
+
} else {
|
|
187
|
+
for (const g of (m.grants as unknown[]) ?? []) {
|
|
188
|
+
// String(g), not `${g}` — a template literal THROWS on a symbol, and a
|
|
189
|
+
// validator that exists to absorb hostile input must not have a throwing
|
|
190
|
+
// path. The message names the fix, not just the verdict.
|
|
191
|
+
if (typeof g !== 'string') {
|
|
192
|
+
errors.push(
|
|
193
|
+
`malformed grant "${String(g)}" — expected "<namespace>:<action>", ` +
|
|
194
|
+
'e.g. "blueprint:read" or "agent:risk-analyst:run"',
|
|
195
|
+
)
|
|
196
|
+
continue
|
|
197
|
+
}
|
|
198
|
+
const colon = g.indexOf(':')
|
|
199
|
+
const typed = colon > 0 ? TYPED_NAMESPACES[g.slice(0, colon)] : undefined
|
|
200
|
+
if (typed) {
|
|
201
|
+
// A typed namespace validates its OWN value grammar. `http:` and
|
|
202
|
+
// `secret:` carry hosts and secret names, neither of which is a SEGMENT.
|
|
203
|
+
if (!typed.re.test(g.slice(colon + 1))) {
|
|
204
|
+
errors.push(`malformed grant "${g}" — the part after the colon must be ${typed.hint}`)
|
|
205
|
+
}
|
|
206
|
+
continue
|
|
207
|
+
}
|
|
208
|
+
if (!GRANT_RE.test(g)) {
|
|
209
|
+
errors.push(
|
|
210
|
+
`malformed grant "${String(g)}" — expected "<namespace>:<action>", ` +
|
|
211
|
+
'e.g. "blueprint:read" or "agent:risk-analyst:run"',
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const c = m.concurrency
|
|
218
|
+
if (c !== undefined && (!Number.isInteger(c) || (c as number) < 1 || (c as number) > 50)) {
|
|
219
|
+
errors.push('concurrency must be an integer between 1 and 50')
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Capped at 5. Above that it is not a retry policy, it is a loop — and every
|
|
223
|
+
// attempt re-runs whatever side effects the previous one already performed.
|
|
224
|
+
const r = m.retries
|
|
225
|
+
if (r !== undefined && (!Number.isInteger(r) || (r as number) < 0 || (r as number) > 5)) {
|
|
226
|
+
errors.push('retries must be an integer between 0 and 5')
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const inputs = m.inputs
|
|
230
|
+
if (inputs !== undefined) {
|
|
231
|
+
if (!inputs || typeof inputs !== 'object' || Array.isArray(inputs)) {
|
|
232
|
+
errors.push('inputs must be an object of { name: { type, … } }')
|
|
233
|
+
} else {
|
|
234
|
+
// Per-field rules (name shape, type, required/default shape, enum) live
|
|
235
|
+
// in `checkInputFieldSpec` — shared with `sanitizeInputsSchema` so the
|
|
236
|
+
// two can never drift on what "a well-formed input field" means.
|
|
237
|
+
for (const [key, raw] of Object.entries(inputs as Record<string, unknown>)) {
|
|
238
|
+
const field = checkInputFieldSpec(key, raw)
|
|
239
|
+
errors.push(...field.errors)
|
|
240
|
+
warnings.push(...field.warnings)
|
|
241
|
+
}
|
|
242
|
+
// A cron fire has no one to prompt: every required field must be
|
|
243
|
+
// satisfiable from defaults, or the schedule would fail on every tick.
|
|
244
|
+
const trig = m.trigger as { cron?: unknown } | undefined
|
|
245
|
+
if (typeof trig?.cron === 'string') {
|
|
246
|
+
for (const [key, raw] of Object.entries(inputs as Record<string, unknown>)) {
|
|
247
|
+
const spec = raw as { type?: unknown; required?: unknown; default?: unknown }
|
|
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'
|
|
253
|
+
? 'A file input cannot have a default — make the input optional or the trigger manual.'
|
|
254
|
+
: 'Add a default or make the trigger manual.'
|
|
255
|
+
errors.push(
|
|
256
|
+
`input "${key}" is required with no default, and the trigger is a cron — `
|
|
257
|
+
+ `cron has nobody to ask. ${remedy}`,
|
|
258
|
+
)
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
// A run's input is capped at MAX_INPUT_BYTES when it starts. If the
|
|
263
|
+
// declared defaults alone already exceed that, a cron fire (or a bare
|
|
264
|
+
// Run-now) fails inside run-open before any run row exists — a silent
|
|
265
|
+
// death only visible in runner logs. Catch it here, the one place the
|
|
266
|
+
// author is still looking at the file.
|
|
267
|
+
const defaultsOnly: Record<string, unknown> = {}
|
|
268
|
+
for (const [key, raw] of Object.entries(inputs as Record<string, unknown>)) {
|
|
269
|
+
const spec = raw as { default?: unknown }
|
|
270
|
+
if (spec && typeof spec === 'object' && spec.default !== undefined) {
|
|
271
|
+
defaultsOnly[key] = spec.default
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
try {
|
|
275
|
+
const bytes = new TextEncoder().encode(JSON.stringify(defaultsOnly)).length
|
|
276
|
+
if (bytes > MAX_INPUT_BYTES) {
|
|
277
|
+
errors.push(
|
|
278
|
+
`input defaults alone serialize to ${bytes} bytes — over the ${MAX_INPUT_BYTES}-byte `
|
|
279
|
+
+ 'run-input cap, so every run would fail at start. Slim the defaults.',
|
|
280
|
+
)
|
|
281
|
+
}
|
|
282
|
+
} catch {
|
|
283
|
+
// A default that JSON.stringify chokes on (circular, throwing toJSON)
|
|
284
|
+
// is practically unreachable — the manifest itself must serialize to
|
|
285
|
+
// deploy at all — but the size check must never be the thing that throws.
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// `trigger: { agent: true }` turns this manifest into the source of a tool
|
|
291
|
+
// definition a language model reads and decides from. Two fields that are
|
|
292
|
+
// courtesies everywhere else become load-bearing here, so they are errors
|
|
293
|
+
// rather than warnings: a model handed an undescribed tool, or an undescribed
|
|
294
|
+
// argument, does not fail loudly — it guesses, and the guess starts a real
|
|
295
|
+
// run against real systems. Checked at deploy, where the author still has the
|
|
296
|
+
// file open, rather than at bind time in a Console someone else is using.
|
|
297
|
+
if (trigger?.agent === true) {
|
|
298
|
+
const description = m.description
|
|
299
|
+
if (typeof description !== 'string' || description.trim().length < AGENT_DESCRIPTION_MIN_CHARS) {
|
|
300
|
+
errors.push(
|
|
301
|
+
`trigger { agent: true } requires a description of at least ${AGENT_DESCRIPTION_MIN_CHARS} `
|
|
302
|
+
+ 'characters — it becomes the tool description an agent reads before calling this '
|
|
303
|
+
+ 'automation.',
|
|
304
|
+
)
|
|
305
|
+
}
|
|
306
|
+
const agentInputs = m.inputs
|
|
307
|
+
if (agentInputs && typeof agentInputs === 'object' && !Array.isArray(agentInputs)) {
|
|
308
|
+
for (const [key, raw] of Object.entries(agentInputs as Record<string, unknown>)) {
|
|
309
|
+
const spec = raw as { description?: unknown; redact?: unknown } | null
|
|
310
|
+
if (typeof spec?.description !== 'string' || spec.description.trim().length === 0) {
|
|
311
|
+
errors.push(
|
|
312
|
+
`input "${key}" needs a description: trigger { agent: true } publishes every input as `
|
|
313
|
+
+ 'a tool argument, and an agent cannot fill an argument it has no description for.',
|
|
314
|
+
)
|
|
315
|
+
}
|
|
316
|
+
// An error, not a warning, and refused here where the author still has
|
|
317
|
+
// the file open. On the agent path the MODEL produces this value as
|
|
318
|
+
// tool-call arguments: it is in the conversation and in that
|
|
319
|
+
// conversation's trace before a run row exists to mask. Masking the run
|
|
320
|
+
// row would advertise a guarantee this path cannot keep.
|
|
321
|
+
if (spec?.redact === true) {
|
|
322
|
+
errors.push(
|
|
323
|
+
`input "${key}": redact cannot be used with trigger { agent: true } — the agent `
|
|
324
|
+
+ 'supplies this value as a tool argument, so it is already in the conversation and '
|
|
325
|
+
+ 'its trace before the run exists.',
|
|
326
|
+
)
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (input && typeof input === 'object' && !Array.isArray(input)) {
|
|
333
|
+
for (const key of Object.keys(input)) {
|
|
334
|
+
if (!KNOWN_KEYS.has(key)) {
|
|
335
|
+
warnings.push(`unknown manifest key "${key}" — ignored`)
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return { valid: errors.length === 0, errors, warnings }
|
|
341
|
+
}
|