diff --git a/packages/cli/src/invoke-command.test.ts b/packages/cli/src/invoke-command.test.ts index 13f70fa9..5f3f7bf6 100644 --- a/packages/cli/src/invoke-command.test.ts +++ b/packages/cli/src/invoke-command.test.ts @@ -333,7 +333,7 @@ test('runInvoke: watch reruns preserve simulated provider receipts for later thr const relayfileDir = path.join(dir, 'node_modules', '@relayfile'); await mkdir(relayfileDir, { recursive: true }); await symlink( - path.resolve(process.cwd(), '..', 'delivery', 'node_modules', '@relayfile', 'relay-helpers'), + path.resolve(process.cwd(), '..', 'runtime', 'node_modules', '@relayfile', 'relay-helpers'), path.join(relayfileDir, 'relay-helpers') ); @@ -360,6 +360,14 @@ test('runInvoke: watch reruns preserve simulated provider receipts for later thr const body = data?.body as Record | undefined; assert.equal(body?.parentRef, '/slack/channels/C123/messages/preview-slack-messages-0001.json'); assert.equal(body?.thread_ts, 'preview-slack-messages-0001'); + const human = io.err.join(''); + assert.match(human, /slack message: parent/); + assert.match(human, /slack message: reply/); + assert.match(human, /channel: C123/); + assert.match(human, /text \(exact\): "header"/); + assert.match(human, /text \(exact\): "reply"/); + assert.match(human, /parentRef=\/slack\/channels\/C123\/messages\/preview-slack-messages-0001\.json/); + assert.match(human, /thread_ts=preview-slack-messages-0001/); }); }); @@ -413,6 +421,10 @@ expect: assert.equal(result.status, 'succeeded'); assert.ok(result.actions.some((action) => action.kind === 'http.read')); assert.ok(result.actions.some((action) => action.kind === 'model.complete')); + const human = io.err.join(''); + assert.match(human, /\[ALLOW\] http\.read GET https:\/\/example\.test\/front-page fidelity=fixture/); + assert.match(human, /model\.complete mode=stub source=simulated fidelity=simulated/); + assert.match(human, /state changes:\n\s+memory:/); }); }); @@ -805,7 +817,7 @@ test('runInvoke: preview transport binds before import and blocks ambient Slack const relayfileDir = path.join(dir, 'node_modules', '@relayfile'); await mkdir(relayfileDir, { recursive: true }); await symlink( - path.resolve(process.cwd(), '..', 'delivery', 'node_modules', '@relayfile', 'relay-helpers'), + path.resolve(process.cwd(), '..', 'runtime', 'node_modules', '@relayfile', 'relay-helpers'), path.join(relayfileDir, 'relay-helpers') ); const io = collectingIO(); @@ -821,6 +833,440 @@ test('runInvoke: preview transport binds before import and blocks ambient Slack } }); +test('runInvoke: writes deny resists authored authorizer rebinding without receipts or state mutation', async () => { + let sentinelHits = 0; + const sentinel = createServer((_req, res) => { + sentinelHits += 1; + res.statusCode = 500; + res.end('write escaped preview'); + }); + await new Promise((resolve) => sentinel.listen(0, '127.0.0.1', () => resolve())); + const address = sentinel.address(); + assert.ok(address && typeof address === 'object'); + + const env = { + RELAYFILE_TOKEN: process.env.RELAYFILE_TOKEN, + RELAYFILE_URL: process.env.RELAYFILE_URL, + RELAYFILE_WORKSPACE_ID: process.env.RELAYFILE_WORKSPACE_ID, + RELAY_API_KEY: process.env.RELAY_API_KEY, + SLACK_BOT_TOKEN: process.env.SLACK_BOT_TOKEN, + CLOUD_API_ACCESS_TOKEN: process.env.CLOUD_API_ACCESS_TOKEN + }; + process.env.RELAYFILE_TOKEN = 'rf_live_51N7INEL_PRODUCTION_SHAPED'; + process.env.RELAYFILE_URL = `http://127.0.0.1:${address.port}`; + process.env.RELAYFILE_WORKSPACE_ID = 'rf_ws_production'; + process.env.RELAY_API_KEY = 'rk_live_51N7INEL_PRODUCTION_SHAPED'; + process.env.SLACK_BOT_TOKEN = 'xoxb-123456789012-123456789012-SENTINEL'; + process.env.CLOUD_API_ACCESS_TOKEN = 'wft_live_51N7INEL_PRODUCTION_SHAPED'; + + try { + await withAgent({ + 'agent.ts': ` + import { defineAgent } from '@agentworkforce/runtime'; + import { + PreviewTransport, + bindRelayWriteAuthorizer, + slackClient + } from '@relayfile/relay-helpers'; + export default defineAgent({ + schedules: [{ name: 'scan', cron: '0 9 * * *' }], + handler: async (ctx) => { + const explicitTransport = new PreviewTransport(); + let customTransportWrites = 0; + const customTransport = { + async read() { return undefined; }, + async list() { return []; }, + async write() { + customTransportWrites += 1; + await fetch('http://127.0.0.1:${address.port}/custom-transport', { + method: 'POST', + body: 'custom transport write escaped' + }); + return { path: '/escaped', absolutePath: '/escaped' }; + } + }; + let authoredAuthorizerBound = false; + if (typeof bindRelayWriteAuthorizer !== 'function') { + throw new Error('public Relay write authorizer binder is missing'); + } + let authoredBindingOutcome = 'returned'; + let restoreAuthoredAuthorizer = () => {}; + try { + restoreAuthoredAuthorizer = bindRelayWriteAuthorizer(() => { + authoredAuthorizerBound = true; + return { allowed: true, transport: customTransport }; + }); + } catch (error) { + authoredBindingOutcome = String(error?.code ?? error?.name ?? error); + } + const legacyAuthorizerKey = Symbol.for('agentworkforce.relay-write-authorizer'); + const legacyRegistry = globalThis as Record; + let legacyOverwriteAttempted = false; + let legacyDeleteAttempted = false; + try { + legacyOverwriteAttempted = true; + legacyRegistry[legacyAuthorizerKey] = () => ({ allowed: true, transport: customTransport }); + } catch {} + try { + legacyDeleteAttempted = true; + delete legacyRegistry[legacyAuthorizerKey]; + } catch {} + const outcomes = await Promise.allSettled([ + slackClient().post('C123', 'Provider sentinel must be denied'), + slackClient({ transport: explicitTransport }).post( + 'C124', + 'Explicit xoxb-123456789012-123456789012-SENTINEL must be denied' + ), + slackClient({ transport: customTransport }).post('C125', 'Custom transport must be denied'), + ctx.relay.post('general', 'Relay sentinel must be denied'), + ctx.files.write('/preview/files.txt', 'Files sentinel must be denied'), + ctx.sandbox.writeFile('/preview/sandbox.txt', 'Sandbox sentinel must be denied'), + ctx.memory.save('Memory sentinel must be denied'), + ctx.schedule.at(new Date('2026-07-16T12:00:00.000Z'), { denied: true }), + ctx.schedule.cancel('denied-schedule') + ]); + restoreAuthoredAuthorizer(); + ctx.log('info', 'transport.audit', { + authoredAuthorizerBound, + authoredBindingOutcome, + binderType: typeof bindRelayWriteAuthorizer, + explicitActions: explicitTransport.actions.length, + customTransportWrites, + legacyDeleteAttempted, + legacyOverwriteAttempted, + rejected: outcomes.filter((outcome) => outcome.status === 'rejected').length + }); + } + }); + `, + 'writes-deny.case.yaml': ` +schemaVersion: 1 +id: safety.authored-writes-deny +kind: scheduled +event: + schedule: scan +policy: + reads: fixtures + writes: deny + model: stub + shell: simulate + compose: preview +expect: + status: failed + effectsContain: + - provider.write + - files.write + - memory.save + - schedule.at + - schedule.cancel +` + }, async (dir, agentPath) => { + const relayfileDir = path.join(dir, 'node_modules', '@relayfile'); + await mkdir(relayfileDir, { recursive: true }); + await symlink( + path.resolve(process.cwd(), '..', 'runtime', 'node_modules', '@relayfile', 'relay-helpers'), + path.join(relayfileDir, 'relay-helpers') + ); + + const io = collectingIO(); + const previousExitCode = process.exitCode; + const result = await runInvoke([ + agentPath, + '--case', + path.join(dir, 'writes-deny.case.yaml') + ], io); + const exitCode = process.exitCode; + process.exitCode = previousExitCode; + + assert.ok(result && !Array.isArray(result), io.err.join('')); + assert.equal(exitCode, 1); + assert.equal(result.status, 'failed'); + assert.equal(result.policy.writes, 'deny'); + assert.equal(sentinelHits, 0); + const deniedWrites = result.actions.filter((action) => action.status === 'denied'); + assert.equal(deniedWrites.length, 9); + assert.equal(deniedWrites.filter((action) => action.kind === 'provider.write').length, 4); + assert.equal( + deniedWrites.filter((action) => + action.kind === 'provider.write' && action.provider === 'slack' + ).length, + 3 + ); + assert.equal( + result.actions.some((action) => + action.status === 'previewed' && [ + 'provider.write', + 'files.write', + 'memory.save', + 'schedule.at', + 'schedule.cancel' + ].includes(action.kind) + ), + false + ); + assert.deepEqual(result.stateDiff.files, []); + assert.deepEqual(result.stateDiff.memory, []); + assert.doesNotMatch(JSON.stringify(result), /simulatedReceipt/); + assert.doesNotMatch(JSON.stringify(result), /51N7INEL|xoxb-123456789012/); + const logs = ((result.extensions as Record).logs ?? []) as string[]; + assert.ok(logs.some((line) => + line.includes('transport.audit') + && line.includes('"authoredAuthorizerBound":false') + && line.includes('"authoredBindingOutcome":"returned"') + && line.includes('"binderType":"function"') + && line.includes('"explicitActions":0') + && line.includes('"customTransportWrites":0') + && line.includes('"legacyDeleteAttempted":true') + && line.includes('"legacyOverwriteAttempted":true') + && line.includes('"rejected":9') + )); + }); + } finally { + sentinel.close(); + process.env.RELAYFILE_TOKEN = env.RELAYFILE_TOKEN; + process.env.RELAYFILE_URL = env.RELAYFILE_URL; + process.env.RELAYFILE_WORKSPACE_ID = env.RELAYFILE_WORKSPACE_ID; + process.env.RELAY_API_KEY = env.RELAY_API_KEY; + process.env.SLACK_BOT_TOKEN = env.SLACK_BOT_TOKEN; + process.env.CLOUD_API_ACCESS_TOKEN = env.CLOUD_API_ACCESS_TOKEN; + } +}); + +test('runInvoke: preview writes and authored authorizer rebinding cannot replace the canonical recorder', async () => { + let sentinelHits = 0; + const sentinel = createServer((_req, res) => { + sentinelHits += 1; + res.statusCode = 500; + res.end('custom transport write escaped'); + }); + await new Promise((resolve) => sentinel.listen(0, '127.0.0.1', () => resolve())); + const address = sentinel.address(); + assert.ok(address && typeof address === 'object'); + + try { + await withAgent({ + 'agent.ts': ` + import { defineAgent } from '@agentworkforce/runtime'; + import { slackClient } from '@relayfile/relay-helpers'; + import { + PreviewTransport, + bindRelayWriteAuthorizer + } from '@relayfile/relay-helpers/transport'; + export default defineAgent({ + schedules: [{ name: 'scan', cron: '0 9 * * *' }], + handler: async (ctx) => { + const explicitTransport = new PreviewTransport(); + let customTransportWrites = 0; + const customTransport = { + async read() { return undefined; }, + async list() { return []; }, + async write() { + customTransportWrites += 1; + await fetch('http://127.0.0.1:${address.port}/custom-preview', { + method: 'POST', + body: 'escaped' + }); + return { path: '/escaped', absolutePath: '/escaped' }; + } + }; + let authoredAuthorizerCalled = false; + if (typeof bindRelayWriteAuthorizer !== 'function') { + throw new Error('public Relay write authorizer binder is missing'); + } + let authoredBindingOutcome = 'returned'; + let restoreAuthoredAuthorizer = () => {}; + try { + restoreAuthoredAuthorizer = bindRelayWriteAuthorizer(() => { + authoredAuthorizerCalled = true; + return { allowed: true, transport: customTransport }; + }); + } catch (error) { + authoredBindingOutcome = String(error?.code ?? error?.name ?? error); + } + const legacyAuthorizerKey = Symbol.for('agentworkforce.relay-write-authorizer'); + const legacyRegistry = globalThis as Record; + let legacyOverwriteAttempted = false; + let legacyDeleteAttempted = false; + try { + legacyOverwriteAttempted = true; + legacyRegistry[legacyAuthorizerKey] = () => ({ allowed: true, transport: customTransport }); + } catch {} + try { + legacyDeleteAttempted = true; + delete legacyRegistry[legacyAuthorizerKey]; + } catch {} + const explicitReceipt = await slackClient({ transport: explicitTransport }).post( + 'C124', + 'Canonical explicit preview' + ); + const customReceipt = await slackClient({ transport: customTransport }).post( + 'C125', + 'Canonical custom preview' + ); + restoreAuthoredAuthorizer(); + ctx.log('info', 'transport.preview.audit', { + authoredAuthorizerCalled, + authoredBindingOutcome, + binderType: typeof bindRelayWriteAuthorizer, + explicitActions: explicitTransport.actions.length, + customTransportWrites, + legacyDeleteAttempted, + legacyOverwriteAttempted, + explicitReceipt: explicitReceipt.ref, + customReceipt: customReceipt.ref + }); + } + }); + ` + }, async (dir, agentPath) => { + const relayfileDir = path.join(dir, 'node_modules', '@relayfile'); + await mkdir(relayfileDir, { recursive: true }); + await symlink( + path.resolve(process.cwd(), '..', 'runtime', 'node_modules', '@relayfile', 'relay-helpers'), + path.join(relayfileDir, 'relay-helpers') + ); + + const io = collectingIO(); + const previousExitCode = process.exitCode; + const result = await runInvoke([agentPath, '--schedule', 'scan'], io); + const exitCode = process.exitCode; + process.exitCode = previousExitCode; + + assert.ok(result && !Array.isArray(result), io.err.join('')); + assert.equal(exitCode, 0); + assert.equal(result.status, 'succeeded'); + assert.equal(sentinelHits, 0); + const writes = result.actions.filter((action) => action.kind === 'provider.write'); + assert.equal(writes.length, 2); + assert.ok(writes.every((action) => action.status === 'previewed')); + assert.ok(writes.every((action) => action.data?.simulatedReceipt)); + const logs = ((result.extensions as Record).logs ?? []) as string[]; + assert.ok(logs.some((line) => + line.includes('transport.preview.audit') + && line.includes('"authoredBindingOutcome":"returned"') + && line.includes('"binderType":"function"') + && line.includes('"explicitActions":0') + && line.includes('"customTransportWrites":0') + && line.includes('"legacyDeleteAttempted":true') + && line.includes('"legacyOverwriteAttempted":true') + && line.includes('preview-slack-messages-0001') + && line.includes('preview-slack-messages-0002') + )); + }); + } finally { + sentinel.close(); + } +}); + +test('runInvoke: one provider read produces one action and one trace span', async () => { + await withAgent({ + 'agent.ts': ` + import { defineAgent } from '@agentworkforce/runtime'; + import { slackClient } from '@relayfile/relay-helpers'; + export default defineAgent({ + schedules: [{ name: 'scan', cron: '0 9 * * *' }], + handler: async () => { + await slackClient().messages.list({ channelId: 'C123' }); + } + }); + ` + }, async (dir, agentPath) => { + const relayfileDir = path.join(dir, 'node_modules', '@relayfile'); + await mkdir(relayfileDir, { recursive: true }); + await symlink( + path.resolve(process.cwd(), '..', 'runtime', 'node_modules', '@relayfile', 'relay-helpers'), + path.join(relayfileDir, 'relay-helpers') + ); + + const io = collectingIO(); + const previousExitCode = process.exitCode; + const result = await runInvoke([agentPath, '--schedule', 'scan'], io); + const exitCode = process.exitCode; + process.exitCode = previousExitCode; + + assert.ok(result && !Array.isArray(result), io.err.join('')); + assert.equal(exitCode, 0); + assert.equal(result.actions.filter((action) => action.kind === 'provider.read').length, 1); + assert.equal(result.trace.filter((span) => span.kind === 'provider.read').length, 1); + }); +}); + +test('runInvoke: real handler preserves one monotonic interleaved effect stream', async () => { + await withAgent({ + 'agent.ts': ` + import { defineAgent } from '@agentworkforce/runtime'; + import { slackClient } from '@relayfile/relay-helpers'; + export default defineAgent({ + schedules: [{ name: 'scan', cron: '0 9 * * *' }], + handler: async (ctx) => { + const slack = slackClient(); + await slack.messages.list({ channelId: 'C123' }); + await ctx.files.write('/preview/interleaved.json', '{"step":1}'); + await fetch('https://example.test/interleaved'); + await ctx.llm.complete('interleaved model call'); + const parent = await slack.post('C123', 'Interleaved parent'); + await ctx.memory.save('interleaved memory'); + await slack.post('C123', 'Interleaved reply', { replyTo: parent.ref }); + } + }); + `, + 'case.yaml': ` +schemaVersion: 1 +id: test.monotonic-effects +kind: scheduled +event: + schedule: scan +policy: + reads: fixtures + writes: preview + model: stub +http: + - method: GET + match: interleaved + file: ./fixture.json +expect: + status: succeeded +`, + 'fixture.json': '{"ok":true}\n' + }, async (dir, agentPath) => { + const relayfileDir = path.join(dir, 'node_modules', '@relayfile'); + await mkdir(relayfileDir, { recursive: true }); + await symlink( + path.resolve(process.cwd(), '..', 'runtime', 'node_modules', '@relayfile', 'relay-helpers'), + path.join(relayfileDir, 'relay-helpers') + ); + + const io = collectingIO(); + const previousExitCode = process.exitCode; + const result = await runInvoke([agentPath, '--case', path.join(dir, 'case.yaml')], io); + const exitCode = process.exitCode; + process.exitCode = previousExitCode; + + assert.ok(result && !Array.isArray(result), io.err.join('')); + assert.equal(exitCode, 0); + assert.deepEqual(result.actions.map((action) => action.kind), [ + 'provider.read', + 'files.write', + 'http.read', + 'model.complete', + 'provider.write', + 'memory.save', + 'provider.write' + ]); + assert.deepEqual(result.trace.slice(1, -1).map((span) => span.kind), result.actions.map((action) => action.kind)); + assert.equal(result.actions.filter((action) => action.kind === 'provider.read').length, 1); + assert.equal(result.trace.filter((span) => span.kind === 'provider.read').length, 1); + assert.equal(result.actions[4]?.resource, 'messages'); + assert.equal(result.actions[6]?.resource, 'messages'); + + const human = io.err.join(''); + const numberedKinds = [...human.matchAll(/\d{2}\. \[[^\]]+\] ([a-z.]+)/gu)].map((match) => match[1]); + assert.deepEqual(numberedKinds, result.actions.map((action) => action.kind)); + assert.match(human, /text \(exact\): "Interleaved parent"/); + assert.match(human, /text \(exact\): "Interleaved reply"/); + }); +}); + test('runInvoke: live GET is allowed while POST is denied and never reaches the sentinel', async () => { let getHits = 0; let postHits = 0; @@ -1667,7 +2113,7 @@ test('runInvoke: replay bundle preserves provenance and unavailable state fideli }); }); -test('renderHumanSummary: renders single or multi-record previews', () => { +test('renderHumanSummary: renders ordered read, model, state, and exact Slack preview details', () => { const record: RunRecordV2 = { runId: 'run_1', status: 'succeeded', @@ -1684,11 +2130,77 @@ test('renderHumanSummary: renders single or multi-record previews', () => { eventId: 'evt_1', eventContract: 'cron.tick@1', trace: [], - actions: [], + actions: [ + { + kind: 'provider.read', + status: 'previewed', + provider: 'slack', + resource: 'messages', + data: { method: 'list', path: '/slack/channels/C123/messages', parameters: { channelId: 'C123' } } + }, + { + kind: 'model.complete', + status: 'previewed', + data: { mode: 'stub', source: 'simulated', promptChars: 10, outputChars: 20 }, + extensions: { sourceFidelity: 'simulated' } + }, + { + kind: 'provider.write', + status: 'previewed', + provider: 'slack', + resource: 'messages', + data: { + method: 'write', + path: '/slack/channels/C123/messages/preview-parent.json', + parameters: { channelId: 'C123' }, + body: { text: 'Exact parent\nwith second line' }, + simulatedReceipt: { id: 'preview-parent', timestamp: '2000-01-01T00:00:00.000Z' } + } + }, + { + kind: 'provider.write', + status: 'previewed', + provider: 'slack', + resource: 'replies', + data: { + method: 'write', + path: '/slack/channels/C123/messages/preview-parent/replies/preview-reply.json', + parameters: { channelId: 'C123', messageTs: 'preview-parent' }, + body: { + text: 'Exact threaded reply', + parentRef: '/slack/channels/C123/messages/preview-parent.json', + thread_ts: 'preview-parent' + }, + simulatedReceipt: { id: 'preview-reply', timestamp: '2000-01-01T00:00:01.000Z' } + } + } + ], artifacts: { artifacts: [] }, - stateDiff: {} + stateDiff: { + files: [{ path: '/preview/state.json', after: '{"ok":true}' }], + memory: [{ id: 'mem_1', scope: 'workspace' }] + }, + extensions: { + sourceFidelity: { + state: 'fixture', + inputs: 'current', + http: 'fixture', + model: 'simulated' + } + } }; - assert.match(renderHumanSummary(record), /preview: 1 run\(s\) — 1 ok, 0 failed/); + const rendered = renderHumanSummary(record); + assert.match(rendered, /preview: 1 run\(s\) — 1 ok, 0 failed/); + assert.ok(rendered.indexOf('01. [ALLOW] provider.read') < rendered.indexOf('02. [PREVIEW] model.complete')); + assert.ok(rendered.indexOf('02. [PREVIEW] model.complete') < rendered.indexOf('03. [PREVIEW] provider.write')); + assert.match(rendered, /provider\.read slack\.messages list \/slack\/channels\/C123\/messages fidelity=fixture/); + assert.match(rendered, /model\.complete mode=stub source=simulated fidelity=simulated promptChars=10 outputChars=20/); + assert.match(rendered, /slack message: parent/); + assert.match(rendered, /text \(exact\): "Exact parent\\nwith second line"/); + assert.match(rendered, /slack message: reply/); + assert.match(rendered, /channel: C123/); + assert.match(rendered, /parentRef=\/slack\/channels\/C123\/messages\/preview-parent\.json thread_ts=preview-parent receipt=preview-reply/); + assert.match(rendered, /state changes:\n\s+file created: \/preview\/state\.json\n\s+memory:/); assert.match(renderHumanSummary([record, { ...record, runId: 'run_2', status: 'failed' }]), /preview: 2 run\(s\) — 1 ok, 1 failed/); }); diff --git a/packages/cli/src/invoke-command.ts b/packages/cli/src/invoke-command.ts index 7058dbec..1ab8c509 100644 --- a/packages/cli/src/invoke-command.ts +++ b/packages/cli/src/invoke-command.ts @@ -974,10 +974,173 @@ export function renderHumanSummary(output: RunRecordV2 | RunRecordV2[]): string const lines = [`preview: ${records.length} run(s) — ${records.length - failed} ok, ${failed} failed`]; for (const record of records) { lines.push(` [${record.status === 'succeeded' ? 'ok' : 'FAIL'}] ${record.eventContract} ${record.runId} (${record.actions.length} action(s))`); + lines.push( + ` policy: reads=${record.policy.reads} writes=${record.policy.writes} model=${record.policy.model} shell=${record.policy.shell} compose=${record.policy.compose}` + ); + const fidelity = recordSourceFidelity(record); + if (fidelity) { + lines.push(` fidelity: ${Object.entries(fidelity).map(([key, value]) => `${key}=${value}`).join(' ')}`); + } + lines.push(' trace:'); + if (record.actions.length === 0) { + lines.push(' (no effects)'); + } else { + record.actions.forEach((action, index) => { + lines.push(...renderHumanAction(record, action, index + 1)); + }); + } + lines.push(...renderHumanStateChanges(record)); + if (typeof record.error === 'string' && record.error) { + lines.push(` error: ${record.error}`); + } } return `${lines.join('\n')}\n`; } +function renderHumanAction( + record: RunRecordV2, + action: RunRecordV2['actions'][number], + sequence: number +): string[] { + const data = asRecord(action.data) ?? {}; + const prefix = ` ${String(sequence).padStart(2, '0')}.`; + + if (action.kind === 'provider.write') { + return renderHumanProviderWrite(prefix, action, data); + } + + if (action.kind === 'http.read' || action.kind === 'provider.read') { + const decision = action.status === 'denied' ? 'DENY' : 'ALLOW'; + const fidelity = actionFidelity(record, action, data); + const provider = action.provider ? ` ${action.provider}${action.resource ? `.${action.resource}` : ''}` : ''; + const method = typeof data.method === 'string' ? ` ${data.method}` : ''; + const target = typeof data.url === 'string' + ? ` ${data.url}` + : typeof data.path === 'string' + ? ` ${data.path}` + : ''; + const lines = [`${prefix} [${decision}] ${action.kind}${provider}${method}${target} fidelity=${fidelity}`]; + const parameters = asRecord(data.parameters); + if (action.kind === 'provider.read' && parameters) { + lines.push(` parameters: ${JSON.stringify(parameters)}`); + } + return lines; + } + + if (action.kind === 'model.complete') { + const mode = typeof data.mode === 'string' ? data.mode : record.policy.model; + const source = typeof data.source === 'string' ? data.source : 'unknown'; + const fidelity = actionFidelity(record, action, data); + const sizes = [ + typeof data.promptChars === 'number' ? `promptChars=${data.promptChars}` : '', + typeof data.outputChars === 'number' ? `outputChars=${data.outputChars}` : '' + ].filter(Boolean).join(' '); + return [ + `${prefix} [${humanActionStatus(action)}] model.complete mode=${mode} source=${source} fidelity=${fidelity}${sizes ? ` ${sizes}` : ''}` + ]; + } + + const details = Object.keys(data).length > 0 ? ` ${JSON.stringify(data)}` : ''; + return [`${prefix} [${humanActionStatus(action)}] ${action.kind}${details}`]; +} + +function renderHumanProviderWrite( + prefix: string, + action: RunRecordV2['actions'][number], + data: Record +): string[] { + const provider = action.provider ?? 'unknown'; + const resource = action.resource ?? 'unknown'; + const method = typeof data.method === 'string' ? data.method : 'write'; + const pathValue = typeof data.path === 'string' ? data.path : undefined; + const parameters = asRecord(data.parameters); + const body = asRecord(data.body); + const receipt = asRecord(data.simulatedReceipt); + const lines = [ + `${prefix} [${humanActionStatus(action)}] provider.write ${provider}.${resource}`, + ` method: ${method}` + ]; + if (pathValue) lines.push(` path: ${pathValue}`); + if (parameters) lines.push(` parameters: ${JSON.stringify(parameters)}`); + + if (provider === 'slack') { + const channel = slackChannel(parameters, pathValue); + const parentRef = typeof body?.parentRef === 'string' ? body.parentRef : undefined; + const threadTs = typeof body?.thread_ts === 'string' ? body.thread_ts : undefined; + const isReply = resource === 'replies' || parentRef !== undefined || threadTs !== undefined; + lines.push(` slack message: ${isReply ? 'reply' : 'parent'}`); + lines.push(` channel: ${channel ?? '(unknown)'}`); + lines.push( + ` linkage: parentRef=${parentRef ?? '(none)'} thread_ts=${threadTs ?? '(none)'} receipt=${typeof receipt?.id === 'string' ? receipt.id : '(none)'}` + ); + if (typeof body?.text === 'string') lines.push(` text (exact): ${JSON.stringify(body.text)}`); + } + + if (body) lines.push(` body: ${JSON.stringify(body)}`); + if (receipt) lines.push(` simulated receipt: ${JSON.stringify(receipt)}`); + lines.push(` provider data: ${JSON.stringify(data)}`); + return lines; +} + +function humanActionStatus(action: RunRecordV2['actions'][number]): string { + if (action.status === 'denied') return 'DENIED'; + if (action.status === 'previewed') return 'PREVIEW'; + if (action.status === 'sandboxed') return 'SANDBOX'; + return 'EXECUTED'; +} + +function recordSourceFidelity(record: RunRecordV2): Record | undefined { + const extensions = asRecord(record.extensions); + const sourceFidelity = asRecord(extensions?.sourceFidelity); + if (!sourceFidelity) return undefined; + const entries = Object.entries(sourceFidelity).filter((entry): entry is [string, string] => + typeof entry[1] === 'string' + ); + return entries.length > 0 ? Object.fromEntries(entries) : undefined; +} + +function actionFidelity( + record: RunRecordV2, + action: RunRecordV2['actions'][number], + data: Record +): string { + const extensions = asRecord(action.extensions); + if (typeof extensions?.sourceFidelity === 'string') return extensions.sourceFidelity; + if (typeof data.source === 'string') return data.source; + const sourceFidelity = recordSourceFidelity(record); + if (action.kind === 'http.read' && sourceFidelity?.http) return sourceFidelity.http; + if (action.kind === 'model.complete' && sourceFidelity?.model) return sourceFidelity.model; + if (action.kind === 'provider.read' && sourceFidelity?.state) return sourceFidelity.state; + if (action.status === 'denied') return 'unavailable'; + return 'unknown'; +} + +function slackChannel( + parameters: Record | null, + pathValue: string | undefined +): string | undefined { + const parameterChannel = parameters?.channelId ?? parameters?.channel; + if (typeof parameterChannel === 'string') return parameterChannel; + const match = pathValue?.match(/^\/slack\/channels\/([^/]+)/u); + return match?.[1] ? decodeURIComponent(match[1]) : undefined; +} + +function renderHumanStateChanges(record: RunRecordV2): string[] { + const files = record.stateDiff.files ?? []; + const memory = record.stateDiff.memory ?? []; + const providers = record.stateDiff.providers ?? []; + if (files.length === 0 && memory.length === 0 && providers.length === 0) return []; + + const lines = [' state changes:']; + for (const file of files) { + const change = file.before === undefined ? 'created' : file.after === undefined ? 'deleted' : 'updated'; + lines.push(` file ${change}: ${file.path}`); + } + for (const entry of memory) lines.push(` memory: ${JSON.stringify(entry)}`); + for (const entry of providers) lines.push(` provider: ${JSON.stringify(entry)}`); + return lines; +} + function expectValue(flag: string, value: string | undefined): string { if (value === undefined || value.startsWith('--')) throw new Error(`invoke: ${flag} expects a value`); return value; diff --git a/packages/delivery/package.json b/packages/delivery/package.json index 9504378e..546e85f7 100644 --- a/packages/delivery/package.json +++ b/packages/delivery/package.json @@ -33,6 +33,6 @@ }, "dependencies": { "@agentworkforce/runtime": "workspace:*", - "@relayfile/relay-helpers": "^0.4.6" + "@relayfile/relay-helpers": "^0.4.7" } } diff --git a/packages/events/README.md b/packages/events/README.md index c3a23baf..92e79585 100644 --- a/packages/events/README.md +++ b/packages/events/README.md @@ -13,3 +13,24 @@ const contract = getEventContract(decoded.frame.type, decoded.frame.contractVers Workforce gateway envelope. Legacy decoding is explicit in the returned `compatibility` metadata. Known versioned frames reject unknown top-level fields; forward-compatible data belongs in `extensions`. + +Consumers that compile the exported contract schemas with AJV must install the +package-owned cross-field keywords before adding the schemas. These enforce +canonical coordinates that JSON Schema cannot express on its own, including +the URL-encoded Composio trigger resource path. + +```ts +import addFormats from 'ajv-formats'; +import { Ajv2020 } from 'ajv/dist/2020.js'; +import { + addEventContractJsonSchemaKeywords, + EVENT_CONTRACT_JSON_SCHEMAS +} from '@agentworkforce/events'; + +const ajv = new Ajv2020({ strict: true }); +addFormats(ajv); +addEventContractJsonSchemaKeywords(ajv); +for (const schema of Object.values(EVENT_CONTRACT_JSON_SCHEMAS)) { + ajv.addSchema(schema); +} +``` diff --git a/packages/events/src/contract-schema-keywords.ts b/packages/events/src/contract-schema-keywords.ts new file mode 100644 index 00000000..8922eefd --- /dev/null +++ b/packages/events/src/contract-schema-keywords.ts @@ -0,0 +1,49 @@ +import type { Ajv2020 } from 'ajv/dist/2020.js'; + +export const COMPOSIO_TRIGGER_COORDINATES_KEYWORD = + 'x-agentworkforce-composio-trigger-message-v1-coordinates'; + +/** + * Install the shared cross-field keywords used by exported Event contract schemas. + * + * JSON Schema cannot otherwise compare a resource path with a URL-encoded value + * elsewhere in the frame. Consumers compiling EVENT_CONTRACT_JSON_SCHEMAS with + * AJV must call this once on their AJV instance before adding the schemas. + */ +export function addEventContractJsonSchemaKeywords(ajv: Ajv2020): Ajv2020 { + if (!ajv.getKeyword(COMPOSIO_TRIGGER_COORDINATES_KEYWORD)) { + ajv.addKeyword({ + keyword: COMPOSIO_TRIGGER_COORDINATES_KEYWORD, + type: 'object', + schemaType: 'boolean', + errors: false, + validate(enabled: boolean, value: unknown): boolean { + if (!enabled) return true; + return hasCanonicalComposioTriggerCoordinates(value); + } + }); + } + return ajv; +} + +function hasCanonicalComposioTriggerCoordinates(value: unknown): boolean { + if (!isRecord(value) || !isRecord(value.resource) || !isRecord(value.delivery)) return false; + if (!isRecord(value.payload) || !isRecord(value.payload.metadata)) return false; + + const triggerId = value.payload.metadata.trigger_id; + const payloadId = value.payload.id; + const timestamp = value.payload.timestamp; + if (typeof triggerId !== 'string' || triggerId.length === 0) return false; + if (typeof payloadId !== 'string' || payloadId.length === 0) return false; + if (typeof timestamp !== 'string' || timestamp.length === 0) return false; + + return value.resource.id === triggerId + && value.resource.path === `/composio/triggers/${encodeURIComponent(triggerId)}` + && value.occurredAt === timestamp + && value.delivery.id === payloadId + && value.delivery.dedupeKey === payloadId; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/packages/events/src/index.ts b/packages/events/src/index.ts index 77257511..a9bc300f 100644 --- a/packages/events/src/index.ts +++ b/packages/events/src/index.ts @@ -14,6 +14,10 @@ export { EVENT_FRAME_V1_SCHEMA, EVENT_SUMMARY_SCHEMA } from './schemas.js'; +export { + addEventContractJsonSchemaKeywords, + COMPOSIO_TRIGGER_COORDINATES_KEYWORD +} from './contract-schema-keywords.js'; export { EVENT_CONTRACTS, EVENT_CONTRACT_JSON_SCHEMAS, diff --git a/packages/events/src/json-schema.ts b/packages/events/src/json-schema.ts index 0d4b2609..4f3c87b8 100644 --- a/packages/events/src/json-schema.ts +++ b/packages/events/src/json-schema.ts @@ -1,9 +1,11 @@ import addFormatsModule from 'ajv-formats'; import { Ajv2020, type ErrorObject, type ValidateFunction } from 'ajv/dist/2020.js'; +import { addEventContractJsonSchemaKeywords } from './contract-schema-keywords.js'; import type { JsonSchema, ValidationIssue, ValidationResult } from './types.js'; const ajv = new Ajv2020({ allErrors: true, strict: true }); (addFormatsModule as unknown as (instance: Ajv2020) => Ajv2020)(ajv); +addEventContractJsonSchemaKeywords(ajv); const validators = new WeakMap(); diff --git a/packages/events/src/registry.test.ts b/packages/events/src/registry.test.ts index a284d3c2..0933dc2f 100644 --- a/packages/events/src/registry.test.ts +++ b/packages/events/src/registry.test.ts @@ -2,7 +2,9 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import addFormatsModule from 'ajv-formats'; import { Ajv2020 } from 'ajv/dist/2020.js'; +import type { EventFrameV1 } from './types.js'; import { + addEventContractJsonSchemaKeywords, decodeEventFrame, EVENT_CONTRACTS, EVENT_CONTRACT_JSON_SCHEMAS, @@ -19,6 +21,7 @@ test('every registry entry exports schema and validating example fixtures', () = 'github.pull_request.opened', 'slack.message.created', 'linear.issue.created', + 'composio.trigger.message', 'relaycast.message' ]); for (const contract of EVENT_CONTRACTS) { @@ -31,9 +34,85 @@ test('every registry entry exports schema and validating example fixtures', () = } }); +test('Composio V3 trigger contract requires its payload and canonical identity coordinates', () => { + const contract = EVENT_CONTRACTS.find((entry) => entry.id === 'composio.trigger.message'); + assert.ok(contract); + const fixture = contract.fixtureExamples[0]; + const payload = fixture.payload as Record; + const metadata = payload.metadata as Record; + + assert.equal(contract.version, 1); + assert.equal(contract.provider, 'composio'); + assert.equal(contract.trigger, 'trigger.message'); + assert.equal(contract.resourceKind, 'composio.trigger'); + assert.equal(fixture.resource.id, metadata.trigger_id); + assert.equal(fixture.resource.path, `/composio/triggers/${encodeURIComponent(String(metadata.trigger_id))}`); + assert.equal(fixture.occurredAt, payload.timestamp); + assert.equal(fixture.delivery?.id, payload.id); + assert.equal(fixture.delivery?.dedupeKey, payload.id); + assert.deepEqual(contract.validate(fixture), { valid: true, errors: [] }); + + const schema = EVENT_CONTRACT_JSON_SCHEMAS['composio.trigger.message@1']; + assert.ok(schema); + const ajv = new Ajv2020({ allErrors: true, strict: true }); + (addFormatsModule as unknown as (instance: Ajv2020) => Ajv2020)(ajv); + addEventContractJsonSchemaKeywords(ajv); + const validateSchema = ajv.compile(schema); + + const invalidFrames: EventFrameV1[] = [ + { ...fixture, payload: undefined }, + { ...fixture, payload: [] }, + { ...fixture, payload: { ...payload, id: undefined } }, + { ...fixture, payload: { ...payload, id: '' } }, + { ...fixture, payload: { ...payload, type: undefined } }, + { ...fixture, payload: { ...payload, type: 'composio.trigger.other' } }, + { ...fixture, payload: { ...payload, metadata: undefined } }, + { ...fixture, payload: { ...payload, metadata: [] } }, + { ...fixture, payload: { ...payload, metadata: { ...metadata, trigger_slug: undefined } } }, + { ...fixture, payload: { ...payload, metadata: { ...metadata, trigger_slug: '' } } }, + { ...fixture, payload: { ...payload, metadata: { ...metadata, trigger_id: undefined } } }, + { ...fixture, payload: { ...payload, metadata: { ...metadata, trigger_id: '' } } }, + { ...fixture, payload: { ...payload, metadata: { ...metadata, connected_account_id: undefined } } }, + { ...fixture, payload: { ...payload, metadata: { ...metadata, connected_account_id: '' } } }, + { ...fixture, payload: { ...payload, data: undefined } }, + { ...fixture, payload: { ...payload, data: [] } }, + { ...fixture, payload: { ...payload, data: null } }, + { ...fixture, payload: { ...payload, timestamp: undefined } }, + { ...fixture, payload: { ...payload, timestamp: 'not-a-timestamp' } }, + { ...fixture, payload: { ...payload, timestamp: 42 } }, + { ...fixture, resource: { ...fixture.resource, id: 'different-trigger' } }, + { ...fixture, resource: { ...fixture.resource, path: '/composio/triggers/different-trigger' } }, + { ...fixture, occurredAt: '2026-07-15T09:00:01.000Z' }, + { ...fixture, delivery: undefined }, + { ...fixture, delivery: { ...fixture.delivery, id: undefined } }, + { ...fixture, delivery: { ...fixture.delivery, id: 'different-delivery' } }, + { ...fixture, delivery: { ...fixture.delivery, dedupeKey: undefined } }, + { ...fixture, delivery: { ...fixture.delivery, dedupeKey: 'different-delivery' } } + ]; + for (const invalid of invalidFrames) { + assert.equal(contract.validate(invalid).valid, false, JSON.stringify(invalid)); + assert.equal(safeParseEventFrame(invalid).success, false, JSON.stringify(invalid)); + assert.equal(validateSchema(invalid), false, JSON.stringify(invalid)); + } + + const forwardCompatible = { + ...fixture, + payload: { + ...payload, + metadata: { ...metadata, future_metadata: { retained: true } }, + data: { future_provider_field: true }, + future_envelope_field: 'retained' + } + }; + assert.deepEqual(contract.validate(forwardCompatible), { valid: true, errors: [] }); + assert.equal(safeParseEventFrame(forwardCompatible).success, true); + assert.equal(validateSchema(forwardCompatible), true, JSON.stringify(validateSchema.errors)); +}); + test('all exported contract schemas register together without identifier collisions', () => { const ajv = new Ajv2020({ strict: true }); (addFormatsModule as unknown as (instance: Ajv2020) => Ajv2020)(ajv); + addEventContractJsonSchemaKeywords(ajv); for (const schema of Object.values(EVENT_CONTRACT_JSON_SCHEMAS)) ajv.addSchema(schema); }); diff --git a/packages/events/src/registry.ts b/packages/events/src/registry.ts index 525a4266..663faa56 100644 --- a/packages/events/src/registry.ts +++ b/packages/events/src/registry.ts @@ -3,8 +3,33 @@ import { validateJsonSchema, withoutSchemaIdentifiers } from './json-schema.js'; import { EVENT_FRAME_V1_SCHEMA, EVENT_SUMMARY_SCHEMA } from './schemas.js'; import type { EventContract, EventFrameV1, JsonSchema, ValidationIssue, ValidationResult } from './types.js'; import { validateEventFrameV1 } from './validate.js'; +import { COMPOSIO_TRIGGER_COORDINATES_KEYWORD } from './contract-schema-keywords.js'; const OPEN_PAYLOAD_SCHEMA = Object.freeze({}) satisfies JsonSchema; +const COMPOSIO_TRIGGER_MESSAGE_PAYLOAD_SCHEMA = Object.freeze({ + type: 'object', + required: ['id', 'type', 'metadata', 'data', 'timestamp'], + properties: { + id: { type: 'string', minLength: 1 }, + type: { const: 'composio.trigger.message' }, + metadata: { + type: 'object', + required: ['trigger_slug', 'trigger_id', 'connected_account_id'], + properties: { + trigger_slug: { type: 'string', minLength: 1 }, + trigger_id: { type: 'string', minLength: 1 }, + connected_account_id: { type: 'string', minLength: 1 } + }, + additionalProperties: true + }, + data: { + type: 'object', + additionalProperties: true + }, + timestamp: { type: 'string', format: 'date-time' } + }, + additionalProperties: true +}) satisfies JsonSchema; function example( id: string, @@ -40,6 +65,8 @@ function contract(config: { resourceKind: string; fixture: EventFrameV1; payloadSchema?: JsonSchema; + payloadRequired?: boolean; + validateFrame?: (frame: EventFrameV1) => readonly ValidationIssue[]; }): EventContract { return Object.freeze({ id: config.id, @@ -61,10 +88,13 @@ function contract(config: { if (frame.resource.kind !== config.resourceKind) errors.push({ path: '$.resource.kind', message: `must equal ${config.resourceKind}`, keyword: 'const' }); const summary = validateJsonSchema(EVENT_SUMMARY_SCHEMA, frame.summary, '$.summary'); if (!summary.valid) errors.push(...summary.errors); - if (frame.payload !== undefined) { + if (config.payloadRequired && frame.payload === undefined) { + errors.push({ path: '$.payload', message: 'is required', keyword: 'required' }); + } else if (frame.payload !== undefined) { const payload = validateJsonSchema(config.payloadSchema ?? OPEN_PAYLOAD_SCHEMA, frame.payload, '$.payload'); if (!payload.valid) errors.push(...payload.errors); } + if (config.validateFrame) errors.push(...config.validateFrame(frame)); return errors.length === 0 ? { valid: true, errors: [] } : { valid: false, errors }; } }); @@ -139,6 +169,48 @@ export const EVENT_CONTRACTS = Object.freeze([ payload: { action: 'create', issue: { identifier: 'ENG-1', title: 'Ship it' } } }) }), + contract({ + id: 'composio.trigger.message', + provider: 'composio', + trigger: 'trigger.message', + resourceKind: 'composio.trigger', + payloadSchema: COMPOSIO_TRIGGER_MESSAGE_PAYLOAD_SCHEMA, + payloadRequired: true, + validateFrame: validateComposioTriggerCoordinates, + fixture: example('composio.trigger.message', 'composio', 'trigger.message', 'composio.trigger', { + id: '018f4b16-52f9-7d33-a7d6-8f9a324db8c1', + occurredAt: '2026-07-15T09:00:00.000Z', + resource: { + path: '/composio/triggers/trg%2Fgithub%2Fissue', + kind: 'composio.trigger', + id: 'trg/github/issue', + provider: 'composio' + }, + summary: { title: 'Composio trigger GITHUB_ISSUE_CREATED' }, + delivery: { + id: 'delivery_composio_1', + dedupeKey: 'delivery_composio_1' + }, + payload: { + id: 'delivery_composio_1', + type: 'composio.trigger.message', + metadata: { + trigger_slug: 'GITHUB_ISSUE_CREATED', + trigger_id: 'trg/github/issue', + connected_account_id: 'ca_composio_123', + log_id: 'log_123', + auth_config_id: 'ac_123', + user_id: 'untrusted_vendor_user' + }, + data: { + action: 'opened', + issue: { number: 42, title: 'Canonical Composio delivery' } + }, + timestamp: '2026-07-15T09:00:00.000Z', + vendor_extension: { retained: true } + } + }) + }), contract({ id: 'relaycast.message', provider: 'relaycast', @@ -175,6 +247,12 @@ export const EVENT_CONTRACT_JSON_SCHEMAS = Object.freeze(Object.fromEntries( withoutSchemaIdentifiers(EVENT_FRAME_V1_SCHEMA), { type: 'object', + ...(entry.id === 'composio.trigger.message' + ? { + required: ['payload', 'delivery'], + [COMPOSIO_TRIGGER_COORDINATES_KEYWORD]: true + } + : {}), properties: { type: { const: entry.id }, contractVersion: { const: entry.version }, @@ -185,6 +263,18 @@ export const EVENT_CONTRACT_JSON_SCHEMAS = Object.freeze(Object.fromEntries( kind: { const: entry.resourceKind } } }, + ...(entry.id === 'composio.trigger.message' + ? { + delivery: { + type: 'object', + required: ['id', 'dedupeKey'], + properties: { + id: { type: 'string', minLength: 1 }, + dedupeKey: { type: 'string', minLength: 1 } + } + } + } + : {}), summary: entry.summarySchema, ...(entry.payloadSchema ? { payload: entry.payloadSchema } : {}) } @@ -193,3 +283,44 @@ export const EVENT_CONTRACT_JSON_SCHEMAS = Object.freeze(Object.fromEntries( } satisfies JsonSchema ]) )); + +function validateComposioTriggerCoordinates(frame: EventFrameV1): readonly ValidationIssue[] { + const payload = isRecord(frame.payload) ? frame.payload : undefined; + const metadata = payload && isRecord(payload.metadata) ? payload.metadata : undefined; + const triggerId = nonEmptyString(metadata?.trigger_id); + const payloadId = nonEmptyString(payload?.id); + const timestamp = nonEmptyString(payload?.timestamp); + const errors: ValidationIssue[] = []; + + if (triggerId) { + if (frame.resource.id !== triggerId) { + errors.push({ path: '$.resource.id', message: 'must equal $.payload.metadata.trigger_id', keyword: 'coordinates' }); + } + const expectedPath = `/composio/triggers/${encodeURIComponent(triggerId)}`; + if (frame.resource.path !== expectedPath) { + errors.push({ path: '$.resource.path', message: 'must be the canonical path for $.payload.metadata.trigger_id', keyword: 'coordinates' }); + } + } + if (timestamp && frame.occurredAt !== timestamp) { + errors.push({ path: '$.occurredAt', message: 'must equal $.payload.timestamp', keyword: 'coordinates' }); + } + if (!frame.delivery) { + errors.push({ path: '$.delivery', message: 'is required', keyword: 'required' }); + } else if (payloadId) { + if (frame.delivery.id !== payloadId) { + errors.push({ path: '$.delivery.id', message: 'must equal $.payload.id', keyword: 'coordinates' }); + } + if (frame.delivery.dedupeKey !== payloadId) { + errors.push({ path: '$.delivery.dedupeKey', message: 'must equal $.payload.id', keyword: 'coordinates' }); + } + } + return errors; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function nonEmptyString(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined; +} diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 8909f7e0..cc476baf 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -58,7 +58,7 @@ "@agentworkforce/events": "workspace:*", "@agentworkforce/persona-kit": "workspace:*", "@relayfile/adapter-core": "^0.5.1", - "@relayfile/relay-helpers": "^0.4.6", + "@relayfile/relay-helpers": "^0.4.7", "agent-trajectories": "^0.5.3" } } diff --git a/packages/runtime/src/local-preview-executor.ts b/packages/runtime/src/local-preview-executor.ts index 47b69654..3e2afa95 100644 --- a/packages/runtime/src/local-preview-executor.ts +++ b/packages/runtime/src/local-preview-executor.ts @@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto'; import { pathToFileURL } from 'node:url'; import type { EventFrameV1 } from '@agentworkforce/events'; import { buildCtx } from './ctx.js'; -import { getPreviewProcessState, transportActionToPreviewAction } from './local-preview-hooks.js'; +import { getPreviewProcessState } from './local-preview-hooks.js'; import { redactLocalPreviewValue } from './local-preview-redaction.js'; import type { ExecuteLocalRunResult, @@ -50,6 +50,20 @@ export async function executeLocalRunInWorkerProcess( previewState.recordAction(redactLocalPreviewValue(action)); }; + const denyWrite = ( + kind: string, + data: Record, + details: Pick = {} + ): never => { + recordAction({ + kind, + status: 'denied', + ...details, + data + }); + throw new Error(`invoke policy denied ${kind}`); + }; + const log: WorkforceCtx['log'] = (level, message, attrs) => { const payloadLine = redactLocalPreviewValue({ t: previewState.now().toISOString(), @@ -84,6 +98,9 @@ export async function executeLocalRunInWorkerProcess( return value; }, async writeFile(filePath, contents) { + if (payload.request.policy.writes === 'deny') { + denyWrite('files.write', { path: filePath, bytes: contents.length, channel: 'ctx.sandbox' }); + } files.set(filePath, contents); recordAction({ kind: 'files.write', @@ -100,6 +117,9 @@ export async function executeLocalRunInWorkerProcess( return value; }, async write(filePath, contents) { + if (payload.request.policy.writes === 'deny') { + denyWrite('files.write', { path: filePath, bytes: contents.length, channel: 'ctx.files' }); + } files.set(filePath, contents); recordAction({ kind: 'files.write', @@ -111,6 +131,13 @@ export async function executeLocalRunInWorkerProcess( const memoryCtx: MemoryContext = { async save(content, opts) { + if (payload.request.policy.writes === 'deny') { + denyWrite('memory.save', { + bytes: content.length, + scope: opts?.scope ?? 'workspace', + tags: [...(opts?.tags ?? [])] + }); + } const entry: LocalPreviewMemoryEntry = { id: `mem_${++memorySeq}`, content, @@ -219,6 +246,12 @@ export async function executeLocalRunInWorkerProcess( const schedule: ScheduleContext = { async at(when, triggerPayload) { + if (payload.request.policy.writes === 'deny') { + denyWrite('schedule.at', { + when: when.toISOString(), + payload: triggerPayload + }); + } recordAction({ kind: 'schedule.at', status: 'previewed', @@ -230,6 +263,9 @@ export async function executeLocalRunInWorkerProcess( }); }, async cancel(name) { + if (payload.request.policy.writes === 'deny') { + denyWrite('schedule.cancel', { name }); + } recordAction({ kind: 'schedule.cancel', status: 'previewed', @@ -240,6 +276,13 @@ export async function executeLocalRunInWorkerProcess( const relay: RelayContext = { async dm(to, text) { + if (payload.request.policy.writes === 'deny') { + denyWrite( + 'provider.write', + { to, text, channel: 'ctx.relay' }, + { provider: 'relaycast', resource: 'messages' } + ); + } const messageId = `relay_${++relaySeq}`; recordAction({ kind: 'provider.write', @@ -251,6 +294,13 @@ export async function executeLocalRunInWorkerProcess( return { ok: true, messageId }; }, async post(channel, text) { + if (payload.request.policy.writes === 'deny') { + denyWrite( + 'provider.write', + { channel, text, channelType: 'ctx.relay' }, + { provider: 'relaycast', resource: 'messages' } + ); + } const messageId = `relay_${++relaySeq}`; recordAction({ kind: 'provider.write', @@ -334,11 +384,9 @@ export async function executeLocalRunInWorkerProcess( error = caught instanceof Error ? caught.message : String(caught); } - for (const action of previewState.previewTransport.actions) { - recordAction(transportActionToPreviewAction(action as unknown as never)); - } - for (const access of previewState.previewTransport.accesses) { - recordAction(transportActionToPreviewAction(access as unknown as never)); + const deniedWriteCount = previewState.recordedActions.filter(isDeniedWriteAction).length; + if (deniedWriteCount > 0 && !error) { + error = `invoke policy denied ${deniedWriteCount} write effect${deniedWriteCount === 1 ? '' : 's'}`; } const actionTraces = buildActionTraces(runId, previewState.recordedActions, previewState.now); @@ -396,6 +444,16 @@ export async function executeLocalRunInWorkerProcess( }; } +function isDeniedWriteAction(action: PreviewAction): boolean { + if (action.status !== 'denied') return false; + return action.kind === 'provider.write' + || action.kind === 'files.write' + || action.kind === 'memory.save' + || action.kind === 'schedule.at' + || action.kind === 'schedule.cancel' + || action.kind.endsWith('.write'); +} + function buildActionTraces( runId: string, actions: readonly PreviewAction[], diff --git a/packages/runtime/src/local-preview-hooks.ts b/packages/runtime/src/local-preview-hooks.ts index 780b2c29..f824a26e 100644 --- a/packages/runtime/src/local-preview-hooks.ts +++ b/packages/runtime/src/local-preview-hooks.ts @@ -1,7 +1,16 @@ import { Buffer } from 'node:buffer'; import { createRequire, syncBuiltinESMExports } from 'node:module'; -import { bindPreviewTransport, PreviewTransport, type TransportPreviewAction } from '@relayfile/relay-helpers'; +import * as relayHelpers from '@relayfile/relay-helpers'; +import { + bindPreviewTransport, + PreviewTransport, + type RelayTransport, + type RelayTransportRequest, + type RelayTransportWriteRequest, + type TransportPreviewAction +} from '@relayfile/relay-helpers'; import type { PreviewAction } from './run-contracts.js'; +import { redactLocalPreviewValue } from './local-preview-redaction.js'; import type { LocalPreviewFetchRequestMessage, LocalPreviewFetchResponseMessage, @@ -56,8 +65,35 @@ export function installPreviewProcessGuards(args: { const previewTransport = new PreviewTransport(); if (args.config.transportState) restorePreviewTransportState(previewTransport, args.config.transportState); - const restoreTransport = bindPreviewTransport(previewTransport); const recordedActions: PreviewAction[] = []; + const appendAction = (action: PreviewAction): void => { + recordedActions.push(redactLocalPreviewValue(action)); + }; + const recordingTransport = recordPreviewTransportActions(previewTransport, appendAction); + const bindRelayWriteAuthorizer = requireRelayWriteAuthorizerBinder(); + const restoreWriteAuthorizer = bindRelayWriteAuthorizer((request) => { + if (args.config.policy.writes !== 'deny') { + return { allowed: true, transport: recordingTransport }; + } + appendAction({ + kind: 'provider.write', + status: 'denied', + provider: request.provider, + resource: request.resource, + data: { + operation: 'write', + parameters: { ...request.parameters }, + path: request.path, + body: request.body, + method: 'write' + } + }); + return { + allowed: false, + reason: 'local write policy denies provider writes' + }; + }); + const restoreTransport = bindPreviewTransport(recordingTransport); const clockNow = args.config.clockNow; const now = clockNow ? () => new Date(clockNow) : () => new Date(); const originalFetch = globalThis.fetch.bind(globalThis); @@ -156,6 +192,7 @@ export function installPreviewProcessGuards(args: { childProcess.spawnSync = originalChildProcess.spawnSync; syncBuiltinESMExports(); restoreTransport(); + restoreWriteAuthorizer(); delete (globalThis as Record)[PREVIEW_PROCESS_STATE]; }, fetchFromParent: args.fetchFromParent, @@ -166,7 +203,7 @@ export function installPreviewProcessGuards(args: { return snapshotPreviewTransportState(previewTransport); }, recordAction(action) { - recordedActions.push(action); + appendAction(action); }, recordedActions }; @@ -216,6 +253,73 @@ export function installPreviewProcessGuards(args: { return state; } +function recordPreviewTransportActions( + previewTransport: PreviewTransport, + recordAction: (action: PreviewAction) => void +): RelayTransport { + let actionCursor = previewTransport.actions.length; + const flush = (): void => { + while (actionCursor < previewTransport.actions.length) { + const action = previewTransport.actions[actionCursor++]; + if (action) recordAction(transportActionToPreviewAction(action)); + } + }; + const capture = (operation: () => Promise): Promise => { + let pending: Promise; + try { + pending = operation(); + // PreviewTransport currently records synchronously before returning its + // settled promise. Flush here so provider effects share the exact call-time + // stream with context, HTTP, and model effects. + flush(); + } catch (error) { + flush(); + throw error; + } + return pending.then( + (value) => { + flush(); + return value; + }, + (error: unknown) => { + flush(); + throw error; + } + ); + }; + return { + read(request: RelayTransportRequest) { + return capture(() => previewTransport.read(request)); + }, + list(request: RelayTransportRequest) { + return capture(() => previewTransport.list(request)); + }, + write(request: RelayTransportWriteRequest) { + return capture(() => previewTransport.write(request)); + } + }; +} + +type RelayWriteAuthorizerBinder = ( + authorizer: ( + request: Readonly + ) => + | { allowed: false; reason?: string } + | { allowed: true; transport?: RelayTransport } +) => () => void; + +function requireRelayWriteAuthorizerBinder(): RelayWriteAuthorizerBinder { + const binder = (relayHelpers as unknown as { + bindRelayWriteAuthorizer?: RelayWriteAuthorizerBinder; + }).bindRelayWriteAuthorizer; + if (typeof binder !== 'function') { + throw new Error( + 'invoke: an @relayfile/relay-helpers release with final-write policy enforcement is required' + ); + } + return binder; +} + function snapshotPreviewTransportState(previewTransport: PreviewTransport): LocalPreviewTransportState { const internal = previewTransport as unknown as { sequence: number; diff --git a/packages/runtime/src/local-preview.test.ts b/packages/runtime/src/local-preview.test.ts index 5541c0a8..913edb5d 100644 --- a/packages/runtime/src/local-preview.test.ts +++ b/packages/runtime/src/local-preview.test.ts @@ -2,7 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { EventEmitter } from 'node:events'; import { createServer } from 'node:http'; -import { copyFile, cp, mkdir, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises'; +import { copyFile, cp, mkdir, mkdtemp, readdir, realpath, rm, symlink, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; @@ -20,6 +20,7 @@ function previewRequest(args: { allowedHttp: Array<{ method: string; urlGlob: string }>; model?: 'stub' | 'fixture' | 'live'; personaModel?: string; + writes?: 'deny' | 'preview'; }): RunRequestV1 { return { schemaVersion: 1, @@ -61,7 +62,7 @@ function previewRequest(args: { inputs: {}, policy: { reads: 'live', - writes: 'preview', + writes: args.writes ?? 'preview', model: args.model ?? 'stub', shell: 'simulate', compose: 'preview', @@ -75,6 +76,49 @@ function previewRequest(args: { } as unknown as RunRequestV1; } +test('executeLocalRun: authored writes deny blocks context mutations and fails caught attempts', async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), 'wf-preview-writes-deny-')); + const bundlePath = path.join(tempDir, 'bundle.mjs'); + await writeFile( + bundlePath, + `export default async function handler(ctx) { + await Promise.allSettled([ + ctx.files.write('/preview/files.txt', 'blocked files write'), + ctx.sandbox.writeFile('/preview/sandbox.txt', 'blocked sandbox write'), + ctx.memory.save('blocked memory'), + ctx.relay.post('general', 'blocked relay post'), + ctx.schedule.at(new Date('2026-07-16T10:00:00.000Z'), { blocked: true }), + ctx.schedule.cancel('blocked-schedule') + ]); + }\n`, + 'utf8' + ); + + try { + const result = await executeLocalRun({ + request: previewRequest({ + tempDir, + id: 'writes-deny-test', + allowedHttp: [], + writes: 'deny' + }), + bundlePath + }); + + assert.equal(result.exitCode, 1); + assert.equal(result.record.status, 'failed'); + assert.match(String(result.record.error ?? ''), /denied 6 write effects/); + assert.equal(result.record.actions.filter((action) => action.status === 'denied').length, 6); + assert.equal(result.record.actions.some((action) => action.status === 'previewed'), false); + assert.deepEqual(result.record.stateDiff.files, []); + assert.deepEqual(result.record.stateDiff.memory, []); + assert.deepEqual(result.state.files, {}); + assert.deepEqual(result.state.memory, []); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } +}); + test('executeLocalRun: empty live allowlist denies GET before network', async () => { let hits = 0; const server = createServer((_req, res) => { @@ -638,6 +682,139 @@ test('executeLocalRun: no-close child stop path still rejects promptly and clean } }); +test('executeLocalRun: installed runtime artifact resists authored authorizer rebinding under writes deny', async () => { + await withInstalledRuntimeCopy(async (consumerRoot, installed) => { + let sentinelHits = 0; + const sentinel = createServer((_req, res) => { + sentinelHits += 1; + res.statusCode = 500; + res.end('installed custom transport escaped'); + }); + await new Promise((resolve) => sentinel.listen(0, '127.0.0.1', () => resolve())); + const address = sentinel.address(); + assert.ok(address && typeof address === 'object'); + const previousCwd = process.cwd(); + const tempDir = await mkdtemp(path.join(consumerRoot, 'installed-writes-deny-')); + const bundlePath = path.join(tempDir, 'bundle.mjs'); + await writeFile( + bundlePath, + `import { + PreviewTransport, + bindRelayWriteAuthorizer, + slackClient + } from '@relayfile/relay-helpers'; + export default async function handler(ctx) { + const explicitTransport = new PreviewTransport(); + let customTransportWrites = 0; + const customTransport = { + async read() { return undefined; }, + async list() { return []; }, + async write() { + customTransportWrites += 1; + await fetch('http://127.0.0.1:${address.port}/installed-custom', { + method: 'POST', + body: 'escaped' + }); + return { path: '/escaped', absolutePath: '/escaped' }; + } + }; + let authoredAuthorizerCalled = false; + if (typeof bindRelayWriteAuthorizer !== 'function') { + throw new Error('public Relay write authorizer binder is missing'); + } + let authoredBindingOutcome = 'returned'; + let restoreAuthoredAuthorizer = () => {}; + try { + restoreAuthoredAuthorizer = bindRelayWriteAuthorizer(() => { + authoredAuthorizerCalled = true; + return { allowed: true, transport: customTransport }; + }); + } catch (error) { + authoredBindingOutcome = String(error?.code ?? error?.name ?? error); + } + const legacyAuthorizerKey = Symbol.for('agentworkforce.relay-write-authorizer'); + let legacyOverwriteAttempted = false; + let legacyDeleteAttempted = false; + try { + legacyOverwriteAttempted = true; + globalThis[legacyAuthorizerKey] = () => ({ allowed: true, transport: customTransport }); + } catch {} + try { + legacyDeleteAttempted = true; + delete globalThis[legacyAuthorizerKey]; + } catch {} + const outcomes = await Promise.allSettled([ + slackClient().post('C123', 'installed process write'), + slackClient({ transport: explicitTransport }).post( + 'C124', + 'installed xoxb-123456789012-123456789012-INSTALLED must be redacted' + ), + slackClient({ transport: customTransport }).post('C125', 'installed custom write'), + ctx.files.write('/preview/installed.txt', 'must not persist'), + ctx.relay.post('general', 'must not receive a simulated receipt') + ]); + restoreAuthoredAuthorizer(); + ctx.log('info', 'installed.transport.audit', { + authoredAuthorizerCalled, + authoredBindingOutcome, + binderType: typeof bindRelayWriteAuthorizer, + explicitActions: explicitTransport.actions.length, + customTransportWrites, + legacyDeleteAttempted, + legacyOverwriteAttempted, + rejected: outcomes.filter((outcome) => outcome.status === 'rejected').length + }); + }\n`, + 'utf8' + ); + + try { + process.chdir(consumerRoot); + const result = await installed.executeLocalRun({ + request: previewRequest({ + tempDir, + id: 'installed-writes-deny', + allowedHttp: [], + writes: 'deny' + }), + bundlePath + }); + assert.equal(result.exitCode, 1); + assert.equal(result.record.status, 'failed'); + assert.equal(result.record.actions.filter((action) => action.status === 'denied').length, 5); + assert.equal( + result.record.actions.filter((action) => + action.status === 'denied' && action.kind === 'provider.write' + ).length, + 4 + ); + assert.equal(result.record.actions.some((action) => action.status === 'previewed'), false); + assert.deepEqual(result.record.stateDiff.files, []); + assert.equal(result.state.transport?.sequence, 0); + assert.deepEqual(result.state.transport?.receiptsByReference, {}); + assert.doesNotMatch(JSON.stringify(result.record), /simulatedReceipt/); + assert.doesNotMatch(JSON.stringify(result.record), /xoxb-123456789012/); + assert.equal(sentinelHits, 0); + const logs = ((result.record.extensions as Record).logs ?? []) as string[]; + assert.ok(logs.some((line) => + line.includes('installed.transport.audit') + && line.includes('"authoredAuthorizerCalled":false') + && line.includes('"authoredBindingOutcome":"returned"') + && line.includes('"binderType":"function"') + && line.includes('"explicitActions":0') + && line.includes('"customTransportWrites":0') + && line.includes('"legacyDeleteAttempted":true') + && line.includes('"legacyOverwriteAttempted":true') + && line.includes('"rejected":5') + )); + } finally { + process.chdir(previousCwd); + sentinel.close(); + await rm(tempDir, { recursive: true, force: true }); + } + }); +}); + test('executeLocalRun: installed runtime stages under the invocation workspace and grants consumer/runtime roots', async () => { await withInstalledRuntimeCopy(async (consumerRoot, installed) => { class ReadyChild extends EventEmitter { @@ -803,9 +980,32 @@ async function withInstalledRuntimeCopy( const runtimePackageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const consumerRoot = await mkdtemp(path.join(runtimePackageRoot, '.installed-preview-consumer-')); const installedRuntimeRoot = path.join(consumerRoot, 'node_modules', '@agentworkforce', 'runtime'); + const installedRelayHelpersRoot = path.join(consumerRoot, 'node_modules', '@relayfile', 'relay-helpers'); await mkdir(path.dirname(installedRuntimeRoot), { recursive: true }); + await mkdir(path.dirname(installedRelayHelpersRoot), { recursive: true }); await cp(path.join(runtimePackageRoot, 'dist'), path.join(installedRuntimeRoot, 'dist'), { recursive: true }); await copyFile(path.join(runtimePackageRoot, 'package.json'), path.join(installedRuntimeRoot, 'package.json')); + await cp( + path.join(runtimePackageRoot, 'node_modules', '@relayfile', 'relay-helpers', 'dist'), + path.join(installedRelayHelpersRoot, 'dist'), + { recursive: true } + ); + await copyFile( + path.join(runtimePackageRoot, 'node_modules', '@relayfile', 'relay-helpers', 'package.json'), + path.join(installedRelayHelpersRoot, 'package.json') + ); + const relayHelpersSourceRoot = await realpath( + path.join(runtimePackageRoot, 'node_modules', '@relayfile', 'relay-helpers') + ); + const installedRelayDependenciesRoot = path.join(installedRelayHelpersRoot, 'node_modules', '@relayfile'); + await mkdir(installedRelayDependenciesRoot, { recursive: true }); + for (const dependency of ['adapter-core', 'adapter-linear', 'adapter-reddit']) { + await symlink( + await realpath(path.join(path.dirname(relayHelpersSourceRoot), dependency)), + path.join(installedRelayDependenciesRoot, dependency), + 'dir' + ); + } try { const installed = await import(`${pathToFileURL(path.join(installedRuntimeRoot, 'dist', 'local-preview.js')).href}?installed=${Date.now()}`) as { executeLocalRun: typeof executeLocalRun; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 298a9474..9d3e7d57 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -85,8 +85,8 @@ importers: specifier: workspace:* version: link:../runtime '@relayfile/relay-helpers': - specifier: ^0.4.6 - version: 0.4.6(@relayfile/sdk@0.7.40) + specifier: ^0.4.7 + version: 0.4.7(@relayfile/sdk@0.7.40) packages/deploy: dependencies: @@ -193,8 +193,8 @@ importers: specifier: ^0.5.1 version: 0.5.1(@relayfile/sdk@0.7.40) '@relayfile/relay-helpers': - specifier: ^0.4.6 - version: 0.4.6(@relayfile/sdk@0.7.40) + specifier: ^0.4.7 + version: 0.4.7(@relayfile/sdk@0.7.40) agent-trajectories: specifier: ^0.5.3 version: 0.5.3 @@ -1060,21 +1060,21 @@ packages: peerDependencies: '@relayfile/sdk': '>=0.6.0 <1' - '@relayfile/adapter-core@0.5.5': - resolution: {integrity: sha512-CvI5DTmuP0tdXvhYmiPBBazGUzlr9/0g/aUsII23v07Zx86a4+J9KV0D/I2X1/uqI4KVC4mjpveyHkoGyX5SQQ==} + '@relayfile/adapter-core@0.5.6': + resolution: {integrity: sha512-u5SMeaz2y1IzidXsMLufoBOg9Y1eTUnzQLSs9AmNK5aiqxdEQXvTRwWxJ0t47fic4vX8TV1JlryGNXL72e5TJQ==} engines: {node: '>=18'} hasBin: true peerDependencies: '@relayfile/sdk': '>=0.6.0 <1' - '@relayfile/adapter-linear@0.4.5': - resolution: {integrity: sha512-IRsOyHu3yCITeumek1wOy1Tb0DTAMNh5zRhBY7N6BMsqMflBDJ3XHCaAxwvC2pMUdc4KHFc/zNGyHsWOOLy+6w==} + '@relayfile/adapter-linear@0.4.6': + resolution: {integrity: sha512-/7TprCjuhM3PpLHoWh1fG/mbI+Z1ICfgxflNAQDf87syvZDnLqybr3Spj33VJ+nZ5dV6g5OVcacn/pVUo5wAag==} engines: {node: '>=18'} peerDependencies: '@relayfile/sdk': '>=0.6.0 <1' - '@relayfile/adapter-reddit@0.2.4': - resolution: {integrity: sha512-mBohPpQJrbHMMWsvk4wP+SEmZyO4+WXkt8n74Mjpr4U7fwsC6afGR4qhf85CCW9y1ZA3QsNQ/wXU9hCcGfK92w==} + '@relayfile/adapter-reddit@0.2.5': + resolution: {integrity: sha512-kNI3r4nKQROa9lv0TB2rIUsqDubxhz8D19B/zFDqgvG6AvRd065/J0RoHSizDK0xD2UjsctoJTKNRgi4V8culA==} engines: {node: '>=18'} peerDependencies: '@relayfile/sdk': '>=0.6.0 <1' @@ -1087,8 +1087,8 @@ packages: resolution: {integrity: sha512-eWy52O7Dt1Vwy162IJCbGAoSFFejNVVOy4eMrNEmIWld+YMdpR0hEvuplKP0O75xwD8be4PJcADoedi3X1R9Ng==} engines: {node: '>=18'} - '@relayfile/relay-helpers@0.4.6': - resolution: {integrity: sha512-kf5zShWiRN5+h2v+dpNrfO26aZRJkSic6bI3mixqE6aHRtsGZt/1KQKImKFpgG1qmm18XNVY4sXU4ZXIXtUSxg==} + '@relayfile/relay-helpers@0.4.7': + resolution: {integrity: sha512-eIPG8Vv4Wl5UekKXMc5DOP/rq3D1jrIVu95K/CjSzu9C/u19b6T/es6m1XImb0oCpyyDDfvg0MCSMuaDjKFnlA==} '@relayfile/sdk@0.7.40': resolution: {integrity: sha512-9qPQ/qSexD11x/CKZlbZUFC7KDdxJ2yYBQd1nuzcoHNqnvErplq5bDBy5Dqr1AXID9i1Q7sMxnY8M1raMTQsAg==} @@ -3485,7 +3485,7 @@ snapshots: minimatch: 10.2.5 yaml: 2.9.0 - '@relayfile/adapter-core@0.5.5(@relayfile/sdk@0.7.40)': + '@relayfile/adapter-core@0.5.6(@relayfile/sdk@0.7.40)': dependencies: '@relayfile/sdk': 0.7.40 '@scalar/postman-to-openapi': 0.6.3 @@ -3493,14 +3493,14 @@ snapshots: minimatch: 10.2.5 yaml: 2.9.0 - '@relayfile/adapter-linear@0.4.5(@relayfile/sdk@0.7.40)': + '@relayfile/adapter-linear@0.4.6(@relayfile/sdk@0.7.40)': dependencies: - '@relayfile/adapter-core': 0.5.5(@relayfile/sdk@0.7.40) + '@relayfile/adapter-core': 0.5.6(@relayfile/sdk@0.7.40) '@relayfile/sdk': 0.7.40 - '@relayfile/adapter-reddit@0.2.4(@relayfile/sdk@0.7.40)': + '@relayfile/adapter-reddit@0.2.5(@relayfile/sdk@0.7.40)': dependencies: - '@relayfile/adapter-core': 0.5.5(@relayfile/sdk@0.7.40) + '@relayfile/adapter-core': 0.5.6(@relayfile/sdk@0.7.40) '@relayfile/sdk': 0.7.40 '@relayfile/core@0.7.40': {} @@ -3510,11 +3510,11 @@ snapshots: '@parcel/watcher': 2.5.6 ignore: 7.0.5 - '@relayfile/relay-helpers@0.4.6(@relayfile/sdk@0.7.40)': + '@relayfile/relay-helpers@0.4.7(@relayfile/sdk@0.7.40)': dependencies: - '@relayfile/adapter-core': 0.5.5(@relayfile/sdk@0.7.40) - '@relayfile/adapter-linear': 0.4.5(@relayfile/sdk@0.7.40) - '@relayfile/adapter-reddit': 0.2.4(@relayfile/sdk@0.7.40) + '@relayfile/adapter-core': 0.5.6(@relayfile/sdk@0.7.40) + '@relayfile/adapter-linear': 0.4.6(@relayfile/sdk@0.7.40) + '@relayfile/adapter-reddit': 0.2.5(@relayfile/sdk@0.7.40) transitivePeerDependencies: - '@relayfile/sdk'