From 7eca1f194c72e3be1777817f6c1afba7f2dfba6c Mon Sep 17 00:00:00 2001 From: Miya Date: Thu, 16 Jul 2026 20:14:23 +0200 Subject: [PATCH 1/4] fix(persona-kit): preserve trigger paths --- packages/deploy/src/compile-agent.test.ts | 37 +++++++++++++++ .../persona-kit/schemas/agent.schema.json | 12 ++++- packages/persona-kit/scripts/emit-schema.mjs | 16 +++++++ packages/persona-kit/src/define.test.ts | 9 +++- packages/persona-kit/src/define.ts | 1 + packages/persona-kit/src/emit-schema.test.ts | 47 ++++++++++++++++++- packages/persona-kit/src/parse.test.ts | 29 +++++++++++- packages/persona-kit/src/parse.ts | 19 +++++++- packages/persona-kit/src/types.ts | 6 +++ packages/runtime/src/define-agent.test.ts | 15 +++++- 10 files changed, 183 insertions(+), 8 deletions(-) diff --git a/packages/deploy/src/compile-agent.test.ts b/packages/deploy/src/compile-agent.test.ts index 24a47810..88e04bb9 100644 --- a/packages/deploy/src/compile-agent.test.ts +++ b/packages/deploy/src/compile-agent.test.ts @@ -98,6 +98,43 @@ test('unknown capability extensions survive source-to-persistence projection', a } }); +test('trigger paths survive source-to-persistence projection for wake scoping', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'compiled-agent-trigger-paths-')); + try { + const sourcePath = path.join(dir, 'agent.ts'); + const source = PRESET + .replace('integrations: { github: {} },', 'integrations: { github: {}, slack: {} },') + .replace( + "triggers: { github: [{ on: 'pull_request.opened' }] },", + "triggers: {\n" + + " github: [{ on: 'pull_request.opened', paths: ['/github/repos/AgentWorkforce/workforce/pulls/**'] }],\n" + + " slack: [{ on: 'message.created', paths: ['/slack/channels/C_REVIEW/**'] }]\n" + + ' },' + ); + await writeFile(sourcePath, source); + + const compiled = await compileAgentSource(sourcePath); + const persisted = projectCompiledAgentForPersistence(compiled); + + assert.deepEqual(persisted.agent.triggers, { + github: [ + { + on: 'pull_request.opened', + paths: ['/github/repos/AgentWorkforce/workforce/pulls/**'] + } + ], + slack: [ + { + on: 'message.created', + paths: ['/slack/channels/C_REVIEW/**'] + } + ] + }); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + test('split persona plus agent form remains supported', async () => { const dir = await mkdtemp(path.join(tmpdir(), 'compiled-agent-split-')); try { diff --git a/packages/persona-kit/schemas/agent.schema.json b/packages/persona-kit/schemas/agent.schema.json index 51bb0df6..f197bc9e 100644 --- a/packages/persona-kit/schemas/agent.schema.json +++ b/packages/persona-kit/schemas/agent.schema.json @@ -49,6 +49,16 @@ "where": { "type": "string" }, + "paths": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "pattern": "^/" + }, + "description": "Optional Relayfile path globs used to scope wake routing before an agent is provisioned. Paths are absolute (leading `/`) and provider-specific.", + "minItems": 1 + }, "maxConcurrency": { "type": "integer", "description": "Optional delivery backpressure hint for this trigger. Positive integers are preserved by the parser; invalid values are treated as unset so older or malformed specs do not block delivery.", @@ -58,7 +68,7 @@ "required": [ "on" ], - "description": "A single event trigger declared by an **agent** (`agent.ts`). `on` is a Relayfile-adapter-normalized event name (e.g. `pull_request.opened`, `issue.create`, `message.created`). `match` and `where` are filter sugars the deploy CLI lints against a known registry; unknown values warn but do not fail parse, so the cloud runtime stays the source of truth.\n\nTriggers live on the agent ( {@link AgentSpec.triggers } ), not the persona — the persona only declares which providers it connects to ( {@link PersonaIntegrationConfig } ). The deploy CLI joins the two: events from `agent.triggers[provider]`, connection/scope from `persona.integrations[provider]`.\n\nExamples: { on: \"pull_request.opened\" } { on: \"pull_request_review_comment.created\", match: \"@mention\" } { on: \"check_run.completed\", where: \"conclusion=failure\" } { on: \"message.created\", maxConcurrency: 1 }" + "description": "A single event trigger declared by an **agent** (`agent.ts`). `on` is a Relayfile-adapter-normalized event name (e.g. `pull_request.opened`, `issue.create`, `message.created`). `match` and `where` are filter sugars the deploy CLI lints against a known registry; unknown values warn but do not fail parse, so the cloud runtime stays the source of truth.\n\nTriggers live on the agent ( {@link AgentSpec.triggers } ), not the persona — the persona only declares which providers it connects to ( {@link PersonaIntegrationConfig } ). The deploy CLI joins the two: events from `agent.triggers[provider]`, connection/scope from `persona.integrations[provider]`.\n\nExamples: { on: \"pull_request.opened\" } { on: \"pull_request_review_comment.created\", match: \"@mention\" } { on: \"check_run.completed\", where: \"conclusion=failure\" } { on: \"message.created\", paths: [\"/slack/channels/C_REVIEW/**\"] } { on: \"message.created\", maxConcurrency: 1 }" }, "PersonaSchedule": { "type": "object", diff --git a/packages/persona-kit/scripts/emit-schema.mjs b/packages/persona-kit/scripts/emit-schema.mjs index a046f28e..7d972e0c 100644 --- a/packages/persona-kit/scripts/emit-schema.mjs +++ b/packages/persona-kit/scripts/emit-schema.mjs @@ -146,6 +146,22 @@ if ( }; } +const agentTriggerPaths = agentTrigger?.properties?.paths; +if (agentTriggerPaths?.type === 'array') { + agentTriggerPaths.minItems = 1; + if ( + agentTriggerPaths.items && + typeof agentTriggerPaths.items === 'object' && + !Array.isArray(agentTriggerPaths.items) + ) { + agentTriggerPaths.items = { + ...agentTriggerPaths.items, + minLength: 1, + pattern: '^/' + }; + } +} + const agentSerialized = `${JSON.stringify(agentSchema, null, 2)}\n`; let existingAgent = ''; try { diff --git a/packages/persona-kit/src/define.test.ts b/packages/persona-kit/src/define.test.ts index d02b81ad..3d2e80b8 100644 --- a/packages/persona-kit/src/define.test.ts +++ b/packages/persona-kit/src/define.test.ts @@ -126,11 +126,18 @@ test('TypedTriggerMap gives per-provider event autocomplete; arbitrary providers { on: 'AgentSessionEvent.prompted' }, { on: 'AppUserNotification.issueCommentMention' } ], - slack: [{ on: 'message.created', maxConcurrency: 1 }], + slack: [ + { + on: 'message.created', + paths: ['/slack/channels/C_REVIEW/**'], + maxConcurrency: 1 + } + ], customProvider: [{ on: 'custom.event' }] }; assert.equal(triggers.github?.[1]?.on, 'off_registry.github_event'); assert.equal(triggers.slack?.[0]?.maxConcurrency, 1); + assert.deepEqual(triggers.slack?.[0]?.paths, ['/slack/channels/C_REVIEW/**']); assert.equal(triggers.customProvider?.[0]?.on, 'custom.event'); }); diff --git a/packages/persona-kit/src/define.ts b/packages/persona-kit/src/define.ts index c98aab32..06672e6c 100644 --- a/packages/persona-kit/src/define.ts +++ b/packages/persona-kit/src/define.ts @@ -24,6 +24,7 @@ export interface TypedTrigger

{ on: TriggerNameFor

; match?: string; where?: string; + paths?: string[]; maxConcurrency?: number; } diff --git a/packages/persona-kit/src/emit-schema.test.ts b/packages/persona-kit/src/emit-schema.test.ts index 5081183f..b60210d2 100644 --- a/packages/persona-kit/src/emit-schema.test.ts +++ b/packages/persona-kit/src/emit-schema.test.ts @@ -160,6 +160,16 @@ test('agent schema exposes launchedBy/triggers/schedules/watch', async () => { ? watchRule.properties.paths.type : undefined, 'array'); const trigger = definitions.PersonaIntegrationTrigger; + const triggerPaths = trigger.properties?.paths; + assert.equal(triggerPaths && triggerPaths !== true + ? triggerPaths.type + : undefined, 'array'); + assert.equal(triggerPaths && triggerPaths !== true + ? triggerPaths.minItems + : undefined, 1); + assert.deepEqual(triggerPaths && triggerPaths !== true + ? triggerPaths.items + : undefined, { type: 'string', minLength: 1, pattern: '^/' }); const maxConcurrency = trigger.properties?.maxConcurrency; assert.equal(maxConcurrency && maxConcurrency !== true ? maxConcurrency.type @@ -168,7 +178,30 @@ test('agent schema exposes launchedBy/triggers/schedules/watch', async () => { ? maxConcurrency.minimum : undefined, 1); - assertSchema({ triggers: { slack: [{ on: 'message.created', maxConcurrency: 1 }] } }, schema, schema, 'agent'); + assertSchema( + { + triggers: { + slack: [ + { + on: 'message.created', + paths: ['/slack/channels/C_REVIEW/**'], + maxConcurrency: 1 + } + ] + } + }, + schema, + schema, + 'agent' + ); + assert.throws( + () => assertSchema({ triggers: { slack: [{ on: 'message.created', paths: [] }] } }, schema, schema, 'agent'), + /agent\.triggers\.slack\[0\]\.paths must have at least 1 item/ + ); + assert.throws( + () => assertSchema({ triggers: { slack: [{ on: 'message.created', paths: ['slack/**'] }] } }, schema, schema, 'agent'), + /agent\.triggers\.slack\[0\]\.paths\[0\] must match \^\// + ); assert.throws( () => assertSchema({ triggers: { slack: [{ on: 'message.created', maxConcurrency: 0 }] } }, schema, schema, 'agent'), /agent\.triggers\.slack\[0\]\.maxConcurrency must be >= 1/ @@ -190,6 +223,9 @@ type SchemaNode = Record & { const?: unknown; type?: string | string[]; minimum?: number; + minItems?: number; + minLength?: number; + pattern?: string; properties?: Record; additionalProperties?: SchemaNode | boolean; required?: string[]; @@ -232,6 +268,12 @@ function assertSchema(value: unknown, schema: SchemaNode, root: SchemaNode, path if (typeof schema.minimum === 'number' && typeof value === 'number' && value < schema.minimum) { throw new Error(`${path} must be >= ${schema.minimum}`); } + if (typeof schema.minLength === 'number' && typeof value === 'string' && value.length < schema.minLength) { + throw new Error(`${path} must have at least ${schema.minLength} character(s)`); + } + if (schema.pattern && typeof value === 'string' && !new RegExp(schema.pattern).test(value)) { + throw new Error(`${path} must match ${schema.pattern}`); + } if (schema.type === 'object' || schema.properties || schema.additionalProperties || schema.required) { if (!isObject(value)) { throw new Error(`${path} must be an object`); @@ -256,6 +298,9 @@ function assertSchema(value: unknown, schema: SchemaNode, root: SchemaNode, path if (!Array.isArray(value)) { throw new Error(`${path} must be an array`); } + if (typeof schema.minItems === 'number' && value.length < schema.minItems) { + throw new Error(`${path} must have at least ${schema.minItems} item(s)`); + } if (schema.items) { value.forEach((item, index) => assertSchema(item, schema.items as SchemaNode, root, `${path}[${index}]`) diff --git a/packages/persona-kit/src/parse.test.ts b/packages/persona-kit/src/parse.test.ts index e0decdde..cc586696 100644 --- a/packages/persona-kit/src/parse.test.ts +++ b/packages/persona-kit/src/parse.test.ts @@ -774,17 +774,26 @@ test('parseAgentSpec validates launchedBy plus provider-keyed triggers, schedule triggers: { github: [ { on: 'pull_request.opened' }, - { on: 'issue_comment.created', match: '@mention', maxConcurrency: 1 } + { + on: 'issue_comment.created', + match: '@mention', + paths: ['/github/repos/AgentWorkforce/workforce/issues/**'], + maxConcurrency: 1 + } ], - slack: [{ on: 'app_mention' }] + slack: [{ on: 'app_mention', paths: ['/slack/channels/C_REVIEW/**'] }] }, schedules: [{ name: 'nightly', cron: '0 2 * * *', tz: 'UTC' }], watch: [{ paths: ['/github/x.json'], events: ['created'] }] }); assert.equal(agent.triggers?.github.length, 2); assert.equal(agent.triggers?.github[1].match, '@mention'); + assert.deepEqual(agent.triggers?.github[1].paths, [ + '/github/repos/AgentWorkforce/workforce/issues/**' + ]); assert.equal(agent.triggers?.github[1].maxConcurrency, 1); assert.equal(agent.triggers?.slack[0].on, 'app_mention'); + assert.deepEqual(agent.triggers?.slack[0].paths, ['/slack/channels/C_REVIEW/**']); assert.equal(agent.schedules?.[0].name, 'nightly'); assert.equal(agent.watch?.[0].paths[0], '/github/x.json'); assert.equal(agent.launchedBy, 'team-dispatcher'); @@ -837,6 +846,22 @@ test('parseAgentSpec rejects malformed triggers maps with precise field paths', () => parseAgentSpec({ triggers: { github: [{ on: 'issue.opened', where: 1 }] } }), /triggers\.github\[0\]\.where must be a non-empty string if provided/ ); + assert.throws( + () => parseAgentSpec({ triggers: { github: [{ on: 'issue.opened', paths: '/*' }] } }), + /triggers\.github\[0\]\.paths must be a non-empty array if provided/ + ); + assert.throws( + () => parseAgentSpec({ triggers: { github: [{ on: 'issue.opened', paths: [] }] } }), + /triggers\.github\[0\]\.paths must be a non-empty array if provided/ + ); + assert.throws( + () => parseAgentSpec({ triggers: { github: [{ on: 'issue.opened', paths: [''] }] } }), + /triggers\.github\[0\]\.paths\[0\] must be a non-empty string/ + ); + assert.throws( + () => parseAgentSpec({ triggers: { github: [{ on: 'issue.opened', paths: ['github/**'] }] } }), + /triggers\.github\[0\]\.paths\[0\] must start with \// + ); // An empty agent (no listeners) parses to {}; the deploy CLI enforces "at least one". assert.deepEqual(parseAgentSpec({}), {}); }); diff --git a/packages/persona-kit/src/parse.ts b/packages/persona-kit/src/parse.ts index 66d56e41..897d209b 100644 --- a/packages/persona-kit/src/parse.ts +++ b/packages/persona-kit/src/parse.ts @@ -551,7 +551,7 @@ export function parseIntegrationTrigger( if (!isObject(value)) { throw new Error(`${context} must be an object`); } - const { on, match, where, maxConcurrency } = value; + const { on, match, where, paths, maxConcurrency } = value; if (typeof on !== 'string' || !on.trim()) { throw new Error(`${context}.on must be a non-empty string`); } @@ -561,6 +561,22 @@ export function parseIntegrationTrigger( if (where !== undefined && (typeof where !== 'string' || !where.trim())) { throw new Error(`${context}.where must be a non-empty string if provided`); } + let parsedPaths: string[] | undefined; + if (paths !== undefined) { + if (!Array.isArray(paths) || paths.length === 0) { + throw new Error(`${context}.paths must be a non-empty array if provided`); + } + parsedPaths = paths.map((path, pathIdx) => { + const pathContext = `${context}.paths[${pathIdx}]`; + if (typeof path !== 'string' || !path.trim()) { + throw new Error(`${pathContext} must be a non-empty string`); + } + if (!path.startsWith('/')) { + throw new Error(`${pathContext} must start with /`); + } + return path; + }); + } // Intentionally lenient: Cloud derives this as an optional backpressure hint, // so invalid values mean "unset" rather than a parse failure. const parsedMaxConcurrency = @@ -573,6 +589,7 @@ export function parseIntegrationTrigger( on, ...(typeof match === 'string' ? { match } : {}), ...(typeof where === 'string' ? { where } : {}), + ...(parsedPaths ? { paths: parsedPaths } : {}), ...(parsedMaxConcurrency !== undefined ? { maxConcurrency: parsedMaxConcurrency } : {}) diff --git a/packages/persona-kit/src/types.ts b/packages/persona-kit/src/types.ts index c773639f..e8d46550 100644 --- a/packages/persona-kit/src/types.ts +++ b/packages/persona-kit/src/types.ts @@ -194,12 +194,18 @@ export type McpServerSpec = * { on: "pull_request.opened" } * { on: "pull_request_review_comment.created", match: "@mention" } * { on: "check_run.completed", where: "conclusion=failure" } + * { on: "message.created", paths: ["/slack/channels/C_REVIEW/**"] } * { on: "message.created", maxConcurrency: 1 } */ export interface PersonaIntegrationTrigger { on: string; match?: string; where?: string; + /** + * Optional Relayfile path globs used to scope wake routing before an agent is + * provisioned. Paths are absolute (leading `/`) and provider-specific. + */ + paths?: string[]; /** * Optional delivery backpressure hint for this trigger. Positive integers are * preserved by the parser; invalid values are treated as unset so older or diff --git a/packages/runtime/src/define-agent.test.ts b/packages/runtime/src/define-agent.test.ts index 79f124f2..c4b10b42 100644 --- a/packages/runtime/src/define-agent.test.ts +++ b/packages/runtime/src/define-agent.test.ts @@ -11,8 +11,15 @@ test('defineAgent brands the object and wraps the handler', () => { const agent = defineAgent({ launchedBy: 'team-dispatcher', triggers: { - github: [{ on: 'pull_request.opened' }, { on: 'issue_comment.created', match: '@mention' }], - slack: [{ on: 'app_mention' }] + github: [ + { on: 'pull_request.opened' }, + { + on: 'issue_comment.created', + match: '@mention', + paths: ['/github/repos/AgentWorkforce/workforce/issues/**'] + } + ], + slack: [{ on: 'app_mention', paths: ['/slack/channels/C_REVIEW/**'] }] }, schedules: [{ name: 'nightly', cron: '0 2 * * *', tz: 'UTC' }], handler: async () => { @@ -23,7 +30,11 @@ test('defineAgent brands the object and wraps the handler', () => { assert.equal(isWorkforceAgent(agent), true); assert.equal(isWorkforceHandler(agent.handler), true); assert.equal(agent.triggers?.github?.length, 2); + assert.deepEqual(agent.triggers?.github?.[1]?.paths, [ + '/github/repos/AgentWorkforce/workforce/issues/**' + ]); assert.equal(agent.triggers?.slack?.[0]?.on, 'app_mention'); + assert.deepEqual(agent.triggers?.slack?.[0]?.paths, ['/slack/channels/C_REVIEW/**']); assert.equal(agent.schedules?.[0]?.name, 'nightly'); assert.equal(agent.launchedBy, 'team-dispatcher'); // __workforceAgent is non-enumerable so the listener declarations serialize clean. From 5d70acb68abd5b818e91772835cfefa81b9b2d04 Mon Sep 17 00:00:00 2001 From: Miya Date: Thu, 16 Jul 2026 20:19:39 +0200 Subject: [PATCH 2/4] refactor(persona-kit): share absolute path parsing --- packages/persona-kit/src/parse.test.ts | 4 +-- packages/persona-kit/src/parse.ts | 49 +++++++++++--------------- 2 files changed, 22 insertions(+), 31 deletions(-) diff --git a/packages/persona-kit/src/parse.test.ts b/packages/persona-kit/src/parse.test.ts index cc586696..8d64d6c2 100644 --- a/packages/persona-kit/src/parse.test.ts +++ b/packages/persona-kit/src/parse.test.ts @@ -848,11 +848,11 @@ test('parseAgentSpec rejects malformed triggers maps with precise field paths', ); assert.throws( () => parseAgentSpec({ triggers: { github: [{ on: 'issue.opened', paths: '/*' }] } }), - /triggers\.github\[0\]\.paths must be a non-empty array if provided/ + /triggers\.github\[0\]\.paths must be a non-empty array/ ); assert.throws( () => parseAgentSpec({ triggers: { github: [{ on: 'issue.opened', paths: [] }] } }), - /triggers\.github\[0\]\.paths must be a non-empty array if provided/ + /triggers\.github\[0\]\.paths must be a non-empty array/ ); assert.throws( () => parseAgentSpec({ triggers: { github: [{ on: 'issue.opened', paths: [''] }] } }), diff --git a/packages/persona-kit/src/parse.ts b/packages/persona-kit/src/parse.ts index 897d209b..6270d19a 100644 --- a/packages/persona-kit/src/parse.ts +++ b/packages/persona-kit/src/parse.ts @@ -544,6 +544,22 @@ function assertCronExpression(value: string, context: string): void { } } +function parseAbsolutePathList(value: unknown, context: string): string[] { + if (!Array.isArray(value) || value.length === 0) { + throw new Error(`${context} must be a non-empty array`); + } + return value.map((path, pathIdx) => { + const pathContext = `${context}[${pathIdx}]`; + if (typeof path !== 'string' || !path.trim()) { + throw new Error(`${pathContext} must be a non-empty string`); + } + if (!path.startsWith('/')) { + throw new Error(`${pathContext} must start with /`); + } + return path; + }); +} + export function parseIntegrationTrigger( value: unknown, context: string @@ -561,22 +577,9 @@ export function parseIntegrationTrigger( if (where !== undefined && (typeof where !== 'string' || !where.trim())) { throw new Error(`${context}.where must be a non-empty string if provided`); } - let parsedPaths: string[] | undefined; - if (paths !== undefined) { - if (!Array.isArray(paths) || paths.length === 0) { - throw new Error(`${context}.paths must be a non-empty array if provided`); - } - parsedPaths = paths.map((path, pathIdx) => { - const pathContext = `${context}.paths[${pathIdx}]`; - if (typeof path !== 'string' || !path.trim()) { - throw new Error(`${pathContext} must be a non-empty string`); - } - if (!path.startsWith('/')) { - throw new Error(`${pathContext} must start with /`); - } - return path; - }); - } + const parsedPaths = paths === undefined + ? undefined + : parseAbsolutePathList(paths, `${context}.paths`); // Intentionally lenient: Cloud derives this as an optional backpressure hint, // so invalid values mean "unset" rather than a parse failure. const parsedMaxConcurrency = @@ -820,19 +823,7 @@ export function parseWatch(value: unknown, context: string): WatchRule[] | undef throw new Error(`${entryContext} must be an object`); } const { paths, events, debounceMs, match } = entry; - if (!Array.isArray(paths) || paths.length === 0) { - throw new Error(`${entryContext}.paths must be a non-empty array`); - } - const parsedPaths = paths.map((path, pathIdx) => { - const pathContext = `${entryContext}.paths[${pathIdx}]`; - if (typeof path !== 'string' || !path.trim()) { - throw new Error(`${pathContext} must be a non-empty string`); - } - if (!path.startsWith('/')) { - throw new Error(`${pathContext} must start with /`); - } - return path; - }); + const parsedPaths = parseAbsolutePathList(paths, `${entryContext}.paths`); if (!Array.isArray(events) || events.length === 0) { throw new Error(`${entryContext}.events must be a non-empty array`); From da2b05ae74af6e5b7960ba5945889f39b420ea69 Mon Sep 17 00:00:00 2001 From: Miya Date: Thu, 16 Jul 2026 20:26:42 +0200 Subject: [PATCH 3/4] fix(persona-kit): reject padded trigger paths --- packages/persona-kit/schemas/agent.schema.json | 2 +- packages/persona-kit/scripts/emit-schema.mjs | 2 +- packages/persona-kit/src/emit-schema.test.ts | 8 ++++++-- packages/persona-kit/src/parse.test.ts | 5 +++++ packages/persona-kit/src/parse.ts | 3 +++ 5 files changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/persona-kit/schemas/agent.schema.json b/packages/persona-kit/schemas/agent.schema.json index f197bc9e..b44235bd 100644 --- a/packages/persona-kit/schemas/agent.schema.json +++ b/packages/persona-kit/schemas/agent.schema.json @@ -54,7 +54,7 @@ "items": { "type": "string", "minLength": 1, - "pattern": "^/" + "pattern": "^/(?:.*\\S)?$" }, "description": "Optional Relayfile path globs used to scope wake routing before an agent is provisioned. Paths are absolute (leading `/`) and provider-specific.", "minItems": 1 diff --git a/packages/persona-kit/scripts/emit-schema.mjs b/packages/persona-kit/scripts/emit-schema.mjs index 7d972e0c..18ffe908 100644 --- a/packages/persona-kit/scripts/emit-schema.mjs +++ b/packages/persona-kit/scripts/emit-schema.mjs @@ -157,7 +157,7 @@ if (agentTriggerPaths?.type === 'array') { agentTriggerPaths.items = { ...agentTriggerPaths.items, minLength: 1, - pattern: '^/' + pattern: '^/(?:.*\\S)?$' }; } } diff --git a/packages/persona-kit/src/emit-schema.test.ts b/packages/persona-kit/src/emit-schema.test.ts index b60210d2..b9ee4063 100644 --- a/packages/persona-kit/src/emit-schema.test.ts +++ b/packages/persona-kit/src/emit-schema.test.ts @@ -169,7 +169,7 @@ test('agent schema exposes launchedBy/triggers/schedules/watch', async () => { : undefined, 1); assert.deepEqual(triggerPaths && triggerPaths !== true ? triggerPaths.items - : undefined, { type: 'string', minLength: 1, pattern: '^/' }); + : undefined, { type: 'string', minLength: 1, pattern: '^/(?:.*\\S)?$' }); const maxConcurrency = trigger.properties?.maxConcurrency; assert.equal(maxConcurrency && maxConcurrency !== true ? maxConcurrency.type @@ -200,7 +200,11 @@ test('agent schema exposes launchedBy/triggers/schedules/watch', async () => { ); assert.throws( () => assertSchema({ triggers: { slack: [{ on: 'message.created', paths: ['slack/**'] }] } }, schema, schema, 'agent'), - /agent\.triggers\.slack\[0\]\.paths\[0\] must match \^\// + /agent\.triggers\.slack\[0\]\.paths\[0\] must match/ + ); + assert.throws( + () => assertSchema({ triggers: { slack: [{ on: 'message.created', paths: ['/slack/** '] }] } }, schema, schema, 'agent'), + /agent\.triggers\.slack\[0\]\.paths\[0\] must match/ ); assert.throws( () => assertSchema({ triggers: { slack: [{ on: 'message.created', maxConcurrency: 0 }] } }, schema, schema, 'agent'), diff --git a/packages/persona-kit/src/parse.test.ts b/packages/persona-kit/src/parse.test.ts index 8d64d6c2..0d68ac6c 100644 --- a/packages/persona-kit/src/parse.test.ts +++ b/packages/persona-kit/src/parse.test.ts @@ -858,6 +858,10 @@ test('parseAgentSpec rejects malformed triggers maps with precise field paths', () => parseAgentSpec({ triggers: { github: [{ on: 'issue.opened', paths: [''] }] } }), /triggers\.github\[0\]\.paths\[0\] must be a non-empty string/ ); + assert.throws( + () => parseAgentSpec({ triggers: { github: [{ on: 'issue.opened', paths: ['/github/** '] }] } }), + /triggers\.github\[0\]\.paths\[0\] must not have leading or trailing whitespace/ + ); assert.throws( () => parseAgentSpec({ triggers: { github: [{ on: 'issue.opened', paths: ['github/**'] }] } }), /triggers\.github\[0\]\.paths\[0\] must start with \// @@ -1063,6 +1067,7 @@ test('parseWatch rejects malformed relayfile watch rules with precise field path assert.throws(() => parseWatch([{ events: ['created'] }], 'watch'), /watch\[0\]\.paths must be a non-empty array/); assert.throws(() => parseWatch([{ paths: [], events: ['created'] }], 'watch'), /watch\[0\]\.paths must be a non-empty array/); assert.throws(() => parseWatch([{ paths: [42], events: ['created'] }], 'watch'), /watch\[0\]\.paths\[0\] must be a non-empty string/); + assert.throws(() => parseWatch([{ paths: ['/x '], events: ['created'] }], 'watch'), /watch\[0\]\.paths\[0\] must not have leading or trailing whitespace/); assert.throws(() => parseWatch([{ paths: ['relative/**'], events: ['created'] }], 'watch'), /watch\[0\]\.paths\[0\] must start with \//); assert.throws(() => parseWatch([{ paths: ['/x'] }], 'watch'), /watch\[0\]\.events must be a non-empty array/); assert.throws(() => parseWatch([{ paths: ['/x'], events: [] }], 'watch'), /watch\[0\]\.events must be a non-empty array/); diff --git a/packages/persona-kit/src/parse.ts b/packages/persona-kit/src/parse.ts index 6270d19a..d2dba67d 100644 --- a/packages/persona-kit/src/parse.ts +++ b/packages/persona-kit/src/parse.ts @@ -553,6 +553,9 @@ function parseAbsolutePathList(value: unknown, context: string): string[] { if (typeof path !== 'string' || !path.trim()) { throw new Error(`${pathContext} must be a non-empty string`); } + if (path !== path.trim()) { + throw new Error(`${pathContext} must not have leading or trailing whitespace`); + } if (!path.startsWith('/')) { throw new Error(`${pathContext} must start with /`); } From 2a3902eaad2bb407a01b778f68c2f1f917cb952a Mon Sep 17 00:00:00 2001 From: Miya Date: Thu, 16 Jul 2026 20:43:19 +0200 Subject: [PATCH 4/4] fix(persona-kit): align path validation schemas --- .../persona-kit/schemas/agent.schema.json | 9 ++-- packages/persona-kit/scripts/emit-schema.mjs | 27 +++++----- packages/persona-kit/src/emit-schema.test.ts | 49 +++++++++++++++++-- packages/persona-kit/src/parse.test.ts | 22 +++++++++ packages/persona-kit/src/parse.ts | 3 ++ 5 files changed, 91 insertions(+), 19 deletions(-) diff --git a/packages/persona-kit/schemas/agent.schema.json b/packages/persona-kit/schemas/agent.schema.json index b44235bd..af6771ba 100644 --- a/packages/persona-kit/schemas/agent.schema.json +++ b/packages/persona-kit/schemas/agent.schema.json @@ -54,7 +54,7 @@ "items": { "type": "string", "minLength": 1, - "pattern": "^/(?:.*\\S)?$" + "pattern": "^/(?:[^\\r\\n\\u2028\\u2029]*\\S)?$" }, "description": "Optional Relayfile path globs used to scope wake routing before an agent is provisioned. Paths are absolute (leading `/`) and provider-specific.", "minItems": 1 @@ -95,8 +95,11 @@ "paths": { "type": "array", "items": { - "type": "string" - } + "type": "string", + "minLength": 1, + "pattern": "^/(?:[^\\r\\n\\u2028\\u2029]*\\S)?$" + }, + "minItems": 1 }, "events": { "type": "array", diff --git a/packages/persona-kit/scripts/emit-schema.mjs b/packages/persona-kit/scripts/emit-schema.mjs index 18ffe908..aa324c7d 100644 --- a/packages/persona-kit/scripts/emit-schema.mjs +++ b/packages/persona-kit/scripts/emit-schema.mjs @@ -146,22 +146,23 @@ if ( }; } -const agentTriggerPaths = agentTrigger?.properties?.paths; -if (agentTriggerPaths?.type === 'array') { - agentTriggerPaths.minItems = 1; - if ( - agentTriggerPaths.items && - typeof agentTriggerPaths.items === 'object' && - !Array.isArray(agentTriggerPaths.items) - ) { - agentTriggerPaths.items = { - ...agentTriggerPaths.items, - minLength: 1, - pattern: '^/(?:.*\\S)?$' - }; +const absolutePathArrayConstraint = { + minItems: 1, + items: { + type: 'string', + minLength: 1, + pattern: '^/(?:[^\\r\\n\\u2028\\u2029]*\\S)?$' } +}; + +function applyAbsolutePathArrayConstraint(pathsSchema) { + if (pathsSchema?.type !== 'array') return; + Object.assign(pathsSchema, absolutePathArrayConstraint); } +applyAbsolutePathArrayConstraint(agentTrigger?.properties?.paths); +applyAbsolutePathArrayConstraint(agentSchema.definitions?.WatchRule?.properties?.paths); + const agentSerialized = `${JSON.stringify(agentSchema, null, 2)}\n`; let existingAgent = ''; try { diff --git a/packages/persona-kit/src/emit-schema.test.ts b/packages/persona-kit/src/emit-schema.test.ts index b9ee4063..8774d4b2 100644 --- a/packages/persona-kit/src/emit-schema.test.ts +++ b/packages/persona-kit/src/emit-schema.test.ts @@ -159,6 +159,18 @@ test('agent schema exposes launchedBy/triggers/schedules/watch', async () => { assert.equal(watchRule.properties?.paths && watchRule.properties.paths !== true ? watchRule.properties.paths.type : undefined, 'array'); + const absolutePathItems = { + type: 'string', + minLength: 1, + pattern: '^/(?:[^\\r\\n\\u2028\\u2029]*\\S)?$' + }; + const watchPaths = watchRule.properties?.paths; + assert.equal(watchPaths && watchPaths !== true + ? watchPaths.minItems + : undefined, 1); + assert.deepEqual(watchPaths && watchPaths !== true + ? watchPaths.items + : undefined, absolutePathItems); const trigger = definitions.PersonaIntegrationTrigger; const triggerPaths = trigger.properties?.paths; assert.equal(triggerPaths && triggerPaths !== true @@ -169,7 +181,7 @@ test('agent schema exposes launchedBy/triggers/schedules/watch', async () => { : undefined, 1); assert.deepEqual(triggerPaths && triggerPaths !== true ? triggerPaths.items - : undefined, { type: 'string', minLength: 1, pattern: '^/(?:.*\\S)?$' }); + : undefined, absolutePathItems); const maxConcurrency = trigger.properties?.maxConcurrency; assert.equal(maxConcurrency && maxConcurrency !== true ? maxConcurrency.type @@ -184,11 +196,12 @@ test('agent schema exposes launchedBy/triggers/schedules/watch', async () => { slack: [ { on: 'message.created', - paths: ['/slack/channels/C_REVIEW/**'], + paths: ['/ slack/channels/C_REVIEW/**'], maxConcurrency: 1 } ] - } + }, + watch: [{ paths: ['/ github/**'], events: ['created'] }] }, schema, schema, @@ -206,6 +219,36 @@ test('agent schema exposes launchedBy/triggers/schedules/watch', async () => { () => assertSchema({ triggers: { slack: [{ on: 'message.created', paths: ['/slack/** '] }] } }, schema, schema, 'agent'), /agent\.triggers\.slack\[0\]\.paths\[0\] must match/ ); + assert.throws( + () => assertSchema({ watch: [{ paths: [], events: ['created'] }] }, schema, schema, 'agent'), + /agent\.watch\[0\]\.paths must have at least 1 item/ + ); + assert.throws( + () => assertSchema({ watch: [{ paths: ['watch/**'], events: ['created'] }] }, schema, schema, 'agent'), + /agent\.watch\[0\]\.paths\[0\] must match/ + ); + assert.throws( + () => assertSchema({ watch: [{ paths: [' /watch/**'], events: ['created'] }] }, schema, schema, 'agent'), + /agent\.watch\[0\]\.paths\[0\] must match/ + ); + assert.throws( + () => assertSchema({ watch: [{ paths: ['/watch/** '], events: ['created'] }] }, schema, schema, 'agent'), + /agent\.watch\[0\]\.paths\[0\] must match/ + ); + for (const separator of ['\r', '\n', '\u2028', '\u2029']) { + assert.throws( + () => assertSchema({ + triggers: { slack: [{ on: 'message.created', paths: [`/slack/${separator}channels/**`] }] } + }, schema, schema, 'agent'), + /agent\.triggers\.slack\[0\]\.paths\[0\] must match/ + ); + assert.throws( + () => assertSchema({ + watch: [{ paths: [`/github/${separator}issues/**`], events: ['created'] }] + }, schema, schema, 'agent'), + /agent\.watch\[0\]\.paths\[0\] must match/ + ); + } assert.throws( () => assertSchema({ triggers: { slack: [{ on: 'message.created', maxConcurrency: 0 }] } }, schema, schema, 'agent'), /agent\.triggers\.slack\[0\]\.maxConcurrency must be >= 1/ diff --git a/packages/persona-kit/src/parse.test.ts b/packages/persona-kit/src/parse.test.ts index 0d68ac6c..13897f93 100644 --- a/packages/persona-kit/src/parse.test.ts +++ b/packages/persona-kit/src/parse.test.ts @@ -866,6 +866,18 @@ test('parseAgentSpec rejects malformed triggers maps with precise field paths', () => parseAgentSpec({ triggers: { github: [{ on: 'issue.opened', paths: ['github/**'] }] } }), /triggers\.github\[0\]\.paths\[0\] must start with \// ); + const triggerWithInternalSpace = parseAgentSpec({ + triggers: { github: [{ on: 'issue.opened', paths: ['/ github/**'] }] } + }); + assert.deepEqual(triggerWithInternalSpace.triggers?.github[0].paths, ['/ github/**']); + for (const separator of ['\r', '\n', '\u2028', '\u2029']) { + assert.throws( + () => parseAgentSpec({ + triggers: { github: [{ on: 'issue.opened', paths: [`/github/${separator}issues/**`] }] } + }), + /triggers\.github\[0\]\.paths\[0\] must not contain line separators/ + ); + } // An empty agent (no listeners) parses to {}; the deploy CLI enforces "at least one". assert.deepEqual(parseAgentSpec({}), {}); }); @@ -1074,6 +1086,16 @@ test('parseWatch rejects malformed relayfile watch rules with precise field path assert.throws(() => parseWatch([{ paths: ['/x'], events: ['changed'] }], 'watch'), /watch\[0\]\.events\[0\] must be one of: created, updated, deleted/); assert.throws(() => parseWatch([{ paths: ['/x'], events: ['created'], debounceMs: -1 }], 'watch'), /watch\[0\]\.debounceMs must be a non-negative number/); assert.throws(() => parseWatch([{ paths: ['/x'], events: ['created'], match: '' }], 'watch'), /watch\[0\]\.match must be a non-empty string/); + assert.deepEqual( + parseWatch([{ paths: ['/ github/**'], events: ['created'] }], 'watch')?.[0].paths, + ['/ github/**'] + ); + for (const separator of ['\r', '\n', '\u2028', '\u2029']) { + assert.throws( + () => parseWatch([{ paths: [`/github/${separator}issues/**`], events: ['created'] }], 'watch'), + /watch\[0\]\.paths\[0\] must not contain line separators/ + ); + } }); test('parsePersonaSpec round-trips mount.enabled=false (watch now lives on the agent)', () => { diff --git a/packages/persona-kit/src/parse.ts b/packages/persona-kit/src/parse.ts index d2dba67d..112af8bd 100644 --- a/packages/persona-kit/src/parse.ts +++ b/packages/persona-kit/src/parse.ts @@ -556,6 +556,9 @@ function parseAbsolutePathList(value: unknown, context: string): string[] { if (path !== path.trim()) { throw new Error(`${pathContext} must not have leading or trailing whitespace`); } + if (/[\r\n\u2028\u2029]/u.test(path)) { + throw new Error(`${pathContext} must not contain line separators`); + } if (!path.startsWith('/')) { throw new Error(`${pathContext} must start with /`); }