From 98428049f7169336d8c9839738fa6d30b87f706d Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Sat, 18 Jul 2026 13:21:12 +0200 Subject: [PATCH] fix(runtime): isolate Relaycast agent auth --- packages/delivery/src/delivery.ts | 2 +- packages/delivery/src/relaycast.test.ts | 76 ++++++++++++++++++++++++- packages/delivery/src/relaycast.ts | 11 ++-- packages/delivery/src/types.ts | 4 +- packages/runtime/src/local-preview.ts | 1 + packages/runtime/src/relay.test.ts | 23 +++++++- packages/runtime/src/relay.ts | 9 +-- 7 files changed, 110 insertions(+), 16 deletions(-) diff --git a/packages/delivery/src/delivery.ts b/packages/delivery/src/delivery.ts index 314325ee..36778393 100644 --- a/packages/delivery/src/delivery.ts +++ b/packages/delivery/src/delivery.ts @@ -69,7 +69,7 @@ export function createDelivery( : undefined); // Relaycast reply: address from the inbound event, client from the injected - // sender or the default env-backed one (POST /v1/dm with RELAY_API_KEY). + // sender or the default env-backed one (POST /v1/dm with a Relaycast token). const relaycast = targets.includes('relaycast') && transports?.relaycast?.to ? { to: transports.relaycast.to, diff --git a/packages/delivery/src/relaycast.test.ts b/packages/delivery/src/relaycast.test.ts index f50a2e1f..0efd07a7 100644 --- a/packages/delivery/src/relaycast.test.ts +++ b/packages/delivery/src/relaycast.test.ts @@ -2,7 +2,11 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { createDelivery } from './delivery.js'; -import { resolveRelaycastUrl, DEFAULT_RELAYCAST_URL } from './relaycast.js'; +import { + defaultRelaycastSender, + resolveRelaycastUrl, + DEFAULT_RELAYCAST_URL +} from './relaycast.js'; import type { WorkforceCtx } from '@agentworkforce/runtime'; import type { RelaycastSender } from './types.js'; @@ -10,6 +14,40 @@ function makeCtx(inputs: Record = {}): WorkforceCtx { return { persona: { inputs, inputSpecs: {} }, log: () => {} } as unknown as WorkforceCtx; } +type Captured = { url: string; init: RequestInit }; + +function stubFetch(response: () => Response): { calls: Captured[]; restore: () => void } { + const calls: Captured[] = []; + const original = globalThis.fetch; + globalThis.fetch = (async (url: string | URL, init: RequestInit = {}) => { + calls.push({ url: String(url), init }); + return response(); + }) as typeof fetch; + return { calls, restore: () => { globalThis.fetch = original; } }; +} + +function withRelayEnv( + vars: Record, + fn: () => Promise +): Promise { + const keys = ['WORKFORCE_AGENT_TOKEN', 'RELAY_AGENT_TOKEN', 'RELAY_API_KEY']; + const saved: Record = {}; + for (const key of keys) { + saved[key] = process.env[key]; + delete process.env[key]; + } + for (const [key, value] of Object.entries(vars)) { + if (value !== undefined) process.env[key] = value; + } + return fn().finally(() => { + for (const key of keys) { + saved[key] === undefined + ? delete process.env[key] + : (process.env[key] = saved[key]); + } + }); +} + test('DEFAULT_RELAYCAST_URL is cast.agentrelay.com', () => { assert.equal(DEFAULT_RELAYCAST_URL, 'https://cast.agentrelay.com'); }); @@ -49,6 +87,42 @@ test('relaycast target DMs the inbound sender and returns a RelaycastRef', async assert.deepEqual(res.refs, [{ provider: 'relaycast', to: 'local-tester', messageId: 'm1' }]); }); +test('default sender prefers RELAY_AGENT_TOKEN over workflow and deprecated agent-token aliases', async () => { + await withRelayEnv({ + RELAY_AGENT_TOKEN: 'tok_agent', + WORKFORCE_AGENT_TOKEN: 'tok_workflow', + RELAY_API_KEY: 'tok_legacy_agent' + }, async () => { + const { calls, restore } = stubFetch(() => + new Response(JSON.stringify({ ok: true, data: { message: { id: 'm2' } } }), { status: 200 }) + ); + try { + const result = await defaultRelaycastSender(makeCtx()).dm('peer', 'hello'); + assert.deepEqual(result, { ok: true, messageId: 'm2' }); + assert.equal(calls.length, 1); + assert.equal( + (calls[0].init.headers as Record).authorization, + 'Bearer tok_agent' + ); + } finally { + restore(); + } + }); +}); + +test('default sender never sends a workflow token to Relaycast', async () => { + await withRelayEnv({ WORKFORCE_AGENT_TOKEN: 'tok_workflow' }, async () => { + const { calls, restore } = stubFetch(() => new Response('{}', { status: 200 })); + try { + const result = await defaultRelaycastSender(makeCtx()).dm('peer', 'hello'); + assert.deepEqual(result, { ok: false }); + assert.equal(calls.length, 0); + } finally { + restore(); + } + }); +}); + test('relaycast is NOT a target unless a reply address is supplied (event-driven, not config)', () => { assert.equal(createDelivery(makeCtx(), {}).targets.includes('relaycast'), false); // Even with slack configured, relaycast only appears when transports.relaycast.to is set. diff --git a/packages/delivery/src/relaycast.ts b/packages/delivery/src/relaycast.ts index f99e2a06..f551743a 100644 --- a/packages/delivery/src/relaycast.ts +++ b/packages/delivery/src/relaycast.ts @@ -20,14 +20,13 @@ export function resolveRelaycastUrl(): string { /** * Resolve the token to authenticate relaycast agent actions (DMs). `/v1/dm` is - * secured with the AGENT token, not the workspace key — so prefer the agent - * token and only fall back to the workspace `RELAY_API_KEY` (which lets tests - * and single-identity boxes still work). Mirrors the runtime's agent-token - * resolution order. + * secured with an agent credential, not the Relayfile/workflow token. Prefer + * `RELAY_AGENT_TOKEN`. `RELAY_API_KEY` is a deprecated compatibility alias and + * works here only when it contains an agent-scoped token; workspace bootstrap + * keys cannot authenticate this endpoint. Mirrors the runtime's resolution. */ function resolveRelayAgentToken(): string | undefined { return ( - process.env.WORKFORCE_AGENT_TOKEN?.trim() || process.env.RELAY_AGENT_TOKEN?.trim() || process.env.RELAY_API_KEY?.trim() || undefined @@ -47,7 +46,7 @@ export function defaultRelaycastSender(ctx: WorkforceCtx): RelaycastSender { async dm(to: string, text: string): Promise<{ ok: boolean; messageId?: string }> { if (!token) { ctx.log?.('warn', 'delivery.relaycast.no-token', { - reason: 'no agent token (WORKFORCE_AGENT_TOKEN/RELAY_AGENT_TOKEN/RELAY_API_KEY) in the agent box' + reason: 'no Relaycast token (RELAY_AGENT_TOKEN/RELAY_API_KEY) in the agent box' }); return { ok: false }; } diff --git a/packages/delivery/src/types.ts b/packages/delivery/src/types.ts index 5f0a4924..7f4a048b 100644 --- a/packages/delivery/src/types.ts +++ b/packages/delivery/src/types.ts @@ -64,7 +64,9 @@ export interface DeliveryOptions { /** * Minimal seam for sending a relaycast DM back to a peer agent. The default - * implementation posts `POST /v1/dm` with the box's injected `RELAY_API_KEY`; + * implementation posts `POST /v1/dm` with the box's injected + * `RELAY_AGENT_TOKEN` (or deprecated `RELAY_API_KEY` alias when it contains an + * agent-scoped token); * tests inject a mock. Unlike Slack/Telegram (config-driven via persona * inputs), the relaycast reply address is EVENT-driven — `to` is the inbound * message's sender, supplied by the caller. diff --git a/packages/runtime/src/local-preview.ts b/packages/runtime/src/local-preview.ts index 7fb37b76..9cab53fa 100644 --- a/packages/runtime/src/local-preview.ts +++ b/packages/runtime/src/local-preview.ts @@ -64,6 +64,7 @@ const SECRET_INPUT_NAMES = new Set([ 'OPENAI_API_KEY', 'OPENCODE_API_KEY', 'RELAYFILE_TOKEN', + 'RELAY_AGENT_TOKEN', 'RELAY_API_KEY', 'SLACK_BOT_TOKEN', 'SLACK_TOKEN', diff --git a/packages/runtime/src/relay.test.ts b/packages/runtime/src/relay.test.ts index e1f37373..7bb3ae24 100644 --- a/packages/runtime/src/relay.test.ts +++ b/packages/runtime/src/relay.test.ts @@ -48,12 +48,29 @@ test('dm posts /v1/dm with bearer agent token + {to,text}, unwraps {ok,data} id' }); }); -test('agent token precedence: WORKFORCE_AGENT_TOKEN over RELAY_API_KEY', async () => { - await withEnv({ WORKFORCE_AGENT_TOKEN: 'tok_wf', RELAY_API_KEY: 'rk_live_x' }, async () => { +test('agent token precedence: RELAY_AGENT_TOKEN over workflow and deprecated agent-token aliases', async () => { + await withEnv({ + RELAY_AGENT_TOKEN: 'tok_agent', + WORKFORCE_AGENT_TOKEN: 'tok_workflow', + RELAY_API_KEY: 'tok_legacy_agent' + }, async () => { const { calls, restore } = stubFetch(() => new Response(JSON.stringify({ ok: true, data: { id: 'm2' } }), { status: 200 })); try { await buildRelayContext(noopLog).dm('p', 'hi'); - assert.equal((calls[0].init.headers as Record).authorization, 'Bearer tok_wf'); + assert.equal((calls[0].init.headers as Record).authorization, 'Bearer tok_agent'); + } finally { + restore(); + } + }); +}); + +test('workflow token alone is never sent to Relaycast', async () => { + await withEnv({ WORKFORCE_AGENT_TOKEN: 'tok_workflow' }, async () => { + const { calls, restore } = stubFetch(() => new Response('{}', { status: 200 })); + try { + const res = await buildRelayContext(noopLog).dm('p', 'hi'); + assert.deepEqual(res, { ok: false }); + assert.equal(calls.length, 0); } finally { restore(); } diff --git a/packages/runtime/src/relay.ts b/packages/runtime/src/relay.ts index 5e236a1d..9f004cf1 100644 --- a/packages/runtime/src/relay.ts +++ b/packages/runtime/src/relay.ts @@ -18,12 +18,13 @@ function resolveRelaycastUrl(env: NodeJS.ProcessEnv): string { /** * Relaycast agent actions (`/v1/dm`, channel posts) are authenticated with the - * AGENT token, not the workspace key — prefer it, falling back to the workspace - * `RELAY_API_KEY` so single-identity boxes and tests still work. + * Relaycast token, never the Relayfile/workflow `WORKFORCE_AGENT_TOKEN`. + * `RELAY_API_KEY` is a deprecated compatibility alias and works here only + * when it contains an agent-scoped token; workspace bootstrap keys cannot + * authenticate these endpoints. */ function resolveAgentToken(env: NodeJS.ProcessEnv): string | undefined { return ( - env.WORKFORCE_AGENT_TOKEN?.trim() || env.RELAY_AGENT_TOKEN?.trim() || env.RELAY_API_KEY?.trim() || undefined @@ -55,7 +56,7 @@ export function buildRelayContext(log: Log, env: NodeJS.ProcessEnv = process.env async function send(path: string, body: unknown, action: string): Promise { if (!token) { log('warn', `relay.${action}.no-token`, { - reason: 'no agent token (WORKFORCE_AGENT_TOKEN/RELAY_AGENT_TOKEN/RELAY_API_KEY) in the box' + reason: 'no Relaycast token (RELAY_AGENT_TOKEN/RELAY_API_KEY) in the box' }); return { ok: false }; }