@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/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
+ }
@@ -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
+ }