Skip to content

Commit 8afe631

Browse files
willwashburnclaude
andcommitted
ctx.sendMessage posts through the canonical message route, not a parallel frame
Remove the node.message WS frame and its engine handler. Node-attributed posts now go through the canonical POST /v1/channels/:name/messages route, which accepts a node token (new `sender` auth requirement) with a required `from` — an agent resolved strictly within the node token's workspace. Delivery routing, observer events, webhooks, triggers, and idempotency come free because it is the one posting path (spec §5.3: only the hosting connection differs). ctx.sendMessage in all three SDKs becomes an HTTP call to that route with the node token. node.spawn stays a WS frame (dispatchCapacitySpawn duplicates no existing surface). Conformance covers node-token posting: delivery to node-hosted recipients, required `from`, unknown-agent and cross-workspace rejection, and `from` rejected on an agent token. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 966a475 commit 8afe631

13 files changed

Lines changed: 301 additions & 206 deletions

File tree

packages/engine/src/__tests__/conformance/nodeProviders.test.ts

Lines changed: 64 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
contextUpdatesOfType,
1010
type TestStack,
1111
} from './harness.js';
12-
import { actions, agents, messages, nodeProviders, nodes, workspaceEvents } from '../../db/schema.js';
12+
import { actions, agents, nodeProviders, nodes } from '../../db/schema.js';
1313

1414
type Cap = { name: string; kind?: string; global?: boolean; queue?: boolean };
1515

@@ -456,76 +456,80 @@ describe('node providers', () => {
456456
expect(reply.data?.handler_node_id).toBe('node_a');
457457
});
458458

459-
it('posts a channel message from a node.message frame and fans out to observers', async () => {
460-
const ws = await createWorkspace(stack.app, 'np-node-message');
461-
await registerAgent(stack.app, ws.workspaceKey, 'reporter');
462-
await enrollNode(ws, 'node_a', 'alpha');
463-
const py = await attachProvider(ws.workspaceId, 'node_a', 'alpha', 'py', [{ name: 'run-etl', kind: 'action' }]);
459+
// ctx.sendMessage posts through the canonical message route with a node token
460+
// and a required `from`, so node-attributed posts get delivery routing,
461+
// observer events, and triggers by construction (no parallel posting path).
462+
async function enrollNodeWithToken(ws: { workspaceKey: string }, nodeId: string, name: string): Promise<string> {
463+
const res = await stack.app.request('/v1/nodes', {
464+
method: 'POST',
465+
headers: { 'content-type': 'application/json', authorization: `Bearer ${ws.workspaceKey}` },
466+
body: JSON.stringify({ node_id: nodeId, name, role: 'broker', capabilities: [], max_agents: 4, tags: ['test'], version: 'v0' }),
467+
});
468+
expect(res.status).toBe(201);
469+
return (await res.json() as { data: { token: string } }).data.token;
470+
}
464471

465-
await py.handle.handleMessage(JSON.stringify({
466-
v: 1,
467-
id: 'msg-1',
468-
type: 'node.message',
469-
to: 'general',
470-
from: 'reporter',
471-
text: 'etl finished',
472-
}));
472+
function postMessage(token: string, channel: string, body: Record<string, unknown>) {
473+
return stack.app.request(`/v1/channels/${channel}/messages`, {
474+
method: 'POST',
475+
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
476+
body: JSON.stringify(body),
477+
});
478+
}
473479

474-
const reply = py.sock.ofType('reply').at(-1) as { ok?: boolean; id?: string; data?: { id?: string; text?: string } };
475-
expect(reply).toMatchObject({ ok: true, id: 'msg-1' });
476-
expect(reply.data?.text).toBe('etl finished');
480+
it('posts a node-token message attributed to `from`, delivered to node-hosted recipients', async () => {
481+
const ws = await createWorkspace(stack.app, 'nt-msg');
482+
await registerAgent(stack.app, ws.workspaceKey, 'poster');
483+
const nodeToken = await enrollNodeWithToken(ws, 'node_a', 'alpha');
484+
const py = await attachProvider(ws.workspaceId, 'node_a', 'alpha', 'py', [{ name: 'run-etl', kind: 'action' }]);
485+
// A worker agent hosted by py joins #general, so it is a delivery recipient.
486+
await py.handle.handleMessage(JSON.stringify({ v: 1, id: 'agent-1', type: 'agent.register', name: 'worker', session_ref: 'pty://py/worker' }));
487+
py.sock.received.length = 0;
477488

478-
const rows = await stack.runtime.handle.db
479-
.select({ body: messages.body, agentId: messages.agentId })
480-
.from(messages)
481-
.where(eq(messages.workspaceId, ws.workspaceId));
482-
expect(rows.map((r) => r.body)).toContain('etl finished');
483-
484-
const events = await stack.runtime.handle.db
485-
.select({ type: workspaceEvents.type })
486-
.from(workspaceEvents)
487-
.where(and(eq(workspaceEvents.workspaceId, ws.workspaceId), eq(workspaceEvents.type, 'message.created')));
488-
expect(events.length).toBeGreaterThanOrEqual(1);
489-
});
489+
const res = await postMessage(nodeToken, 'general', { text: 'etl finished', from: 'poster' });
490+
expect(res.status).toBe(201);
491+
const body = await res.json() as { data: { agent_name: string; text: string } };
492+
expect(body.data).toMatchObject({ agent_name: 'poster', text: 'etl finished' });
490493

491-
it('rejects a node.message from an unknown agent or to an unknown channel', async () => {
492-
const ws = await createWorkspace(stack.app, 'np-node-message-errors');
493-
await registerAgent(stack.app, ws.workspaceKey, 'reporter');
494-
await enrollNode(ws, 'node_a', 'alpha');
495-
const py = await attachProvider(ws.workspaceId, 'node_a', 'alpha', 'py', [{ name: 'run-etl', kind: 'action' }]);
494+
await new Promise((r) => setTimeout(r, 60));
495+
// Delivery routing came for free from the canonical route.
496+
expect(deliverFramesOfType(py.sock, 'message.created').length).toBeGreaterThanOrEqual(1);
497+
});
496498

497-
await py.handle.handleMessage(JSON.stringify({
498-
v: 1, id: 'bad-agent', type: 'node.message', to: 'general', from: 'ghost', text: 'hi',
499-
}));
500-
expect(py.sock.ofType('error').at(-1)).toMatchObject({ id: 'bad-agent', code: 'agent_not_found' });
499+
it('requires `from` on a node-token message', async () => {
500+
const ws = await createWorkspace(stack.app, 'nt-msg-from-required');
501+
const nodeToken = await enrollNodeWithToken(ws, 'node_a', 'alpha');
502+
const res = await postMessage(nodeToken, 'general', { text: 'hi' });
503+
expect(res.status).toBe(400);
504+
expect((await res.json() as { error: { code: string } }).error.code).toBe('from_required');
505+
});
501506

502-
await py.handle.handleMessage(JSON.stringify({
503-
v: 1, id: 'bad-channel', type: 'node.message', to: 'nowhere', from: 'reporter', text: 'hi',
504-
}));
505-
expect(py.sock.ofType('error').at(-1)).toMatchObject({ id: 'bad-channel', code: 'channel_not_found' });
507+
it('rejects a node-token message whose `from` agent does not exist', async () => {
508+
const ws = await createWorkspace(stack.app, 'nt-msg-unknown');
509+
const nodeToken = await enrollNodeWithToken(ws, 'node_a', 'alpha');
510+
const res = await postMessage(nodeToken, 'general', { text: 'hi', from: 'ghost' });
511+
expect(res.status).toBe(404);
512+
expect((await res.json() as { error: { code: string } }).error.code).toBe('agent_not_found');
506513
});
507514

508-
it('scopes node.message attribution to the node workspace, rejecting a cross-workspace from', async () => {
509-
// The `from` agent resolves only within the authenticated node's workspace,
510-
// so a node token cannot post as an agent that exists in another workspace.
511-
const other = await createWorkspace(stack.app, 'np-msg-other-ws');
515+
it('scopes node-token `from` to the node workspace, rejecting a cross-workspace agent', async () => {
516+
const other = await createWorkspace(stack.app, 'nt-msg-other-ws');
512517
await registerAgent(stack.app, other.workspaceKey, 'outsider');
513518

514-
const ws = await createWorkspace(stack.app, 'np-msg-scope');
515-
await enrollNode(ws, 'node_a', 'alpha');
516-
const py = await attachProvider(ws.workspaceId, 'node_a', 'alpha', 'py', [{ name: 'run-etl', kind: 'action' }]);
519+
const ws = await createWorkspace(stack.app, 'nt-msg-scope');
520+
const nodeToken = await enrollNodeWithToken(ws, 'node_a', 'alpha');
521+
const res = await postMessage(nodeToken, 'general', { text: 'hi', from: 'outsider' });
522+
expect(res.status).toBe(404);
523+
expect((await res.json() as { error: { code: string } }).error.code).toBe('agent_not_found');
524+
});
517525

518-
await py.handle.handleMessage(JSON.stringify({
519-
v: 1, id: 'cross-ws', type: 'node.message', to: 'general', from: 'outsider', text: 'hi',
520-
}));
521-
expect(py.sock.ofType('error').at(-1)).toMatchObject({ id: 'cross-ws', code: 'agent_not_found' });
522-
523-
// No message leaked into the other workspace under the outsider's name.
524-
const leaked = await stack.runtime.handle.db
525-
.select({ id: messages.id })
526-
.from(messages)
527-
.where(eq(messages.workspaceId, other.workspaceId));
528-
expect(leaked).toHaveLength(0);
526+
it('rejects `from` on an agent-token message', async () => {
527+
const ws = await createWorkspace(stack.app, 'nt-msg-agent-from');
528+
const agent = await registerAgent(stack.app, ws.workspaceKey, 'poster');
529+
const other = await registerAgent(stack.app, ws.workspaceKey, 'other');
530+
const res = await postMessage(agent.token, 'general', { text: 'hi', from: other.name });
531+
expect(res.status).toBe(400);
532+
expect((await res.json() as { error: { code: string } }).error.code).toBe('from_not_allowed');
529533
});
530534

531535
it('removes a provider through DELETE /v1/nodes/:node/providers/:name', async () => {

packages/engine/src/auth/tokenKind.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ export function getAuthTokenKind(token: string): AuthTokenKind | undefined {
6565
return parseAuthToken(token)?.kind;
6666
}
6767

68-
export function requiredTokenMessage(kind: Exclude<AuthRequire, 'any'>): string {
68+
export function requiredTokenMessage(kind: Exclude<AuthRequire, 'any' | 'sender'>): string {
6969
return tokenKindConfig[kind].requiredMessage;
7070
}
7171

@@ -77,6 +77,9 @@ const allowedKindsByRequire = {
7777
// `any` intentionally means user/workspace API credentials, not transport-only
7878
// node credentials or scoped observer credentials.
7979
any: ['workspace', 'agent'],
80+
// `sender` is a principal that can post a message: a workspace key, an agent
81+
// (posts as itself), or a node token (posts as an explicit `from` agent).
82+
sender: ['workspace', 'agent', 'node'],
8083
} as const satisfies Record<AuthRequire, readonly AuthTokenKind[]>;
8184

8285
export type AuthRequirementValidation =
@@ -108,7 +111,7 @@ export function validateTokenRequirement(
108111
};
109112
}
110113

111-
if (require === 'any') {
114+
if (require === 'any' || require === 'sender') {
112115
return { ok: false, code: 'unauthorized', message: 'Invalid token format' };
113116
}
114117

packages/engine/src/engine/node.ts

Lines changed: 0 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,6 @@ import {
3939
import { emitInvocationCompletionEffects } from './invocationCompletion.js';
4040
import type { InvocationCompletionDeps } from './invocationCompletion.js';
4141
import { ackDeliveriesUpToSeq, deliverPendingToNode } from './delivery.js';
42-
import { getChannel } from './channel.js';
43-
import { postMessage } from './message.js';
44-
import { buildMessageCreatedEventData } from './deliveryWire.js';
45-
import { transformForClient } from './wsTransform.js';
46-
import { appendAndPublishWorkspaceEvent } from './workspaceEvents.js';
4742

4843
type Db = ReturnType<typeof getDb>;
4944
type NodeRow = typeof nodes.$inferSelect;
@@ -1459,58 +1454,6 @@ export async function handleNodeControlMessage(args: HandleNodeControlMessageArg
14591454
});
14601455
return;
14611456
}
1462-
case 'node.message': {
1463-
// Handler-context `ctx.sendMessage`: post a channel message attributed
1464-
// to `from` (an agent resolved by name in the workspace).
1465-
const channel = await getChannel(args.db, args.workspaceId, message.to);
1466-
if (!channel) {
1467-
throw codedError(`Channel "${message.to}" not found`, 'channel_not_found', 404);
1468-
}
1469-
const [fromAgent] = await args.db
1470-
.select({ id: agents.id, name: agents.name })
1471-
.from(agents)
1472-
.where(and(eq(agents.workspaceId, args.workspaceId), eq(agents.name, message.from)));
1473-
if (!fromAgent) {
1474-
throw codedError(`Agent "${message.from}" not found`, 'agent_not_found', 404);
1475-
}
1476-
const posted = await postMessage(args.db, args.workspaceId, channel.id, fromAgent.id, {
1477-
text: message.text,
1478-
...(message.data ? { data: message.data } : {}),
1479-
...(message.mode ? { mode: message.mode } : {}),
1480-
});
1481-
// Fan out to workspace observers, mirroring the message route's publish.
1482-
if (args.completionDeps) {
1483-
const eventData = buildMessageCreatedEventData(posted as unknown as Record<string, unknown>, {
1484-
channelName: message.to,
1485-
fromName: fromAgent.name,
1486-
...(message.mode ? { mode: message.mode } : {}),
1487-
});
1488-
const payload = transformForClient({
1489-
type: 'message.created',
1490-
workspace_id: args.workspaceId,
1491-
channel_id: channel.id,
1492-
data: eventData,
1493-
timestamp: new Date().toISOString(),
1494-
});
1495-
await appendAndPublishWorkspaceEvent(
1496-
{ db: args.completionDeps.db, realtime: args.completionDeps.realtime },
1497-
args.workspaceId,
1498-
{ type: 'message.created', channelId: channel.id, payload },
1499-
);
1500-
}
1501-
const { _deliveries, _delivery_rejections, ...publicMessage } = posted as typeof posted & {
1502-
_deliveries?: unknown;
1503-
_delivery_rejections?: unknown;
1504-
};
1505-
sendControl(args.socket, {
1506-
v: 1,
1507-
id: requestId(message),
1508-
type: 'reply',
1509-
ok: true,
1510-
data: publicMessage as Record<string, unknown>,
1511-
});
1512-
return;
1513-
}
15141457
case 'agent.register': {
15151458
const registered = await registerAgentViaNode(args.db, args.workspaceId, args.nodeId, frameProviderName, message);
15161459
// The relay broker's node_control client awaits a `reply` frame keyed by

packages/engine/src/middleware/auth.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,13 @@ export const requireWorkspaceKey = makeAuthMiddleware('workspace');
127127
/** Accepts either a workspace key or an agent token. */
128128
export const requireAuth = makeAuthMiddleware('any');
129129

130+
/**
131+
* Accepts a workspace key, an agent token, or a node token — the principals that
132+
* can post a message. An agent posts as itself; a node posts as an explicit
133+
* `from` agent resolved within the node's workspace.
134+
*/
135+
export const requireSender = makeAuthMiddleware('sender');
136+
130137
/** Requires an agent token (`at_live_...`). */
131138
export const requireAgentToken = makeAuthMiddleware('agent');
132139

packages/engine/src/ports/auth.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ export type Agent = typeof agents.$inferSelect;
66
export type Node = typeof nodes.$inferSelect;
77
export type ObserverToken = typeof observerTokens.$inferSelect;
88

9-
export type AuthRequire = 'workspace' | 'agent' | 'node' | 'observer' | 'any';
9+
export type AuthRequire = 'workspace' | 'agent' | 'node' | 'observer' | 'any' | 'sender';
1010

1111
export type AuthResult =
1212
| { ok: true; workspace: Workspace; agent?: Agent; node?: Node; observerToken?: ObserverToken }

packages/engine/src/routes/message.ts

Lines changed: 42 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { Hono } from 'hono';
22
import { z } from 'zod';
3+
import { and, eq } from 'drizzle-orm';
4+
import { agents } from '../db/schema.js';
35
import type { AppEnv } from '../env.js';
4-
import { requireAuth, requireWorkspaceRead } from '../middleware/auth.js';
6+
import { requireSender, requireWorkspaceRead } from '../middleware/auth.js';
57
import { rateLimit } from '../middleware/rateLimit.js';
68
import { jsonIdempotentOk, parseIdempotencyKey, runIdempotent } from '../middleware/idempotency.js';
79
import * as messageEngine from '../engine/message.js';
@@ -40,6 +42,9 @@ const postMessageSchema = z.object({
4042
data: z.record(z.string(), z.unknown()).nullable().optional(),
4143
content_type: z.string().optional(),
4244
mode: z.enum(['wait', 'steer']).default('wait'),
45+
// Node-token posts attribute the message to `from` — an agent name resolved
46+
// strictly within the node token's workspace. Agent-token posts must omit it.
47+
from: z.string().min(1).optional(),
4348
});
4449

4550
type ValidationFailure = { error: { issues: Array<{ path: PropertyKey[] }> } };
@@ -58,18 +63,19 @@ function postMessageInvalidMessage(failure: ValidationFailure) {
5863
// POST /v1/channels/:name/messages - post a message
5964
messageRoutes.post(
6065
'/channels/:name/messages',
61-
requireAuth,
66+
requireSender,
6267
rateLimit,
6368
async (c) => {
6469
try {
6570
const db = c.get('db');
6671
const workspace = c.get('workspace');
6772
const agent = c.get('agent');
73+
const node = c.get('node');
6874
const parsed = await parseJsonBody(c, postMessageSchema, postMessageInvalidMessage);
6975
if (!parsed.ok) {
7076
return parsed.response;
7177
}
72-
const { text, blocks, attachments, data, content_type, mode } = parsed.data;
78+
const { text, blocks, attachments, data, content_type, mode, from } = parsed.data;
7379

7480
const { key: idempotencyKey, error: idempotencyError } = parseIdempotencyKey(c.req.header('Idempotency-Key'));
7581
if (idempotencyError) {
@@ -84,33 +90,55 @@ messageRoutes.post(
8490
return jsonNotFound(c, 'channel_not_found', `Channel "${channelName}" not found`);
8591
}
8692

87-
// Determine agent ID (from agent token or body)
88-
const agentId = agent?.id;
89-
if (!agentId) {
90-
return jsonError(c, 'agent_token_required', 'Agent token required to post messages', 403);
93+
// Resolve the sender. An agent token posts as itself; a node token posts
94+
// as `from`, an agent resolved strictly within the node's workspace (so a
95+
// node credential can never attribute a message to another workspace).
96+
let senderAgentId: string;
97+
let senderAgentName: string;
98+
if (agent) {
99+
if (from !== undefined) {
100+
return jsonError(c, 'from_not_allowed', '"from" is only valid with a node token', 400);
101+
}
102+
senderAgentId = agent.id;
103+
senderAgentName = agent.name;
104+
} else if (node) {
105+
if (!from) {
106+
return jsonError(c, 'from_required', 'A node token must set "from" (an agent name in the node workspace)', 400);
107+
}
108+
const [fromAgent] = await db
109+
.select({ id: agents.id, name: agents.name })
110+
.from(agents)
111+
.where(and(eq(agents.workspaceId, workspace.id), eq(agents.name, from)));
112+
if (!fromAgent) {
113+
return jsonNotFound(c, 'agent_not_found', `Agent "${from}" not found in this workspace`);
114+
}
115+
senderAgentId = fromAgent.id;
116+
senderAgentName = fromAgent.name;
117+
} else {
118+
return jsonError(c, 'agent_token_required', 'Agent or node token required to post messages', 403);
91119
}
92120

93121
const mailbox = resolveMailboxConfig(c.get('engine').config, workspace.id);
94122
const toMessageCreatedEventData = (data: Awaited<ReturnType<typeof messageEngine.postMessage>>) => buildMessageCreatedEventData(data, {
95123
channelName,
96-
fromName: agent?.name ?? 'unknown',
124+
fromName: senderAgentName,
97125
mode,
98126
});
99127

100128
const idempotent = await runIdempotent({
101129
workspaceId: workspace.id,
102-
actorId: agentId,
130+
actorId: senderAgentId,
103131
scope: `channel-message:${channel.id}`,
104132
key: idempotencyKey,
105133
status: 201,
106-
fingerprint: JSON.stringify({ channelId: channel.id, text, blocks, attachments, data, content_type, mode }),
134+
fingerprint: JSON.stringify({ channelId: channel.id, text, blocks, attachments, data, content_type, mode, from }),
107135
kv: c.get('engine').kv,
108136
operation: () =>
109137
messageEngine.postMessage(
110138
db,
111139
workspace.id,
112140
channel.id,
113-
agentId,
141+
senderAgentId,
114142
{ text, blocks, attachments, data, content_type, mode },
115143
{ mailbox },
116144
),
@@ -146,7 +174,7 @@ messageRoutes.post(
146174
if (_delivery_rejections && _delivery_rejections.length > 0) {
147175
runInBackground(
148176
c,
149-
notifyDeliveryRejections(c, agentId, _delivery_rejections),
177+
notifyDeliveryRejections(c, senderAgentId, _delivery_rejections),
150178
'fanout delivery rejected',
151179
);
152180
}
@@ -170,8 +198,8 @@ messageRoutes.post(
170198
id: String(publicData.id),
171199
channel_id: channel.id,
172200
channel_name: channelName,
173-
agent_id: agentId,
174-
agent_name: agent?.name,
201+
agent_id: senderAgentId,
202+
agent_name: senderAgentName,
175203
text: String(publicData.text ?? text),
176204
mentions: Array.isArray(publicData.mentions) ? publicData.mentions as string[] : [],
177205
metadata: (publicData.metadata ?? null) as Record<string, unknown> | null,

0 commit comments

Comments
 (0)