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
30 changes: 2 additions & 28 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,37 +5,11 @@ All notable changes to Agent Relay will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased - Minor]

### Added

- `fleet spawn` without placement options starts locally in the caller's exact
directory; `--auto-place` explicitly requests automatic fleet placement.
- Plain `fleet spawn --sandbox` starts from a clean, pushed GitHub `HEAD`, mounts
its decoded source tree and `.skills` through Relayfile, maps the caller's
relative directory, and keeps the tree synchronized as GitHub changes flow
through the connected workspace integration.
- `fleet spawn --sandbox` preserves tracked files, symlinks, and executable modes
in the mounted source tree.
- `fleet spawn --sandbox --checkout` opts into a separate static Git clone at
the exact pushed `HEAD` when a task needs Git metadata or checkout semantics.
- `node agent attach <name>` automatically routes to a unique live Fleet node;
ambiguous placements require `--node`.
- `fleet spawn --sandbox` keeps temporary routing credentials out of project files.

### Fixed

- Windows credential storage allows bounded cold PowerShell startup time while retaining strict ACL validation.

## [12.1.1] - 2026-09-15

### Changed

- Run the proof arms with Claude instead of Codex
## [Unreleased - Patch]

### Fixed

- Transfer proof brokers through bounded run storage
- Direct-message CLI and MCP receipts separate directory name matches from unconfirmed recipient delivery, preserving the queued message ID without reporting reachability from a roster match.

## [12.1.0] - 2026-09-12

Expand Down
7 changes: 5 additions & 2 deletions packages/cli/src/cli/commands/relaycast-groups.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,8 +176,8 @@ describe('SDK-backed CLI groups', () => {
expect(relay.messages.send).toHaveBeenCalledWith({ channel: 'ops', text: 'hello' });
});

// MUST-NOT-FIRE: an independently resolved live recipient remains a normal
// successful CLI command, without the loud failure guard.
// A directory match remains a successful enqueue without claiming the
// recipient's transport or session can receive it.
it('message dm send exits cleanly when the workspace roster resolves the recipient', async () => {
const { program, relay, workspaceRelay, log, error, exit } = harness(registerMessageCommands);
await program.parseAsync(['message', 'dm', 'send', 'lead', 'hi'], { from: 'user' });
Expand All @@ -186,6 +186,9 @@ describe('SDK-backed CLI groups', () => {
expect(relay.messages.direct).toHaveBeenCalledWith({ to: 'lead', text: 'hi' });
expect(log).toHaveBeenCalledWith(expect.stringContaining('"status": "queued_unconfirmed"'));
expect(log).toHaveBeenCalledWith(expect.stringContaining('"resolvedRecipient": "lead"'));
expect(log).toHaveBeenCalledWith(expect.stringContaining('"directoryMatched": true'));
expect(log).toHaveBeenCalledWith(expect.stringContaining('"recipientMatched": null'));
expect(log).toHaveBeenCalledWith(expect.stringContaining('"deliveryConfirmed": false'));
expect(error).not.toHaveBeenCalled();
expect(exit).not.toHaveBeenCalled();
});
Expand Down
25 changes: 17 additions & 8 deletions packages/cli/src/cli/lib/message-delivery-receipts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ export type DirectMessageDeliveryReceipt = Record<string, unknown> & {
mode: DirectMessageMode;
requestedRecipient: string;
resolvedRecipient: string | null;
directoryMatched: boolean | null;
recipientMatched: boolean | null;
deliveryConfirmed: false;
readConfirmed: false;
note: string;
};
Expand Down Expand Up @@ -48,31 +50,38 @@ export function directMessageReceipt(
const message = asRecord(value);
const messageWithoutUntrustedTarget = { ...message };
delete messageWithoutUntrustedTarget.target;
const recipientMatched = resolvedRecipient ? resolvedRecipient === requestedRecipient : null;
const directoryMatched = resolvedRecipient === undefined ? null : resolvedRecipient === requestedRecipient;
// Directory equality proves the address exists, not that its current node,
// provider socket, or agent session can receive the queued message.
const recipientMatched = directoryMatched === false ? false : null;
const status =
recipientMatched === null
directoryMatched === null
? 'recipient_unresolved'
: recipientMatched
: directoryMatched
? 'queued_unconfirmed'
: 'recipient_mismatch';
const note =
recipientMatched === null
directoryMatched === null
? `Recipient resolution was unavailable for ${requestedRecipient}; enqueue is not reported as successful delivery.`
: recipientMatched
: directoryMatched
? mode === 'steer'
? 'Queued as an immediate injection request that may interrupt active work. This receipt does not confirm delivery or reading; call get_message_readers with the message id.'
: "Queued for injection at the recipient's next safe idle boundary. It can remain unread while the recipient is busy. This receipt does not confirm delivery or reading; call get_message_readers with the message id."
? 'Enqueued; routing and injection are unconfirmed. Mode steer requests immediate injection. Check get_message_readers with this ID before resending; retries may duplicate delivery.'
: 'Enqueued; routing and injection are unconfirmed. Mode wait requests the next safe idle boundary. Check get_message_readers with this ID before resending; retries may duplicate delivery.'
: `Recipient mismatch: requested ${requestedRecipient}, but the directory resolved ${resolvedRecipient}.`;

return {
...messageWithoutUntrustedTarget,
...(resolvedRecipient ? { target: { kind: 'agent' as const, agentName: resolvedRecipient } } : {}),
...(resolvedRecipient !== undefined
? { target: { kind: 'agent' as const, agentName: resolvedRecipient } }
: {}),
delivery: {
status,
mode,
requestedRecipient,
resolvedRecipient: resolvedRecipient ?? null,
directoryMatched,
recipientMatched,
deliveryConfirmed: false,
readConfirmed: false,
note,
},
Expand Down
29 changes: 26 additions & 3 deletions packages/cli/src/cli/mcp/messaging-tools.delivery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,23 @@ describe('exact agent-name resolution', () => {
});

describe('direct message delivery receipts', () => {
it.each([
{ requested: '', status: 'queued_unconfirmed', directoryMatched: true },
{ requested: 'worker', status: 'recipient_mismatch', directoryMatched: false },
])('preserves an empty resolved name for $status', ({ requested, status, directoryMatched }) => {
const resolved = resolveExactAgentName([{ name: '' }], '');
expect(resolved).toBe('');
const receipt = directMessageReceipt({ id: 'empty-name' }, requested, 'wait', resolved);
expect(receipt.target).toEqual({ kind: 'agent', agentName: '' });
expect(receipt.delivery).toMatchObject({
status,
resolvedRecipient: '',
directoryMatched,
recipientMatched: directoryMatched ? null : false,
deliveryConfirmed: false,
});
});

it('labels default wait-mode sends as queued and preserves the exact requested recipient', () => {
const receipt = directMessageReceipt(
{ id: 'msg_wait', text: 'status', agentName: 'sender' },
Expand All @@ -68,7 +85,9 @@ describe('direct message delivery receipts', () => {
mode: 'wait',
requestedRecipient: 'chief-khaliq',
resolvedRecipient: 'chief-khaliq',
recipientMatched: true,
directoryMatched: true,
recipientMatched: null,
deliveryConfirmed: false,
readConfirmed: false,
},
});
Expand Down Expand Up @@ -110,6 +129,8 @@ describe('direct message delivery receipts', () => {
requestedRecipient: 'chief-khaliq',
resolvedRecipient: 'chief',
recipientMatched: false,
directoryMatched: false,
deliveryConfirmed: false,
},
});
expect(receipt.delivery.note).toContain('Recipient mismatch');
Expand Down Expand Up @@ -184,9 +205,11 @@ describe('compact direct message receipts', () => {
mode: 'wait',
requestedRecipient: 'chief',
resolvedRecipient: 'chief',
recipientMatched: true,
directoryMatched: true,
recipientMatched: null,
deliveryConfirmed: false,
readConfirmed: false,
note: "Queued for injection at the recipient's next safe idle boundary. It can remain unread while the recipient is busy. This receipt does not confirm delivery or reading; call get_message_readers with the message id.",
note: 'Enqueued; routing and injection are unconfirmed. Mode wait requests the next safe idle boundary. Check get_message_readers with this ID before resending; retries may duplicate delivery.',
},
});
});
Expand Down
38 changes: 37 additions & 1 deletion packages/cli/src/cli/mcp/messaging-tools.protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ describe('messaging delivery receipts over MCP', () => {
registerMessagingTools(
server,
() => ({ dm, readers }) as never,
async () => [{ name: 'chief' }, { name: 'chief-khaliq' }]
async () => [{ name: 'chief' }, { name: 'chief-khaliq', status: 'offline' }]
);

const client = new Client({ name: 'messaging-client-test', version: '1.0.0' });
Expand All @@ -43,9 +43,14 @@ describe('messaging delivery receipts over MCP', () => {
mode: 'wait',
requestedRecipient: 'chief-khaliq',
resolvedRecipient: 'chief-khaliq',
directoryMatched: true,
recipientMatched: null,
deliveryConfirmed: false,
readConfirmed: false,
},
});
expect(sent.isError).not.toBe(true);
expect(dm).toHaveBeenCalledTimes(1);

const unresolved = await client.callTool({
name: 'send_dm',
Expand All @@ -59,6 +64,8 @@ describe('messaging delivery receipts over MCP', () => {
requestedRecipient: 'missing-agent',
resolvedRecipient: null,
recipientMatched: null,
directoryMatched: null,
deliveryConfirmed: false,
readConfirmed: false,
},
});
Expand Down Expand Up @@ -131,6 +138,35 @@ describe('messaging delivery receipts over MCP', () => {
}
});

it('preserves a rejected send as an error even when the directory matched', async () => {
const dm = vi.fn(async () => {
throw new Error('agent_not_found: recipient no longer exists');
});
const server = new McpServer({ name: 'messaging-rejected-test', version: '1.0.0' });
registerMessagingTools(
server,
() => ({ dm }) as never,
async () => [{ name: 'released-agent' }]
);
const client = new Client({ name: 'messaging-rejected-client', version: '1.0.0' });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
try {
await server.connect(serverTransport);
await client.connect(clientTransport);
const result = await client.callTool({
name: 'send_dm',
arguments: { to: 'released-agent', text: 'hello' },
});
expect(result.isError).toBe(true);
expect(JSON.stringify(result.content)).toContain('agent_not_found');
expect(result.structuredContent).toBeUndefined();
expect(dm).toHaveBeenCalledTimes(1);
} finally {
await client.close();
await server.close();
}
});

it('stamps the current replay session on channel, thread, direct, and group messages', async () => {
vi.stubEnv('RELAY_ATTEST_SESSION_ID', '11111111-1111-4111-8111-111111111111');
const send = vi.fn(async () => ({ id: 'msg_channel', text: 'channel' }));
Expand Down
11 changes: 10 additions & 1 deletion packages/cli/src/cli/mcp/messaging-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,15 @@ const directMessageResult = z.looseObject({
mode: z.enum(['wait', 'steer']),
requestedRecipient: z.string(),
resolvedRecipient: z.string().nullable(),
recipientMatched: z.boolean().nullable(),
directoryMatched: z
.boolean()
.nullable()
.describe('Exact directory name match; does not prove reachability'),
recipientMatched: z
.boolean()
.nullable()
.describe('False for a name mismatch; null while recipient delivery is unconfirmed'),
deliveryConfirmed: z.literal(false),
readConfirmed: z.literal(false),
note: z.string(),
}),
Expand Down Expand Up @@ -324,6 +332,7 @@ export function registerMessagingTools(
'The receipt deliberately does not echo the message body back; you already have the text you sent. ' +
'Returns a tool error while preserving that receipt when the recipient cannot be resolved exactly. ' +
'A message ID confirms enqueue, not injection or reading; use "get_message_readers" to confirm consumption. ' +
'directoryMatched reports only address resolution; recipientMatched is null on an unconfirmed enqueue. ' +
'Mode "wait" (the default) waits for the recipient\'s next safe idle boundary and can remain unread while they are busy. ' +
'Mode "steer" requests immediate injection and may interrupt active work.',
inputSchema: {
Expand Down
78 changes: 78 additions & 0 deletions tests/fixtures/receipt-proof-node-runtime.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { describe, expect, it, vi } from 'vitest';
import { ensureTypeStrippingRuntime } from '../relayflows/cases/1593-receipt-reachability/node-runtime.mjs';

describe('receipt proof runtime selection (unit only)', () => {
it('uses an available built-in without starting another process', () => {
const spawn = vi.fn();
expect(
ensureTypeStrippingRuntime({
moduleApi: { stripTypeScriptTypes() {} },
scriptPath: '/case/run.mjs',
env: {},
spawn,
})
).toBeUndefined();
expect(spawn).not.toHaveBeenCalled();
});

it.each([0, 7])('propagates pinned runtime exit %i when the API is unavailable', (status) => {
const spawn = vi.fn(() => ({ status, error: undefined, signal: null }));
expect(
ensureTypeStrippingRuntime({
moduleApi: {},
scriptPath: '/case with spaces/run.mjs',
env: { TMPDIR: '/isolated' },
spawn,
})
).toBe(status);
expect(spawn).toHaveBeenCalledExactlyOnceWith(
'npm',
[
'exec',
'--yes',
'--ignore-scripts=false',
'--package=node@22.14.0',
'--',
'node',
'/case with spaces/run.mjs',
],
{
env: { TMPDIR: '/isolated', RELAY_PR_PROOF_NODE_BOOTSTRAPPED: '22.14.0' },
stdio: 'inherit',
timeout: 120_000,
}
);
});

it('fails without retry when the pinned process still lacks the API', () => {
const spawn = vi.fn();
expect(() =>
ensureTypeStrippingRuntime({
moduleApi: {},
scriptPath: '/case/run.mjs',
env: { RELAY_PR_PROOF_NODE_BOOTSTRAPPED: '22.14.0' },
spawn,
})
).toThrow('did not provide stripTypeScriptTypes');
expect(spawn).not.toHaveBeenCalled();
});

it('preserves bootstrap failure as infrastructure failure', () => {
expect(() =>
ensureTypeStrippingRuntime({
moduleApi: {},
scriptPath: '/case/run.mjs',
env: {},
spawn: () => ({ error: new Error('timeout'), status: null }),
})
).toThrow('Could not launch pinned Node.js');
expect(() =>
ensureTypeStrippingRuntime({
moduleApi: {},
scriptPath: '/case/run.mjs',
env: {},
spawn: () => ({ signal: 'SIGTERM', status: null }),
})
).toThrow('exited without a status');
});
});
15 changes: 15 additions & 0 deletions tests/relayflows/cases/1593-receipt-reachability/case.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"version": 1,
"id": "1593-receipt-reachability",
"kind": "bugfix",
"title": "Directory matching does not claim recipient delivery",
"runner": {
"command": ["node", "tests/relayflows/cases/1593-receipt-reachability/run.mjs"]
},
"requirements": [],
"timeoutSeconds": 180,
"expected": {
"base": { "outcome": "bug", "signature": "directory_match_claims_recipient_match" },
"head": { "outcome": "fixed", "signature": "directory_match_preserves_delivery_uncertainty" }
}
}
Loading
Loading