From 077975cd1cc53b4a53c8a78d9ec5959bc41c114a Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Sat, 19 Sep 2026 20:54:42 -0700 Subject: [PATCH 1/4] fix(inbound): authorize stable GitHub PR identity matching Session-Id: 01a0bcd3-20c9-7a10-af20-266373cbe7cf --- .../2026-09/traj_08wdfgur178x/summary.md | 31 +++++++++ .../2026-09/traj_08wdfgur178x/trajectory.json | 53 +++++++++++++++ CHANGELOG.md | 6 +- README.md | 7 ++ openapi.yaml | 12 ++++ packages/engine/CHANGELOG.md | 6 +- .../scripts/prove-github-cloud-inbound.mjs | 47 +++++++++++++ .../routes/__tests__/relayfileInbound.test.ts | 66 +++++++++++++++++++ .../engine/src/routes/relayfileInbound.ts | 32 +++++++-- 9 files changed, 254 insertions(+), 6 deletions(-) create mode 100644 .agentworkforce/trajectories/completed/2026-09/traj_08wdfgur178x/summary.md create mode 100644 .agentworkforce/trajectories/completed/2026-09/traj_08wdfgur178x/trajectory.json create mode 100644 packages/engine/scripts/prove-github-cloud-inbound.mjs diff --git a/.agentworkforce/trajectories/completed/2026-09/traj_08wdfgur178x/summary.md b/.agentworkforce/trajectories/completed/2026-09/traj_08wdfgur178x/summary.md new file mode 100644 index 00000000..ae79f9b4 --- /dev/null +++ b/.agentworkforce/trajectories/completed/2026-09/traj_08wdfgur178x/summary.md @@ -0,0 +1,31 @@ +# Trajectory: Align Relayfile inbound GitHub PR identity matching + +> **Status:** ✅ Completed +> **Confidence:** 93% +> **Started:** September 19, 2026 at 08:46 PM +> **Completed:** September 19, 2026 at 08:54 PM + +--- + +## Summary + +Added exact GitHub PR inbound matching with HMAC-bound authorization. 1153 engine tests, typecheck, build, and actual cloud Queue-to-signed-inbound-to-agent-inbox proof pass. Deploy receiver before cloud and reprovision with repository read authorization; no production changes. + +**Approach:** Standard approach + +--- + +## Key Decisions + +### Seal semantic GitHub PR authorization into callback URL and signing secret +- **Chose:** Seal semantic GitHub PR authorization into callback URL and signing secret +- **Reasoning:** Legacy receiver URLs must remain literal; a workspace-key-authorized new target uses a distinct HMAC derivation, so old secrets cannot enable semantic matching by query tampering. + +--- + +## Chapters + +### 1. Work +*Agent: default* + +- Seal semantic GitHub PR authorization into callback URL and signing secret: Seal semantic GitHub PR authorization into callback URL and signing secret diff --git a/.agentworkforce/trajectories/completed/2026-09/traj_08wdfgur178x/trajectory.json b/.agentworkforce/trajectories/completed/2026-09/traj_08wdfgur178x/trajectory.json new file mode 100644 index 00000000..c0ba1469 --- /dev/null +++ b/.agentworkforce/trajectories/completed/2026-09/traj_08wdfgur178x/trajectory.json @@ -0,0 +1,53 @@ +{ + "id": "traj_08wdfgur178x", + "version": 1, + "task": { + "title": "Align Relayfile inbound GitHub PR identity matching" + }, + "status": "completed", + "startedAt": "2026-09-20T03:46:15.106Z", + "completedAt": "2026-09-20T03:54:42.152Z", + "agents": [ + { + "name": "default", + "role": "lead", + "joinedAt": "2026-09-20T03:54:42.070Z" + } + ], + "chapters": [ + { + "id": "chap_i6kdencf1bu0", + "title": "Work", + "agentName": "default", + "startedAt": "2026-09-20T03:54:42.070Z", + "endedAt": "2026-09-20T03:54:42.152Z", + "events": [ + { + "ts": 1789876482070, + "type": "decision", + "content": "Seal semantic GitHub PR authorization into callback URL and signing secret: Seal semantic GitHub PR authorization into callback URL and signing secret", + "raw": { + "question": "Seal semantic GitHub PR authorization into callback URL and signing secret", + "chosen": "Seal semantic GitHub PR authorization into callback URL and signing secret", + "alternatives": [], + "reasoning": "Legacy receiver URLs must remain literal; a workspace-key-authorized new target uses a distinct HMAC derivation, so old secrets cannot enable semantic matching by query tampering." + }, + "significance": "high" + } + ] + } + ], + "retrospective": { + "summary": "Added exact GitHub PR inbound matching with HMAC-bound authorization. 1153 engine tests, typecheck, build, and actual cloud Queue-to-signed-inbound-to-agent-inbox proof pass. Deploy receiver before cloud and reprovision with repository read authorization; no production changes.", + "approach": "Standard approach", + "confidence": 0.93 + }, + "commits": [], + "filesChanged": [], + "projectId": "AgentWorkforce/relaycast", + "tags": [], + "_trace": { + "startRef": "af4428bf3411ecf6ef944f20a5a7158a81ea7387", + "endRef": "af4428bf3411ecf6ef944f20a5a7158a81ea7387" + } +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index bebe5d7a..5762ed61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,11 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Packages without a separate changelog are covered by the cross-package notes below. -## [Unreleased] +## [Unreleased - Patch] + +### Fixed + +- GitHub PR inbound subscriptions follow title changes and receive associated comments and reviews without widening repository or PR scope. ## [8.11.4] - 2026-09-19 diff --git a/README.md b/README.md index 16cb7e6e..f9c26e89 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,13 @@ Relaycast gives your agents shared channels, threads, DMs, reactions, files, sea See the [changelog](CHANGELOG.md) for release highlights and upgrade notes. +Relayfile inbound targets scoped to `/github/repos///pulls//**` +follow GitHub PR title changes and associated comments/reviews with the exact stable +PR reference. New targets carry a server-issued, HMAC-bound opt-in; legacy targets +retain literal matching. Deploy the Relaycast receiver and Relayfile cloud +companion, then reprovision the binding with full repository read permission. +Other globs retain literal path matching. + ## Quick Start Install: diff --git a/openapi.yaml b/openapi.yaml index 710694d2..ce2edd21 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -5921,6 +5921,11 @@ paths: description: > Provision a relayfile inbound callback URL and derived HMAC secret for a workspace channel/provider/path binding. + Numeric GitHub PR subtree bindings also match titled PR directories and + associated provider-sync events with the exact stable PR resource reference + in the same repository. Other path globs retain literal matching. + This requires a newly provisioned target with a server-issued HMAC-bound + github_pr_identity opt-in; legacy target URLs remain literal. tags: - Relayfile security: @@ -5995,6 +6000,13 @@ paths: required: true schema: type: string + - name: github_pr_identity + in: query + required: false + schema: + type: string + enum: ['1'] + description: Server-issued opt-in sealed into the target signing secret. Do not add to legacy URLs; reprovision the target with a workspace key. - name: X-Relay-Timestamp in: header required: true diff --git a/packages/engine/CHANGELOG.md b/packages/engine/CHANGELOG.md index deec39d9..fd7e83a3 100644 --- a/packages/engine/CHANGELOG.md +++ b/packages/engine/CHANGELOG.md @@ -7,7 +7,11 @@ See the [root changelog](../../CHANGELOG.md) for cross-package release highlight The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [Unreleased - Patch] + +### Fixed + +- Relayfile numeric PR subtree targets accept titled GitHub PR paths and related provider events carrying the exact stable PR reference. ## [8.11.4] - 2026-09-19 diff --git a/packages/engine/scripts/prove-github-cloud-inbound.mjs b/packages/engine/scripts/prove-github-cloud-inbound.mjs new file mode 100644 index 00000000..b15ce190 --- /dev/null +++ b/packages/engine/scripts/prove-github-cloud-inbound.mjs @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { createHmac } from 'node:crypto'; +import { createEngine } from '../dist/engine.js'; +import { createNodeRuntime } from '../dist/adapters/node/index.js'; + +// Consume actual Queue payloads exported by relayfile-cloud's Workerd proof. +// Build the engine and set RELAYFILE_PROOF_EVENTS to that JSON artifact first. +assert.ok(process.env.RELAYFILE_PROOF_EVENTS, 'RELAYFILE_PROOF_EVENTS is required'); +const events = JSON.parse(await readFile(process.env.RELAYFILE_PROOF_EVENTS, 'utf8')); +assert.equal(events.length, 5); +const runtime = createNodeRuntime({ dbPath: ':memory:', baseUrl: 'http://localhost:0', migrate: true, + config: { environment: 'test', relayfileInboundSecret: 'fixture-master' }, + presence: { sweepIntervalMs: 0 }, eventQueue: { pollIntervalMs: 0 } }); +runtime.webhookQueue.stop(); +const app = createEngine(runtime.deps); +async function request(path, token, body) { + const response = await app.request(path, { method: body ? 'POST' : 'GET', + headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) }, + ...(body ? { body: JSON.stringify(body) } : {}) }); + assert.ok(response.ok, `request failed: ${response.status}`); + return (await response.json()).data; +} +try { + const ws = await request('/v1/workspaces', null, { name: 'cloud-inbound-proof' }); + const key = ws.api_key ?? ws.key; + const agent = await request('/v1/agents', key, { name: 'subscriber' }); + await request('/v1/channels/general/join', agent.token, {}); + const target = await request('/v1/integrations/relayfile/inbound-target', key, { + channel: 'general', provider: 'github', path_glob: '/github/repos/AgentWorkforce/relay/pulls/1815/**', + }); + const delivered = []; + for (const event of events) { + const body = JSON.stringify(event); + const timestamp = String(Math.floor(Date.now() / 1000)); + const response = await app.request(target.url, { method: 'POST', body, headers: { + 'content-type': 'application/json', 'X-Relay-Event-Id': event.eventId, + 'X-Relay-Timestamp': timestamp, + 'X-Relay-Signature': createHmac('sha256', target.secret).update(`${timestamp}.${body}`).digest('hex'), + } }); + assert.equal(response.status, 201, `cloud event skipped: ${event.eventId}`); + delivered.push((await response.json()).data.message_id); + } + const inbox = await request('/v1/deliveries', agent.token); + assert.equal(inbox.filter(item => delivered.includes(item.message_id)).length, 5); + console.log('PASS: 5 real cloud Queue events reached the signed receiver and subscriber inbox'); +} finally { runtime.close(); } diff --git a/packages/engine/src/routes/__tests__/relayfileInbound.test.ts b/packages/engine/src/routes/__tests__/relayfileInbound.test.ts index f5ecf226..6c9370a8 100644 --- a/packages/engine/src/routes/__tests__/relayfileInbound.test.ts +++ b/packages/engine/src/routes/__tests__/relayfileInbound.test.ts @@ -71,6 +71,72 @@ class FailingKeyValueStore implements KeyValueStore { } describe('relayfile inbound bridge', () => { + it('delivers titled PR and related events to the subscribed channel without crossing identities', async () => { + const stack = makeStack(); + const ws = await createWorkspace(stack.app, 'github-pr-matching'); + const agent = await registerAgent(stack.app, ws.workspaceKey, 'github-reader'); + await stack.app.request('/v1/channels/general/join', { method: 'POST', headers: { authorization: `Bearer ${agent.token}` } }); + const targetResponse = await stack.app.request('/v1/integrations/relayfile/inbound-target', { + method: 'POST', headers: { authorization: `Bearer ${ws.workspaceKey}`, 'content-type': 'application/json' }, + body: JSON.stringify({ channel: 'general', provider: 'github', path_glob: '/github/repos/AgentWorkforce/relay/pulls/1815/**' }), + }); + expect(targetResponse.status).toBe(201); + const { data: target } = await targetResponse.json(); + const targetUrl = new URL(target.url); + expect(targetUrl.searchParams.get('github_pr_identity')).toBe('1'); + const legacyUrl = new URL(target.url); + legacyUrl.searchParams.delete('github_pr_identity'); + const legacySecret = await deriveRelayfileInboundSecret('relaycast-master', { + workspaceId: target.workspace_id, channelId: target.channel_id, provider: 'github', + pathGlob: '/github/repos/AgentWorkforce/relay/pulls/1815/**', + }); + const protectedBody = JSON.stringify({ eventId: 'evt_protected', type: 'file.updated', provider: 'github', origin: 'provider_sync', + path: '/github/repos/AgentWorkforce/relay/issues/1815__title/comments/1/meta.json', + resourceRef: '/github/repos/AgentWorkforce__relay/pulls/by-id/1815.json', githubPrIdentityAuthorized: true }); + // Legacy targets remain literal even if the signed body claims authorization. + const legacyResponse = await stack.app.request(legacyUrl.toString(), { method: 'POST', body: protectedBody, headers: signedHeaders(legacySecret, protectedBody) }); + expect(await legacyResponse.json()).toMatchObject({ data: { skipped: 'path_mismatch' } }); + // Adding the opt-in to a legacy callback or omitting it from a new callback + // cannot reuse either target's signature. + expect((await stack.app.request(target.url, { method: 'POST', body: protectedBody, headers: signedHeaders(legacySecret, protectedBody) })).status).toBe(401); + expect((await stack.app.request(legacyUrl.toString(), { method: 'POST', body: protectedBody, headers: signedHeaders(target.secret, protectedBody) })).status).toBe(401); + const prefix = '/github/repos/AgentWorkforce/relay'; + const ref = '/github/repos/AgentWorkforce__relay/pulls/by-id/1815.json'; + const matrix = [ + [`${prefix}/pulls/1815__original/meta.json`, undefined, 'pull_request.opened', true], + [`${prefix}/pulls/1815__renamed/meta.json`, undefined, 'pull_request.edited', true], + [`${prefix}/issues/1815__title/comments/5747223870/meta.json`, ref, 'issue_comment.created', true], + [`${prefix}/reviews/5259274136.json`, ref, 'pull_request_review.submitted', true], + [`${prefix}/comments/4055945245.json`, ref, 'pull_request_review_comment.created', true], + [`${prefix}/pulls/1815/meta.json`, undefined, 'pull_request.edited', true], + [`${prefix}-other/reviews/1.json`, ref, 'pull_request_review.submitted', false], + ['/github/repos/Other/relay/reviews/1.json', ref, 'pull_request_review.submitted', false], + [`${prefix}/pulls/18150__title/meta.json`, undefined, 'pull_request.edited', false], + [`${prefix}/reviews/1.json`, ref.replace('1815', '1816'), 'pull_request_review.submitted', false], + [`${prefix}/reviews/1.json`, ref.replace('AgentWorkforce__relay', 'Other__relay'), 'pull_request_review.submitted', false], + [`${prefix}/issues/1815__title/comments/1/meta.json`, undefined, 'issue_comment.created', false], + [`${prefix}/issues/1815__title/comments/1/meta.json`, ref.replace('/pulls/', '/issues/'), 'issue_comment.created', false], + ] as const; + const deliveredIds: string[] = []; + for (const [index, [path, resourceRef, providerEventType, expected]] of matrix.entries()) { + const eventId = `evt_github_${index}`; + const body = JSON.stringify({ eventId, type: 'file.updated', provider: 'github', origin: 'provider_sync', path, resourceRef, providerEventType, revision: eventId }); + const emit = () => stack.app.request(target.url, { method: 'POST', body, headers: { ...signedHeaders(target.secret, body), 'X-Relay-Event-Id': eventId } }); + const response = await emit(); + if (expected) { + expect(response.status).toBe(201); + const { data } = await response.json(); + deliveredIds.push(data.message_id); + expect(await (await emit()).json()).toMatchObject({ data: { replayed: true, message_id: data.message_id } }); + } else { + expect(await response.json()).toMatchObject({ data: { skipped: 'path_mismatch' } }); + } + } + const inbox = await stack.app.request('/v1/deliveries', { headers: { authorization: `Bearer ${agent.token}` } }); + const { data: queued } = await inbox.json(); + expect(queued.filter((delivery: { message_id: string }) => deliveredIds.includes(delivery.message_id))).toHaveLength(6); + }); + it('returns retryable overflow and accepts the same unique event after capacity recovers', async () => { const stack = makeStack({ depthCap: 1 }); const ws = await createWorkspace(stack.app, 'inbound-backpressure'); diff --git a/packages/engine/src/routes/relayfileInbound.ts b/packages/engine/src/routes/relayfileInbound.ts index 2c39a744..6a1c2ea7 100644 --- a/packages/engine/src/routes/relayfileInbound.ts +++ b/packages/engine/src/routes/relayfileInbound.ts @@ -104,15 +104,20 @@ relayfileInboundRoutes.post('/integrations/relayfile/inbound-target', requireWor const provider = normalizeProvider(parsed.data.provider); const pathGlob = normalizePathGlob(parsed.data.path_glob); + // This route requires a workspace key. Seal its authorization into the + // callback URL/secret; legacy target secrets cannot opt themselves in. + const githubPrIdentityAuthorized = provider === 'github' && /^\/github\/repos\/([^/*]+)\/([^/*]+)\/pulls\/[1-9]\d*\/\*\*$/.test(pathGlob); const secret = await deriveRelayfileInboundSecret(master, { workspaceId: workspace.id, channelId: channel.id, provider, pathGlob, + githubPrIdentityAuthorized, }); const url = new URL(`/v1/integrations/relayfile/inbound/${encodeURIComponent(workspace.id)}/${encodeURIComponent(channel.id)}`, c.req.url); url.searchParams.set('provider', provider); url.searchParams.set('path_glob', pathGlob); + if (githubPrIdentityAuthorized) url.searchParams.set('github_pr_identity', '1'); return jsonCreated(c, { url: url.toString(), @@ -135,6 +140,7 @@ relayfileInboundRoutes.post('/integrations/relayfile/inbound/:workspaceId/:chann return jsonError(c, 'bad_request', 'missing relayfile inbound route parameters', 400); } const pathGlob = normalizePathGlob(rawPathGlob); + const githubPrIdentityAuthorized = c.req.query('github_pr_identity') === '1'; const master = c.get('engine').config?.relayfileInboundSecret?.trim(); if (!master) { @@ -146,7 +152,7 @@ relayfileInboundRoutes.post('/integrations/relayfile/inbound/:workspaceId/:chann return jsonError(c, 'payload_too_large', 'relayfile event body exceeds maximum size', 413); } const rawBody = rawBodyResult.body; - const secret = await deriveRelayfileInboundSecret(master, { workspaceId, channelId, provider, pathGlob }); + const secret = await deriveRelayfileInboundSecret(master, { workspaceId, channelId, provider, pathGlob, githubPrIdentityAuthorized }); const verified = await verifyRelayfileSignature(c.req.raw.headers, rawBody, secret, Date.now()); if (!verified.ok) { logger.warn('relayfile inbound signature rejected', { workspace_id: workspaceId, channel_id: channelId, reason: verified.reason }); @@ -181,7 +187,7 @@ relayfileInboundRoutes.post('/integrations/relayfile/inbound/:workspaceId/:chann if (event.provider && normalizeProvider(event.provider) !== provider) { return jsonOk(c, { skipped: 'provider_mismatch' }); } - if (!eventMatchesGlob(event.path, pathGlob)) { + if (!eventMatchesSubscription(event, pathGlob, githubPrIdentityAuthorized)) { return jsonOk(c, { skipped: 'path_mismatch' }); } if (event.origin === 'agent_write') { @@ -338,9 +344,9 @@ async function getChannelById(db: AppEnv['Variables']['db'], workspaceId: string export async function deriveRelayfileInboundSecret( master: string, - input: { workspaceId: string; channelId: string; provider: string; pathGlob: string }, + input: { workspaceId: string; channelId: string; provider: string; pathGlob: string; githubPrIdentityAuthorized?: boolean }, ): Promise { - const label = `${SECRET_LABEL}:${input.workspaceId}:${input.channelId}:${normalizeProvider(input.provider)}:${normalizePathGlob(input.pathGlob)}`; + const label = `${SECRET_LABEL}:${input.workspaceId}:${input.channelId}:${normalizeProvider(input.provider)}:${normalizePathGlob(input.pathGlob)}${input.githubPrIdentityAuthorized ? ':github-pr-identity-v1' : ''}`; const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(master), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']); const signed = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(label)); return bytesToHex(new Uint8Array(signed)); @@ -470,6 +476,24 @@ function eventMatchesGlob(path: string, glob: string): boolean { return false; } +function eventMatchesSubscription(event: RelayfileEvent, glob: string, githubPrIdentityAuthorized: boolean): boolean { + if (eventMatchesGlob(event.path ?? '', glob)) return true; + if (!githubPrIdentityAuthorized || event.provider !== 'github' || event.origin !== 'provider_sync') return false; + + // Keep aligned with relayfile-cloud eventMatchesWebhookSubscription. Only + // whole numeric PR subtrees gain identity semantics; generic globs stay literal. + const match = /^\/github\/repos\/([^/*]+)\/([^/*]+)\/pulls\/([1-9]\d*)\/\*\*$/.exec(normalizePathGlob(glob)); + if (!match) return false; + const [, owner, repo, number] = match; + const repoPath = `/github/repos/${owner}/${repo}`; + const eventPath = normalizePathGlob(event.path ?? ''); + if (eventPath.split('/').some(segment => segment === '.' || segment === '..')) return false; + if (!eventPath.startsWith(`${repoPath}/`)) return false; + const pullSegment = eventPath.slice(`${repoPath}/pulls/`.length).split('/')[0]; + if (eventPath.startsWith(`${repoPath}/pulls/`) && pullSegment.startsWith(`${number}__`) && pullSegment.length > `${number}__`.length) return true; + return event.resourceRef === `/github/repos/${owner}__${repo}/pulls/by-id/${number}.json`; +} + function eventWorkspaceId(event: RelayfileEvent): string | undefined { const correlation = event.correlationId ?? ''; return correlation.startsWith('workspace:') ? correlation.slice('workspace:'.length) : undefined; From cd23824e552a72596175336ee62ecbe9cf5cc0ca Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Sat, 19 Sep 2026 21:03:32 -0700 Subject: [PATCH 2/4] fix(inbound): structurally separate semantic target signing domain Session-Id: 01a0bcd3-20c9-7a10-af20-266373cbe7cf --- .../routes/__tests__/relayfileInbound.test.ts | 20 +++++++++++++++++++ .../engine/src/routes/relayfileInbound.ts | 14 +++++++++---- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/packages/engine/src/routes/__tests__/relayfileInbound.test.ts b/packages/engine/src/routes/__tests__/relayfileInbound.test.ts index 6c9370a8..e5cd0eb2 100644 --- a/packages/engine/src/routes/__tests__/relayfileInbound.test.ts +++ b/packages/engine/src/routes/__tests__/relayfileInbound.test.ts @@ -71,6 +71,15 @@ class FailingKeyValueStore implements KeyValueStore { } describe('relayfile inbound bridge', () => { + it('uses unambiguous field boundaries for semantic target secrets', async () => { + const common = { provider: 'github', pathGlob: '/github/repos/o/r/pulls/1/**', githubPrIdentityAuthorized: true }; + const first = await deriveRelayfileInboundSecret('master', { ...common, workspaceId: 'ws:channel', channelId: 'id' }); + const second = await deriveRelayfileInboundSecret('master', { ...common, workspaceId: 'ws', channelId: 'channel:id' }); + expect(first).not.toBe(second); + const quoted = await deriveRelayfileInboundSecret('master', { ...common, workspaceId: 'ws","channel', channelId: 'id' }); + expect(quoted).not.toBe(first); + }); + it('delivers titled PR and related events to the subscribed channel without crossing identities', async () => { const stack = makeStack(); const ws = await createWorkspace(stack.app, 'github-pr-matching'); @@ -100,6 +109,17 @@ describe('relayfile inbound bridge', () => { // cannot reuse either target's signature. expect((await stack.app.request(target.url, { method: 'POST', body: protectedBody, headers: signedHeaders(legacySecret, protectedBody) })).status).toBe(401); expect((await stack.app.request(legacyUrl.toString(), { method: 'POST', body: protectedBody, headers: signedHeaders(target.secret, protectedBody) })).status).toBe(401); + const collisionSecret = await deriveRelayfileInboundSecret('relaycast-master', { + workspaceId: target.workspace_id, channelId: target.channel_id, provider: 'github', + pathGlob: '/github/repos/AgentWorkforce/relay/pulls/1815/**:github-pr-identity-v1', + }); + expect(collisionSecret).not.toBe(target.secret); + expect((await stack.app.request(target.url, { method: 'POST', body: protectedBody, headers: signedHeaders(collisionSecret, protectedBody) })).status).toBe(401); + for (const [index, provider] of ['GitHub', undefined].entries()) { + const eventId = `evt_provider_${index}`; + const body = JSON.stringify({ ...JSON.parse(protectedBody), provider, eventId }); + expect((await stack.app.request(target.url, { method: 'POST', body, headers: { ...signedHeaders(target.secret, body), 'X-Relay-Event-Id': eventId } })).status).toBe(201); + } const prefix = '/github/repos/AgentWorkforce/relay'; const ref = '/github/repos/AgentWorkforce__relay/pulls/by-id/1815.json'; const matrix = [ diff --git a/packages/engine/src/routes/relayfileInbound.ts b/packages/engine/src/routes/relayfileInbound.ts index 6a1c2ea7..ef07387a 100644 --- a/packages/engine/src/routes/relayfileInbound.ts +++ b/packages/engine/src/routes/relayfileInbound.ts @@ -187,7 +187,7 @@ relayfileInboundRoutes.post('/integrations/relayfile/inbound/:workspaceId/:chann if (event.provider && normalizeProvider(event.provider) !== provider) { return jsonOk(c, { skipped: 'provider_mismatch' }); } - if (!eventMatchesSubscription(event, pathGlob, githubPrIdentityAuthorized)) { + if (!eventMatchesSubscription(event, pathGlob, githubPrIdentityAuthorized, provider)) { return jsonOk(c, { skipped: 'path_mismatch' }); } if (event.origin === 'agent_write') { @@ -346,7 +346,11 @@ export async function deriveRelayfileInboundSecret( master: string, input: { workspaceId: string; channelId: string; provider: string; pathGlob: string; githubPrIdentityAuthorized?: boolean }, ): Promise { - const label = `${SECRET_LABEL}:${input.workspaceId}:${input.channelId}:${normalizeProvider(input.provider)}:${normalizePathGlob(input.pathGlob)}${input.githubPrIdentityAuthorized ? ':github-pr-identity-v1' : ''}`; + // New targets use a disjoint, structured domain. Appending a marker to the + // legacy glob would collide with a literal glob ending in that same marker. + const label = input.githubPrIdentityAuthorized + ? JSON.stringify([`${SECRET_LABEL}:github-pr-identity-v1`, input.workspaceId, input.channelId, normalizeProvider(input.provider), normalizePathGlob(input.pathGlob)]) + : `${SECRET_LABEL}:${input.workspaceId}:${input.channelId}:${normalizeProvider(input.provider)}:${normalizePathGlob(input.pathGlob)}`; const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(master), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']); const signed = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(label)); return bytesToHex(new Uint8Array(signed)); @@ -476,9 +480,11 @@ function eventMatchesGlob(path: string, glob: string): boolean { return false; } -function eventMatchesSubscription(event: RelayfileEvent, glob: string, githubPrIdentityAuthorized: boolean): boolean { +function eventMatchesSubscription(event: RelayfileEvent, glob: string, githubPrIdentityAuthorized: boolean, provider: string): boolean { if (eventMatchesGlob(event.path ?? '', glob)) return true; - if (!githubPrIdentityAuthorized || event.provider !== 'github' || event.origin !== 'provider_sync') return false; + // The route provider is normalized and authenticated by the target HMAC; + // the caller already rejected any conflicting event provider above. + if (!githubPrIdentityAuthorized || provider !== 'github' || event.origin !== 'provider_sync') return false; // Keep aligned with relayfile-cloud eventMatchesWebhookSubscription. Only // whole numeric PR subtrees gain identity semantics; generic globs stay literal. From c2b21461fa9dd88fe08773051abe12bd26c4ad01 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Sat, 19 Sep 2026 21:59:28 -0700 Subject: [PATCH 3/4] docs: explain GitHub PR matching and signing contracts Session-Id: 01a0bcd3-20c9-7a10-af20-266373cbe7cf --- .../2026-09/traj_5vufil7zdn8h/summary.md | 31 +++++++++++ .../2026-09/traj_5vufil7zdn8h/trajectory.json | 53 +++++++++++++++++++ .../scripts/prove-github-cloud-inbound.mjs | 4 ++ .../routes/__tests__/relayfileInbound.test.ts | 2 + .../engine/src/routes/relayfileInbound.ts | 10 ++++ 5 files changed, 100 insertions(+) create mode 100644 .agentworkforce/trajectories/completed/2026-09/traj_5vufil7zdn8h/summary.md create mode 100644 .agentworkforce/trajectories/completed/2026-09/traj_5vufil7zdn8h/trajectory.json diff --git a/.agentworkforce/trajectories/completed/2026-09/traj_5vufil7zdn8h/summary.md b/.agentworkforce/trajectories/completed/2026-09/traj_5vufil7zdn8h/summary.md new file mode 100644 index 00000000..701ff721 --- /dev/null +++ b/.agentworkforce/trajectories/completed/2026-09/traj_5vufil7zdn8h/summary.md @@ -0,0 +1,31 @@ +# Trajectory: Address full PR feedback audit and docstring coverage + +> **Status:** ✅ Completed +> **Confidence:** 90% +> **Started:** September 19, 2026 at 09:58 PM +> **Completed:** September 19, 2026 at 09:59 PM + +--- + +## Summary + +Added signing, matcher and proof-helper docstrings; 17 tests, typecheck, lint, build and cross-head proof pass. Review readiness remains pending current-head CI and review coverage. + +**Approach:** Standard approach + +--- + +## Key Decisions + +### Document all touched helpers and audit summaries and annotations as well as inline threads +- **Chose:** Document all touched helpers and audit summaries and annotations as well as inline threads +- **Reasoning:** Green status checks did not capture CodeRabbit docstring warning or rate-limited coverage. + +--- + +## Chapters + +### 1. Work +*Agent: default* + +- Document all touched helpers and audit summaries and annotations as well as inline threads: Document all touched helpers and audit summaries and annotations as well as inline threads diff --git a/.agentworkforce/trajectories/completed/2026-09/traj_5vufil7zdn8h/trajectory.json b/.agentworkforce/trajectories/completed/2026-09/traj_5vufil7zdn8h/trajectory.json new file mode 100644 index 00000000..98b2f2c1 --- /dev/null +++ b/.agentworkforce/trajectories/completed/2026-09/traj_5vufil7zdn8h/trajectory.json @@ -0,0 +1,53 @@ +{ + "id": "traj_5vufil7zdn8h", + "version": 1, + "task": { + "title": "Address full PR feedback audit and docstring coverage" + }, + "status": "completed", + "startedAt": "2026-09-20T04:58:22.609Z", + "completedAt": "2026-09-20T04:59:20.399Z", + "agents": [ + { + "name": "default", + "role": "lead", + "joinedAt": "2026-09-20T04:59:20.221Z" + } + ], + "chapters": [ + { + "id": "chap_us4lrn3lsslm", + "title": "Work", + "agentName": "default", + "startedAt": "2026-09-20T04:59:20.221Z", + "endedAt": "2026-09-20T04:59:20.399Z", + "events": [ + { + "ts": 1789880360222, + "type": "decision", + "content": "Document all touched helpers and audit summaries and annotations as well as inline threads: Document all touched helpers and audit summaries and annotations as well as inline threads", + "raw": { + "question": "Document all touched helpers and audit summaries and annotations as well as inline threads", + "chosen": "Document all touched helpers and audit summaries and annotations as well as inline threads", + "alternatives": [], + "reasoning": "Green status checks did not capture CodeRabbit docstring warning or rate-limited coverage." + }, + "significance": "high" + } + ] + } + ], + "retrospective": { + "summary": "Added signing, matcher and proof-helper docstrings; 17 tests, typecheck, lint, build and cross-head proof pass. Review readiness remains pending current-head CI and review coverage.", + "approach": "Standard approach", + "confidence": 0.9 + }, + "commits": [], + "filesChanged": [], + "projectId": "AgentWorkforce/relaycast", + "tags": [], + "_trace": { + "startRef": "cd23824e552a72596175336ee62ecbe9cf5cc0ca", + "endRef": "cd23824e552a72596175336ee62ecbe9cf5cc0ca" + } +} \ No newline at end of file diff --git a/packages/engine/scripts/prove-github-cloud-inbound.mjs b/packages/engine/scripts/prove-github-cloud-inbound.mjs index b15ce190..76aeb797 100644 --- a/packages/engine/scripts/prove-github-cloud-inbound.mjs +++ b/packages/engine/scripts/prove-github-cloud-inbound.mjs @@ -14,6 +14,10 @@ const runtime = createNodeRuntime({ dbPath: ':memory:', baseUrl: 'http://localho presence: { sweepIntervalMs: 0 }, eventQueue: { pollIntervalMs: 0 } }); runtime.webhookQueue.stop(); const app = createEngine(runtime.deps); +/** + * Call the in-memory engine with an optional bearer token and JSON body. + * Fail the proof on HTTP errors and return the successful response data. + */ async function request(path, token, body) { const response = await app.request(path, { method: body ? 'POST' : 'GET', headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) }, diff --git a/packages/engine/src/routes/__tests__/relayfileInbound.test.ts b/packages/engine/src/routes/__tests__/relayfileInbound.test.ts index e5cd0eb2..3f560ffd 100644 --- a/packages/engine/src/routes/__tests__/relayfileInbound.test.ts +++ b/packages/engine/src/routes/__tests__/relayfileInbound.test.ts @@ -19,6 +19,7 @@ interface Stack { const stacks: Stack[] = []; +/** Create an isolated SQLite engine and register its cleanup for the current test. */ function makeStack(opts: { kv?: KeyValueStore; depthCap?: number } = {}): Stack { const runtime = createNodeRuntime({ dbPath: ':memory:', @@ -43,6 +44,7 @@ afterEach(() => { for (const stack of stacks.splice(0)) stack.runtime.close(); }); +/** Sign the exact serialized event bytes with the target secret and supplied timestamp. */ function signedHeaders(secret: string, body: string, timestamp = String(Math.floor(Date.now() / 1000))) { return { 'content-type': 'application/json', diff --git a/packages/engine/src/routes/relayfileInbound.ts b/packages/engine/src/routes/relayfileInbound.ts index ef07387a..0d12805d 100644 --- a/packages/engine/src/routes/relayfileInbound.ts +++ b/packages/engine/src/routes/relayfileInbound.ts @@ -342,6 +342,11 @@ async function getChannelById(db: AppEnv['Variables']['db'], workspaceId: string return row ?? null; } +/** + * Derive a target-specific HMAC secret binding workspace, channel, provider and glob. + * Authorized PR targets use a versioned JSON tuple so field boundaries and the + * semantic opt-in cannot collide with legacy literal-target labels. + */ export async function deriveRelayfileInboundSecret( master: string, input: { workspaceId: string; channelId: string; provider: string; pathGlob: string; githubPrIdentityAuthorized?: boolean }, @@ -480,6 +485,11 @@ function eventMatchesGlob(path: string, glob: string): boolean { return false; } +/** + * Match literal paths first, then authorized GitHub PR identities within one repo. + * The provider comes from the authenticated target; only provider-sync events may + * use titled pull paths or stable PR references to match sibling event layouts. + */ function eventMatchesSubscription(event: RelayfileEvent, glob: string, githubPrIdentityAuthorized: boolean, provider: string): boolean { if (eventMatchesGlob(event.path ?? '', glob)) return true; // The route provider is normalized and authenticated by the target HMAC; From 3b7b6d0c9e4f50a6b5f8b20d901b38d399960b16 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Sat, 19 Sep 2026 22:08:08 -0700 Subject: [PATCH 4/4] docs: document inbound handlers and replay helper Session-Id: 01a0bcd3-20c9-7a10-af20-266373cbe7cf --- .../routes/__tests__/relayfileInbound.test.ts | 1 + packages/engine/src/routes/relayfileInbound.ts | 16 ++++++++++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/engine/src/routes/__tests__/relayfileInbound.test.ts b/packages/engine/src/routes/__tests__/relayfileInbound.test.ts index 3f560ffd..1f4e5d88 100644 --- a/packages/engine/src/routes/__tests__/relayfileInbound.test.ts +++ b/packages/engine/src/routes/__tests__/relayfileInbound.test.ts @@ -143,6 +143,7 @@ describe('relayfile inbound bridge', () => { for (const [index, [path, resourceRef, providerEventType, expected]] of matrix.entries()) { const eventId = `evt_github_${index}`; const body = JSON.stringify({ eventId, type: 'file.updated', provider: 'github', origin: 'provider_sync', path, resourceRef, providerEventType, revision: eventId }); + /** Send the same signed event again to verify delivery and replay deduplication. */ const emit = () => stack.app.request(target.url, { method: 'POST', body, headers: { ...signedHeaders(target.secret, body), 'X-Relay-Event-Id': eventId } }); const response = await emit(); if (expected) { diff --git a/packages/engine/src/routes/relayfileInbound.ts b/packages/engine/src/routes/relayfileInbound.ts index 0d12805d..2b2a58eb 100644 --- a/packages/engine/src/routes/relayfileInbound.ts +++ b/packages/engine/src/routes/relayfileInbound.ts @@ -86,7 +86,13 @@ interface RelayfileEventPublic { snapshot?: RelayfileSnapshot; } -relayfileInboundRoutes.post('/integrations/relayfile/inbound-target', requireWorkspaceKey, rateLimit, async (c) => { +relayfileInboundRoutes.post('/integrations/relayfile/inbound-target', requireWorkspaceKey, rateLimit, +/** + * Provision a channel callback using workspace-key authority. + * Numeric GitHub PR targets receive an HMAC-bound semantic opt-in; other targets + * retain literal matching. The response returns the callback and signing secret. + */ +async (c) => { const parsed = await parseJsonBody(c, createTargetSchema, 'invalid relayfile inbound target body'); if (!parsed.ok) return parsed.response; @@ -130,7 +136,13 @@ relayfileInboundRoutes.post('/integrations/relayfile/inbound-target', requireWor }); }); -relayfileInboundRoutes.post('/integrations/relayfile/inbound/:workspaceId/:channelId', async (c) => { +relayfileInboundRoutes.post('/integrations/relayfile/inbound/:workspaceId/:channelId', +/** + * Verify a Relayfile delivery before matching it against its authenticated target. + * Accepted events become channel messages and subscriber deliveries; replayed + * events are deduplicated and nonmatching events receive an explicit skip result. + */ +async (c) => { const logger = getRequestLogger(c, 'relayfile.inbound'); const workspaceId = c.req.param('workspaceId'); const channelId = c.req.param('channelId');