Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/delivery/src/delivery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
76 changes: 75 additions & 1 deletion packages/delivery/src/relaycast.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,52 @@ 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';

function makeCtx(inputs: Record<string, string> = {}): 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<string, string | undefined>,
fn: () => Promise<void>
): Promise<void> {
const keys = ['WORKFORCE_AGENT_TOKEN', 'RELAY_AGENT_TOKEN', 'RELAY_API_KEY'];
const saved: Record<string, string | undefined> = {};
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');
});
Expand Down Expand Up @@ -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<string, string>).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.
Expand Down
11 changes: 5 additions & 6 deletions packages/delivery/src/relaycast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 };
}
Expand Down
4 changes: 3 additions & 1 deletion packages/delivery/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions packages/runtime/src/local-preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
23 changes: 20 additions & 3 deletions packages/runtime/src/relay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>).authorization, 'Bearer tok_wf');
assert.equal((calls[0].init.headers as Record<string, string>).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();
}
Expand Down
9 changes: 5 additions & 4 deletions packages/runtime/src/relay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines 26 to 30

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The file packages/delivery/src/relaycast.ts contains a mirrored implementation of token resolution (resolveRelayAgentToken) and warning logs that still reference WORKFORCE_AGENT_TOKEN. Since the goal of this PR is to isolate Relaycast agent auth and reject WORKFORCE_AGENT_TOKEN from being sent to /v1/dm, please update packages/delivery/src/relaycast.ts as well to prevent the same authentication issue there.

Expand Down Expand Up @@ -55,7 +56,7 @@ export function buildRelayContext(log: Log, env: NodeJS.ProcessEnv = process.env
async function send(path: string, body: unknown, action: string): Promise<RelaySendResult> {
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 };
}
Expand Down
Loading