agent-relay-server 0.36.2 → 0.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/openapi.json +1 -1
- package/package.json +1 -1
- package/public/assets/{activity-C3mkM6AU.js → activity-BgkmA1lh.js} +2 -2
- package/public/assets/{activity-C3mkM6AU.js.map → activity-BgkmA1lh.js.map} +1 -1
- package/public/assets/{agents-CAhQO7JH.js → agents-B7HnuAXx.js} +2 -2
- package/public/assets/{agents-CAhQO7JH.js.map → agents-B7HnuAXx.js.map} +1 -1
- package/public/assets/{analytics-BwihhhNn.js → analytics-0-akxJCJ.js} +2 -2
- package/public/assets/{analytics-BwihhhNn.js.map → analytics-0-akxJCJ.js.map} +1 -1
- package/public/assets/{automation-BLXToUiU.js → automation-CaE1z_-M.js} +2 -2
- package/public/assets/{automation-BLXToUiU.js.map → automation-CaE1z_-M.js.map} +1 -1
- package/public/assets/{chat-8iIPyww9.js → chat-BANKUW05.js} +2 -2
- package/public/assets/{chat-8iIPyww9.js.map → chat-BANKUW05.js.map} +1 -1
- package/public/assets/{formatted-body-impl-BHH0wzY7.js → formatted-body-impl-DExNPNsL.js} +2 -2
- package/public/assets/{formatted-body-impl-BHH0wzY7.js.map → formatted-body-impl-DExNPNsL.js.map} +1 -1
- package/public/assets/{index-CaauKXl9.js → index-DEZdON6c.js} +5 -5
- package/public/assets/{index-CaauKXl9.js.map → index-DEZdON6c.js.map} +1 -1
- package/public/assets/{maintenance-9n_rJCHT.js → maintenance-DpTdJxQp.js} +2 -2
- package/public/assets/{maintenance-9n_rJCHT.js.map → maintenance-DpTdJxQp.js.map} +1 -1
- package/public/assets/{managed-agents-Rp2-xpBx.js → managed-agents-B3df2xfk.js} +2 -2
- package/public/assets/{managed-agents-Rp2-xpBx.js.map → managed-agents-B3df2xfk.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-YfJsGh6I.js → markdown-preview-impl-D0Zj7c3T.js} +2 -2
- package/public/assets/{markdown-preview-impl-YfJsGh6I.js.map → markdown-preview-impl-D0Zj7c3T.js.map} +1 -1
- package/public/assets/{memory-BQONtGQS.js → memory-TATN2vZf.js} +2 -2
- package/public/assets/{memory-BQONtGQS.js.map → memory-TATN2vZf.js.map} +1 -1
- package/public/assets/{messages-DGqpkH72.js → messages-3rS1lxIf.js} +2 -2
- package/public/assets/{messages-DGqpkH72.js.map → messages-3rS1lxIf.js.map} +1 -1
- package/public/assets/{orchestrators-b8k9QoGv.js → orchestrators-CRIV0g5y.js} +2 -2
- package/public/assets/{orchestrators-b8k9QoGv.js.map → orchestrators-CRIV0g5y.js.map} +1 -1
- package/public/assets/{overview-DSU_CggA.js → overview-CmHC_5oM.js} +2 -2
- package/public/assets/{overview-DSU_CggA.js.map → overview-CmHC_5oM.js.map} +1 -1
- package/public/assets/{pairs-DGocNC1U.js → pairs-DBPAhXTI.js} +2 -2
- package/public/assets/{pairs-DGocNC1U.js.map → pairs-DBPAhXTI.js.map} +1 -1
- package/public/assets/{security-BSh0QxOl.js → security-572X5MNX.js} +2 -2
- package/public/assets/{security-BSh0QxOl.js.map → security-572X5MNX.js.map} +1 -1
- package/public/assets/{settings-C03CAJgO.js → settings-vTBu8w3O.js} +2 -2
- package/public/assets/{settings-C03CAJgO.js.map → settings-vTBu8w3O.js.map} +1 -1
- package/public/assets/{tasks-rKbuUPOk.js → tasks-C0bPrDgN.js} +2 -2
- package/public/assets/{tasks-rKbuUPOk.js.map → tasks-C0bPrDgN.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-CA8u4jh3.js → terminal-viewer-impl-rVPA6Fsx.js} +2 -2
- package/public/assets/{terminal-viewer-impl-CA8u4jh3.js.map → terminal-viewer-impl-rVPA6Fsx.js.map} +1 -1
- package/public/assets/{work-queue-DOsA9s4M.js → work-queue-BxkpTt_A.js} +2 -2
- package/public/assets/{work-queue-DOsA9s4M.js.map → work-queue-BxkpTt_A.js.map} +1 -1
- package/public/index.html +1 -1
- package/runner/src/adapter.ts +7 -0
- package/scripts/orchestrator-spawn-smoke.ts +65 -33
- package/src/agent-ref.ts +28 -1
- package/src/bus.ts +52 -41
- package/src/compaction-watch.ts +7 -0
- package/src/index.ts +23 -6
- package/src/lifecycle-manager.ts +33 -71
- package/src/mcp.ts +100 -308
- package/src/routes/agent-sessions.ts +38 -3
- package/src/routes/agents-spawn.ts +43 -174
- package/src/routes/commands.ts +7 -19
- package/src/routes/messages.ts +24 -87
- package/src/security.ts +7 -0
- package/src/services/auth-context.ts +109 -0
- package/src/services/dispatch-command.ts +60 -0
- package/src/services/errors.ts +26 -0
- package/src/services/managed-running.ts +130 -0
- package/src/services/parity-harness.ts +135 -0
- package/src/services/register-agent.ts +74 -0
- package/src/services/send-message.ts +159 -0
- package/src/services/shutdown-agent.ts +234 -0
- package/src/services/spawn-agent.ts +278 -0
|
@@ -1,21 +1,14 @@
|
|
|
1
1
|
// Auto-split from routes.ts (#299). Domain: agents-spawn.
|
|
2
2
|
import { APPROVAL_MODES, SPAWN_PROVIDERS, VALID_EFFORTS, VALID_WORKSPACE_MODES, isRecord } from "agent-relay-sdk";
|
|
3
|
-
import { McpNotFoundError } from "../mcp-errors";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import { ValidationError, getAgent, getOrchestrator, listAgents, resolveQueuedPolicyMessages, upsertAgent } from "../db";
|
|
7
|
-
import { buildSpawnCommand, resolveSpawnModelParams, type SpawnModelParams } from "../spawn-command";
|
|
3
|
+
import { McpAuthError, McpNotFoundError } from "../mcp-errors";
|
|
4
|
+
import { VALID_AGENT_STATUSES, error, json, parseBody, type Handler } from "./_shared";
|
|
5
|
+
import { ValidationError, listAgents } from "../db";
|
|
8
6
|
import { cleanMeta, cleanNullableString, cleanString, cleanStringArray, optionalEnum } from "../validation";
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import { notifyAgentReady } from "../agent-lifecycle-events";
|
|
13
|
-
import { getCompactionWatch } from "../compaction-watch";
|
|
14
|
-
import { getComponentAuth, resolveSpawnLineage } from "../security";
|
|
15
|
-
import { isPathWithinBase } from "../utils";
|
|
7
|
+
import { registerAgent } from "../services/register-agent";
|
|
8
|
+
import { spawnAgent, type SpawnAgentInput } from "../services/spawn-agent";
|
|
9
|
+
import { authContextFromRequest } from "../services/auth-context";
|
|
16
10
|
import { listHostDirectories } from "../agent-spawn";
|
|
17
11
|
import { rankRouteCandidates, type RouteAdvisorInput } from "../context-router";
|
|
18
|
-
import { runnerRuntimeTokenEnv } from "../runtime-tokens";
|
|
19
12
|
import { type AgentKind, type RegisterAgentInput, type SpawnApprovalMode, type SpawnProvider, type WorkspaceMode } from "../types";
|
|
20
13
|
import { type ProviderEffort } from "agent-relay-sdk/provider-catalog";
|
|
21
14
|
|
|
@@ -63,70 +56,12 @@ export const postAgent: Handler = async (req) => {
|
|
|
63
56
|
const parsed = await parseBody<unknown>(req);
|
|
64
57
|
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
65
58
|
try {
|
|
59
|
+
// Thin transport: parse wire → build AuthContext → call the registerAgent service →
|
|
60
|
+
// serialize. ALL policy + side effects (lineage, managed came-up-running, status emit,
|
|
61
|
+
// timeline note, parent-wake, audit) live in src/services/register-agent.ts so the HTTP,
|
|
62
|
+
// bus, and orchestrator-report paths cannot drift (epic #342).
|
|
66
63
|
const input = normalizeAgentInput(parsed.body);
|
|
67
|
-
|
|
68
|
-
// client-sent body (a child can't forge its own parent). Set by relay at spawn for
|
|
69
|
-
// agent-initiated spawns (`spawnedBy`) and the delegating-component path (`parentAgents`).
|
|
70
|
-
const lineage = resolveSpawnLineage(getComponentAuth(req)?.constraints);
|
|
71
|
-
if (lineage) input.spawnedBy = lineage;
|
|
72
|
-
const existing = getAgent(input.id);
|
|
73
|
-
const agent = upsertAgent(input);
|
|
74
|
-
const policyName = metaString(agent.meta, "policyName");
|
|
75
|
-
const spawnRequestId = metaString(agent.meta, "spawnRequestId");
|
|
76
|
-
const tmuxSession = metaString(agent.meta, "tmuxSession");
|
|
77
|
-
if (policyName && spawnRequestId) {
|
|
78
|
-
const state = getManagedAgentState(policyName);
|
|
79
|
-
if (state?.spawnRequestId === spawnRequestId) {
|
|
80
|
-
const updatedState = updateManagedAgentState(policyName, {
|
|
81
|
-
status: "running",
|
|
82
|
-
agentId: agent.id,
|
|
83
|
-
tmuxSession: tmuxSession ?? state.tmuxSession,
|
|
84
|
-
healthySince: Date.now(),
|
|
85
|
-
lastError: undefined,
|
|
86
|
-
});
|
|
87
|
-
if (updatedState) emitManagedAgentStateChanged(policyName, updatedState as unknown as Record<string, unknown>);
|
|
88
|
-
const available = resolveQueuedPolicyMessages(policyName, agent.id);
|
|
89
|
-
if (available.length) {
|
|
90
|
-
emitMessageAvailable(policyName, agent.id, available);
|
|
91
|
-
for (const message of available) {
|
|
92
|
-
emitNewMessage(message);
|
|
93
|
-
// queued → pending flips delivery_status; the dashboard dedups message.new
|
|
94
|
-
// by id (the message already shows as "queued"), so without an explicit
|
|
95
|
-
// delivery_updated the badge stays stale until the next poll (#265).
|
|
96
|
-
emitMessageDeliveryUpdated(message);
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
emitAgentStatus(agent.id);
|
|
102
|
-
// #308 — a spawned child registering already-ready (the common isolated-worktree case: it
|
|
103
|
-
// comes up ready+idle) is genuinely ready; wake its parent so it can stop block-polling.
|
|
104
|
-
// Fire on the first-ever ready (no prior record, or prior record wasn't ready yet) — the
|
|
105
|
-
// ready/false→true flip path is handled in patchAgentReady. notifyAgentReady no-ops for
|
|
106
|
-
// non-spawned agents and dedups repeats.
|
|
107
|
-
if (agent.ready && !existing?.ready) notifyAgentReady(agent.id);
|
|
108
|
-
// A real PreCompact / SessionStart(clear) hook reports progress via the
|
|
109
|
-
// agent's timelineEvent — clears any pending stall watch for this agent.
|
|
110
|
-
// timelineEvent is latched, so pass its timestamp: only a fresh event
|
|
111
|
-
// (stamped after the watch armed) counts as this command's real start.
|
|
112
|
-
const timelineEvent = isRecord(agent.meta?.timelineEvent) ? agent.meta.timelineEvent : undefined;
|
|
113
|
-
const timelineStatus = metaString(timelineEvent, "status");
|
|
114
|
-
if (timelineStatus) {
|
|
115
|
-
const timelineTs = typeof timelineEvent?.timestamp === "number" ? timelineEvent.timestamp : undefined;
|
|
116
|
-
getCompactionWatch().noteTimelineStatus(agent.id, timelineStatus, timelineTs);
|
|
117
|
-
}
|
|
118
|
-
if (!existing) {
|
|
119
|
-
auditEvent({
|
|
120
|
-
clientId: "server-agent-" + agent.id + "-registered",
|
|
121
|
-
kind: "state",
|
|
122
|
-
title: "Agent registered",
|
|
123
|
-
body: agent.name,
|
|
124
|
-
meta: agent.id,
|
|
125
|
-
icon: "ti-robot",
|
|
126
|
-
view: "agents",
|
|
127
|
-
agentId: agent.id,
|
|
128
|
-
});
|
|
129
|
-
}
|
|
64
|
+
const agent = registerAgent(input, authContextFromRequest(req));
|
|
130
65
|
return json(agent, 201);
|
|
131
66
|
} catch (e) {
|
|
132
67
|
if (e instanceof ValidationError) return error(e.message, 400);
|
|
@@ -162,83 +97,39 @@ export const postAgentSpawn: Handler = async (req) => {
|
|
|
162
97
|
if (!isRecord(parsed.body)) return error("provider required");
|
|
163
98
|
const provider = optionalEnum(parsed.body.provider, "provider", SPAWN_PROVIDERS) as SpawnProvider | undefined;
|
|
164
99
|
if (!provider) return error("provider required");
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
target: orch.agentId,
|
|
197
|
-
correlationId: requestId,
|
|
198
|
-
params: buildSpawnCommand({
|
|
199
|
-
provider,
|
|
200
|
-
modelParams: selection,
|
|
201
|
-
cwd: cwd || orch.baseDir,
|
|
202
|
-
workspaceMode,
|
|
203
|
-
label,
|
|
204
|
-
tags,
|
|
205
|
-
capabilities,
|
|
206
|
-
approvalMode,
|
|
207
|
-
permissionMode: approvalMode,
|
|
208
|
-
profile: profile || undefined,
|
|
209
|
-
agentProfile,
|
|
210
|
-
providerArgs,
|
|
211
|
-
prompt,
|
|
212
|
-
systemPromptAppend,
|
|
213
|
-
policyName,
|
|
214
|
-
spawnRequestId: requestId,
|
|
215
|
-
env: runnerRuntimeTokenEnv({
|
|
216
|
-
orchestratorId: orch.id,
|
|
217
|
-
cwd: cwd || orch.baseDir,
|
|
218
|
-
provider,
|
|
219
|
-
label,
|
|
220
|
-
policyName,
|
|
221
|
-
spawnRequestId: requestId,
|
|
222
|
-
createdBy: "dashboard",
|
|
223
|
-
profile: profile || undefined,
|
|
224
|
-
}),
|
|
225
|
-
requestedBy: "dashboard",
|
|
226
|
-
requestedAt: Date.now(),
|
|
227
|
-
}),
|
|
228
|
-
});
|
|
229
|
-
emitCommand(command);
|
|
230
|
-
auditEvent({
|
|
231
|
-
clientId: "server-agent-spawn-" + provider + "-" + command.id,
|
|
232
|
-
kind: "state",
|
|
233
|
-
title: `${provider} agent spawn requested (via ${orch.id})`,
|
|
234
|
-
body: cwd || orch.baseDir,
|
|
235
|
-
meta: orch.id,
|
|
236
|
-
icon: "ti-plus",
|
|
237
|
-
view: "agents",
|
|
238
|
-
metadata: { provider, orchestratorId: orch.id, approvalMode, workspaceMode, label, commandId: command.id, ...authAuditMetadata(req) },
|
|
239
|
-
});
|
|
240
|
-
return json({ ok: true, orchestratorId: orch.id, provider, command }, 202);
|
|
100
|
+
// Thin transport: parse wire → AuthContext → spawnAgent service → serialize. The gate
|
|
101
|
+
// (command:spawn scope, #221 no-grandchild + quota, #323 resource), caller-context
|
|
102
|
+
// inheritance (#328/#331/#324), profile validation, lineage stamping, host selection, and
|
|
103
|
+
// the command payload all live in src/services/spawn-agent.ts so HTTP and MCP cannot drift
|
|
104
|
+
// (epic #342, #349). The dashboard authenticates with an admin `*` token, so the tightened
|
|
105
|
+
// command:spawn requirement is transparent to it.
|
|
106
|
+
const input: SpawnAgentInput = {
|
|
107
|
+
provider,
|
|
108
|
+
orchestratorId: cleanString(parsed.body.orchestratorId, "orchestratorId", { max: 200 }),
|
|
109
|
+
cwd: cleanString(parsed.body.cwd, "cwd", { max: 500 }),
|
|
110
|
+
model: cleanString(parsed.body.model, "model", { max: 120 }),
|
|
111
|
+
effort: optionalEnum(parsed.body.effort, "effort", VALID_EFFORTS) as ProviderEffort | undefined,
|
|
112
|
+
approvalMode: optionalEnum(parsed.body.approvalMode, "approvalMode", APPROVAL_MODES) as SpawnApprovalMode | undefined,
|
|
113
|
+
workspaceMode: optionalEnum(parsed.body.workspaceMode, "workspaceMode", VALID_WORKSPACE_MODES) as WorkspaceMode | undefined,
|
|
114
|
+
label: cleanString(parsed.body.label, "label", { max: 120 }),
|
|
115
|
+
policyName: cleanString(parsed.body.policyName, "policyName", { max: 120 }),
|
|
116
|
+
profile: cleanString(parsed.body.profile, "profile", { max: 120 }),
|
|
117
|
+
prompt: cleanString(parsed.body.prompt, "prompt", { max: 16000 }),
|
|
118
|
+
systemPromptAppend: cleanString(parsed.body.systemPromptAppend, "systemPromptAppend", { max: 64_000 }),
|
|
119
|
+
tags: cleanStringArray(parsed.body.tags, "tags", { itemMax: 80, maxItems: 50 }),
|
|
120
|
+
capabilities: cleanStringArray(parsed.body.capabilities, "capabilities", { itemMax: 80, maxItems: 50 }),
|
|
121
|
+
providerArgs: cleanStringArray(parsed.body.providerArgs, "providerArgs", { itemMax: 80, maxItems: 50 }),
|
|
122
|
+
spawnRequestId: cleanString(parsed.body.spawnRequestId, "spawnRequestId", { max: 160 }),
|
|
123
|
+
requestedVia: "dashboard",
|
|
124
|
+
requestedBy: "dashboard",
|
|
125
|
+
// HTTP is fire-and-forget (202): the dashboard polls registration separately via the
|
|
126
|
+
// returned spawnRequestId.
|
|
127
|
+
waitForRegistrationMs: 0,
|
|
128
|
+
};
|
|
129
|
+
const result = await spawnAgent(input, authContextFromRequest(req));
|
|
130
|
+
return json({ ok: true, orchestratorId: result.orchestratorId, provider: result.provider, command: result.command }, 202);
|
|
241
131
|
} catch (e) {
|
|
132
|
+
if (e instanceof McpAuthError) return error(e.message, 403);
|
|
242
133
|
if (e instanceof McpNotFoundError) return error(e.message, 404);
|
|
243
134
|
if (e instanceof ValidationError) return error(e.message, 400);
|
|
244
135
|
if (e instanceof Error) return error(e.message, 400);
|
|
@@ -257,25 +148,3 @@ export const getHostDirectories: Handler = (req) => {
|
|
|
257
148
|
throw e;
|
|
258
149
|
}
|
|
259
150
|
};
|
|
260
|
-
|
|
261
|
-
function cleanSpawnSelection(body: Record<string, unknown>, provider: SpawnProvider): SpawnModelParams {
|
|
262
|
-
const model = cleanString(body.model, "model", { max: 120 });
|
|
263
|
-
const effort = optionalEnum(body.effort, "effort", VALID_EFFORTS) as ProviderEffort | undefined;
|
|
264
|
-
return resolveSpawnModelParams(provider, model, effort);
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
function validateSpawnSelectionForOrchestrator(
|
|
268
|
-
orch: NonNullable<ReturnType<typeof getOrchestrator>>,
|
|
269
|
-
provider: SpawnProvider,
|
|
270
|
-
body: Record<string, unknown>,
|
|
271
|
-
): SpawnModelParams | Response {
|
|
272
|
-
if (!orch.providers.includes(provider)) {
|
|
273
|
-
return error(`orchestrator does not have provider available: ${provider}`, 409);
|
|
274
|
-
}
|
|
275
|
-
try {
|
|
276
|
-
return cleanSpawnSelection(body, provider);
|
|
277
|
-
} catch (e) {
|
|
278
|
-
if (e instanceof Error) return error(e.message, 400);
|
|
279
|
-
throw e;
|
|
280
|
-
}
|
|
281
|
-
}
|
package/src/routes/commands.ts
CHANGED
|
@@ -5,11 +5,13 @@ import { ValidationError, deleteWorkspace, getOrchestrator, getWorkspace, patchW
|
|
|
5
5
|
import { applyCommandToRecipe } from "../recipe-runner";
|
|
6
6
|
import { claimMetadataPatch } from "../workspace-claim";
|
|
7
7
|
import { clearActiveMemories, injectAlwaysReloadMemories } from "../memory-service";
|
|
8
|
-
import {
|
|
8
|
+
import { getCommand, listCommands, updateCommand } from "../commands-db";
|
|
9
9
|
import { emitOrchestratorStatus } from "../sse";
|
|
10
10
|
import { getCompactionWatch } from "../compaction-watch";
|
|
11
11
|
import { isRecord } from "agent-relay-sdk";
|
|
12
|
-
import {
|
|
12
|
+
import { authContextFromRequest } from "../services/auth-context";
|
|
13
|
+
import { commandAuthorizationResource as dispatchCommandAuthorizationResource, dispatchCommand } from "../services/dispatch-command";
|
|
14
|
+
import { ServiceAuthError } from "../services/errors";
|
|
13
15
|
import { notifyBranchLanded } from "../branch-landed";
|
|
14
16
|
import { notifyAgentSpawnFailed } from "../agent-lifecycle-events";
|
|
15
17
|
import { type Command, type CommandStatus, type CreateCommandInput, type WorkspaceStatus } from "../types";
|
|
@@ -102,30 +104,16 @@ function normalizeCommandInput(body: unknown): CreateCommandInput {
|
|
|
102
104
|
return { type, source, target, params, correlationId, ttlMs };
|
|
103
105
|
}
|
|
104
106
|
|
|
105
|
-
function commandAuthorizationResource(input: Pick<CreateCommandInput, "target" | "params">): Parameters<typeof isRequestAuthorizedFor>[1]["resource"] {
|
|
106
|
-
const params = isRecord(input.params) ? input.params : {};
|
|
107
|
-
return {
|
|
108
|
-
target: input.target,
|
|
109
|
-
agentId: cleanString(params.agentId, "params.agentId", { max: 240 }) ?? input.target,
|
|
110
|
-
policyName: cleanString(params.policyName, "params.policyName", { max: 120 }),
|
|
111
|
-
orchestratorId: cleanString(params.orchestratorId, "params.orchestratorId", { max: 120 }),
|
|
112
|
-
cwd: cleanString(params.cwd, "params.cwd", { max: 500 }),
|
|
113
|
-
spawnRequestId: cleanString(params.spawnRequestId, "params.spawnRequestId", { max: 160 }),
|
|
114
|
-
};
|
|
115
|
-
}
|
|
116
|
-
|
|
117
107
|
export const postCommand: Handler = async (req) => {
|
|
118
108
|
const parsed = await parseBody<unknown>(req);
|
|
119
109
|
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
120
110
|
try {
|
|
121
111
|
const input = normalizeCommandInput(parsed.body);
|
|
122
|
-
const
|
|
123
|
-
if (denied) return denied;
|
|
124
|
-
const command = createCommand(input);
|
|
125
|
-
emitCommand(command);
|
|
112
|
+
const command = dispatchCommand(input, authContextFromRequest(req));
|
|
126
113
|
return json(command, 201);
|
|
127
114
|
} catch (e) {
|
|
128
115
|
if (e instanceof ValidationError) return error(e.message, 400);
|
|
116
|
+
if (e instanceof ServiceAuthError) return error("forbidden", 403);
|
|
129
117
|
throw e;
|
|
130
118
|
}
|
|
131
119
|
};
|
|
@@ -161,7 +149,7 @@ export const patchCommand: Handler = async (req, params) => {
|
|
|
161
149
|
try {
|
|
162
150
|
const current = getCommand(params.id!);
|
|
163
151
|
if (!current) return error("command not found", 404);
|
|
164
|
-
const denied = authorizeRoute(req, { scope: "command:write", resource:
|
|
152
|
+
const denied = authorizeRoute(req, { scope: "command:write", resource: dispatchCommandAuthorizationResource(current) });
|
|
165
153
|
if (denied) return denied;
|
|
166
154
|
const status = optionalEnum(parsed.body.status, "status", VALID_COMMAND_STATUSES);
|
|
167
155
|
const result = cleanParams(parsed.body.result, "result");
|
package/src/routes/messages.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
// Auto-split from routes.ts (#299). Domain: messages.
|
|
2
2
|
import { MAX_BODY_BYTES } from "../config";
|
|
3
|
-
import { ValidationError, applyMessageDeliveryAction, claimMessage, deleteMessage, getAgent, getLatestMessageId, getMessage, getMessageDeliveryStatus, getThread,
|
|
4
|
-
import { auditEvent,
|
|
3
|
+
import { ValidationError, applyMessageDeliveryAction, claimMessage, deleteMessage, getAgent, getLatestMessageId, getMessage, getMessageDeliveryStatus, getThread, listQueuedMessages, listRecentMessages, markRead, pollMessages, recordMessageDeliveryAttempt, renewMessageClaim, sendMessage, sendMessageWithResult, setMessageReaction } from "../db";
|
|
4
|
+
import { auditEvent, cleanAttachmentRefs, dispatchTaskCallbacks, emitCommand, error, json, memoryContext, normalizeAgentSessionGuard, parseBody, parseId, parseQueryInt, reactionEmoji, sendReactionNotificationToAuthor, withPayloadAttachments, type Handler } from "./_shared";
|
|
5
5
|
import { cleanMeta, cleanPositiveId, cleanString, optionalEnum } from "../validation";
|
|
6
6
|
import { emitMessageClaimed, emitMessageDeleted, emitMessageDeliveryUpdated, emitMessageQueued, emitMessageReactionUpdated, emitNewMessage, emitTaskChanged } from "../sse";
|
|
7
|
-
import { errMessage, isMechanicalMessageKind, isRecord
|
|
8
|
-
import { getLifecycleManager } from "../lifecycle-manager";
|
|
7
|
+
import { errMessage, isMechanicalMessageKind, isRecord } from "agent-relay-sdk";
|
|
9
8
|
import { injectMemoryForMessageDelivery } from "../memory-service";
|
|
10
|
-
import {
|
|
9
|
+
import { authContextFromRequest } from "../services/auth-context";
|
|
10
|
+
import { sendMessageService } from "../services/send-message";
|
|
11
|
+
import { AmbiguousTargetError, ServiceAuthError } from "../services/errors";
|
|
11
12
|
import { type AgentSessionGuard, type Message, type SendMessageInput } from "../types";
|
|
12
13
|
|
|
13
14
|
const VALID_DELIVERY_STATUSES = ["pending", "delivered", "queued", "failed", "dead"] as const;
|
|
@@ -71,27 +72,6 @@ function isDirectTarget(to: string): boolean {
|
|
|
71
72
|
return to !== "broadcast" && !FANOUT_PREFIXES.some((p) => to.startsWith(p));
|
|
72
73
|
}
|
|
73
74
|
|
|
74
|
-
function applyReplyRouting(input: SendMessageInput): void {
|
|
75
|
-
if (input.to || !input.replyTo) return;
|
|
76
|
-
const parent = getMessage(input.replyTo);
|
|
77
|
-
if (!parent) return;
|
|
78
|
-
input.to = parent.from;
|
|
79
|
-
if (!input.channel && parent.channel) input.channel = parent.channel;
|
|
80
|
-
const parentPayload = parent.payload ?? {};
|
|
81
|
-
if (parentPayload.schema === "agent-relay.channel.v1" || parentPayload.conversation) {
|
|
82
|
-
const replyContext: Record<string, unknown> = {};
|
|
83
|
-
if (parent.channel) replyContext.channelId = parent.channel;
|
|
84
|
-
if (parentPayload.conversation && typeof parentPayload.conversation === "object") {
|
|
85
|
-
replyContext.conversationId = (parentPayload.conversation as Record<string, unknown>).id;
|
|
86
|
-
}
|
|
87
|
-
if (parentPayload.event && typeof parentPayload.event === "object") {
|
|
88
|
-
replyContext.parentEventId = (parentPayload.event as Record<string, unknown>).id;
|
|
89
|
-
}
|
|
90
|
-
if (parentPayload.source) replyContext.source = parentPayload.source;
|
|
91
|
-
input.payload = { ...input.payload, replyContext };
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
75
|
const VALID_MSG_KINDS = ["chat", "channel.event", "task", "pair", "control", "system", "session"];
|
|
96
76
|
|
|
97
77
|
export const getQueuedMessagesRoute: Handler = (req) => {
|
|
@@ -173,83 +153,40 @@ export const postMessage: Handler = async (req) => {
|
|
|
173
153
|
if (!input.idempotencyKey) {
|
|
174
154
|
input.idempotencyKey = cleanString(req.headers.get("Idempotency-Key") ?? undefined, "idempotencyKey", { max: 240 });
|
|
175
155
|
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
//
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
// the turn (#284, same outbox-poison failure mode as #184). The message:send scope
|
|
185
|
-
// and any channel constraint still apply; we only drop the target/agentId predicate.
|
|
186
|
-
const reservedSinkPost = isMechanicalMessageKind(input.kind) && isReservedAgentId(input.to);
|
|
187
|
-
const denied = authorizeRoute(req, {
|
|
188
|
-
scope: "message:send",
|
|
189
|
-
resource: reservedSinkPost
|
|
190
|
-
? { channel: input.channel }
|
|
191
|
-
: { target: input.to, channel: input.channel, agentId: input.from },
|
|
192
|
-
});
|
|
193
|
-
if (denied) return denied;
|
|
194
|
-
// Resolve the target through the shared planner — the SAME matcher the MCP send tool
|
|
195
|
-
// uses (planSend → matchAgents) — so a bare label / name / id-segment that matches an
|
|
196
|
-
// existing agent is rewritten to its canonical id. Poll-time matching is exact, so
|
|
197
|
-
// without this a bare label is stored verbatim and reaches no one (#234). An ambiguous
|
|
198
|
-
// direct ref can't be delivered, so reject it up front. Unknown targets are left
|
|
199
|
-
// untouched on purpose: sending to an id that isn't registered yet is a supported
|
|
200
|
-
// "store-ahead" pattern (delivered once that agent registers and polls). Fan-out and
|
|
201
|
-
// reserved/policy targets carry through unchanged (and may legitimately match zero now).
|
|
202
|
-
//
|
|
203
|
-
// "session" = observed assistant turn (Phase 1 live-session lane). It is captured
|
|
204
|
-
// from the provider transcript and stored for the dashboard chat; it must persist
|
|
205
|
-
// regardless of target liveness and never be re-delivered into a session.
|
|
206
|
-
if (!isMechanicalMessageKind(input.kind)) {
|
|
207
|
-
// excludeId: a bare ref must never resolve back to its own author — that self-loop
|
|
208
|
-
// silently swallows the message (the reddit-briefing → telegram bridge break, #290).
|
|
209
|
-
const plan = planSend(input.to, listAgents(), { excludeId: input.from });
|
|
210
|
-
if (plan.kind === "ambiguous") return error(plan.message, 409);
|
|
211
|
-
if (plan.kind !== "not_found") input.to = plan.to;
|
|
212
|
-
// Long-standing guard: refuse a direct send to a known-offline agent (now also
|
|
213
|
-
// catches a ref that resolved to an offline agent by label/segment).
|
|
214
|
-
const target = getAgent(input.to);
|
|
215
|
-
if (target && target.status === "offline") {
|
|
216
|
-
return error(`agent "${input.to}" is offline`, 422);
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
const result = sendMessageWithResult(input);
|
|
220
|
-
if (result.created) {
|
|
221
|
-
const autoMemoryTarget = automaticMemoryTarget(result.message);
|
|
156
|
+
// The shared send service owns reply-routing, target resolution + the converged store-ahead
|
|
157
|
+
// policy, authorization (incl. the reserved-sink mechanical exemption), persistence, and the
|
|
158
|
+
// emit + on-demand-policy-wake — identical across HTTP / MCP (epic #342, src/services/send-message.ts).
|
|
159
|
+
const { message, created } = sendMessageService(input, authContextFromRequest(req));
|
|
160
|
+
if (created) {
|
|
161
|
+
// Transport-edge enrichment (NOT part of the cross-transport send core): automatic memory
|
|
162
|
+
// injection needs request-derived context; the "message sent" audit is the HTTP domain row.
|
|
163
|
+
const autoMemoryTarget = automaticMemoryTarget(message);
|
|
222
164
|
if (autoMemoryTarget) {
|
|
223
165
|
try {
|
|
224
|
-
const memoryInjection = await injectMemoryForMessageDelivery(
|
|
166
|
+
const memoryInjection = await injectMemoryForMessageDelivery(message, autoMemoryTarget, memoryContext(req));
|
|
225
167
|
if (memoryInjection) emitCommand(memoryInjection.command);
|
|
226
168
|
} catch (e) {
|
|
227
169
|
console.warn(`[memory] automatic message context assembly failed: ${errMessage(e)}`);
|
|
228
170
|
}
|
|
229
171
|
}
|
|
230
|
-
|
|
231
|
-
emitMessageQueued(result.message);
|
|
232
|
-
// Wake an on-demand managed policy that has no live agent attached yet.
|
|
233
|
-
if (result.message.to?.startsWith("policy:")) {
|
|
234
|
-
getLifecycleManager().onMessageForPolicy(result.message.to.slice("policy:".length));
|
|
235
|
-
}
|
|
236
|
-
} else emitNewMessage(result.message);
|
|
237
|
-
const isWork = result.message.kind === "task";
|
|
172
|
+
const isWork = message.kind === "task";
|
|
238
173
|
auditEvent({
|
|
239
|
-
clientId: "server-message-" +
|
|
174
|
+
clientId: "server-message-" + message.id,
|
|
240
175
|
kind: isWork ? "task" : "message",
|
|
241
176
|
title: isWork ? "Task message sent" : "Message sent",
|
|
242
|
-
body:
|
|
243
|
-
meta: `${
|
|
177
|
+
body: message.subject || message.body,
|
|
178
|
+
meta: `${message.from} -> ${message.to}`,
|
|
244
179
|
icon: isWork ? "ti-hand-grab" : "ti-send",
|
|
245
180
|
view: "messages",
|
|
246
|
-
messageId:
|
|
247
|
-
agentId:
|
|
181
|
+
messageId: message.id,
|
|
182
|
+
agentId: message.from,
|
|
248
183
|
});
|
|
249
184
|
}
|
|
250
|
-
return json(
|
|
185
|
+
return json(message, created ? 201 : 200);
|
|
251
186
|
} catch (e) {
|
|
252
187
|
if (e instanceof ValidationError) return error(e.message, 400);
|
|
188
|
+
if (e instanceof AmbiguousTargetError) return error(e.message, 409);
|
|
189
|
+
if (e instanceof ServiceAuthError) return error("forbidden", 403);
|
|
253
190
|
throw e;
|
|
254
191
|
}
|
|
255
192
|
};
|
package/src/security.ts
CHANGED
|
@@ -185,6 +185,11 @@ export function requiredScopeFor(method: string, pathname: string): string | nul
|
|
|
185
185
|
if (pathname.startsWith("/api/channels") || pathname.startsWith("/api/channel-bindings")) return method === "GET" ? "channel:read" : "channel:write";
|
|
186
186
|
if (pathname === "/api/integrations" || pathname.startsWith("/api/integrations/")) return method === "GET" ? "integration:read" : "integration:write";
|
|
187
187
|
if (pathname.startsWith("/api/automations") || pathname.startsWith("/api/automation-runs")) return method === "GET" ? "automations:read" : "automations:write";
|
|
188
|
+
// Spawning a worker requires the spawn capability, not generic agent-write — the in-service
|
|
189
|
+
// gate (src/services/spawn-agent.ts) enforces the full quota/no-grandchild rules; this coarse
|
|
190
|
+
// route gate just matches it so an `agent:write`-but-not-`command:spawn` token can't reach it
|
|
191
|
+
// (the MCP spawn tool already required command:spawn — epic #342, #349).
|
|
192
|
+
if (pathname === "/api/agents/spawn") return "command:spawn";
|
|
188
193
|
if (pathname.startsWith("/api/agents")) return method === "GET" ? "agent:read" : "agent:write";
|
|
189
194
|
if (pathname.startsWith("/api/activity")) return method === "GET" ? "activity:read" : "activity:write";
|
|
190
195
|
if (pathname.startsWith("/api/inbox")) return method === "GET" ? "message:read" : "message:send";
|
|
@@ -264,6 +269,8 @@ export function requiredComponentScopeFor(method: string, pathname: string): str
|
|
|
264
269
|
return "memory:write";
|
|
265
270
|
}
|
|
266
271
|
if (pathname === "/api/integrations" || pathname.startsWith("/api/integrations/")) return method === "GET" ? "integration:read" : "integration:write";
|
|
272
|
+
// Spawn requires the spawn capability (see requiredScopeFor above) — matches the in-service gate.
|
|
273
|
+
if (pathname === "/api/agents/spawn") return "command:spawn";
|
|
267
274
|
if (pathname.startsWith("/api/agents")) return method === "GET" ? "agent:read" : "agent:write";
|
|
268
275
|
if (pathname.startsWith("/api/messages") || pathname.startsWith("/api/inbox")) return method === "GET" ? "message:read" : "message:send";
|
|
269
276
|
if (pathname.startsWith("/api/agent-profiles")) return method === "GET" ? "agent:read" : "agent:write";
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Transport convergence (epic #342) — unified auth shape.
|
|
2
|
+
//
|
|
3
|
+
// Every transport (HTTP routes, WS bus, MCP, internal projections) builds ONE
|
|
4
|
+
// `AuthContext` and hands it to the domain services in `src/services/`. Services
|
|
5
|
+
// never see a transport-specific Request / WebSocket / frame — they read identity,
|
|
6
|
+
// scopes, and signed constraints from this single shape. This kills the
|
|
7
|
+
// "I forgot this path also needs the constraint" class of bug (e.g. the v0.36.2
|
|
8
|
+
// `spawned_by`-NULL drift: lineage lived in the HTTP path's token read but not the
|
|
9
|
+
// bus path's). Lineage now resolves uniformly from `ctx.constraints`.
|
|
10
|
+
import type { AgentCard, ComponentToken, TokenConstraints } from "../types";
|
|
11
|
+
import { getComponentAuth, isAuthorized } from "../security";
|
|
12
|
+
import { resolveCallerAgentId } from "../agent-ref";
|
|
13
|
+
import { listAgents } from "../db";
|
|
14
|
+
|
|
15
|
+
export interface AuthContext {
|
|
16
|
+
/** The authenticated principal. `system` is an internal, fully-trusted caller
|
|
17
|
+
* (lifecycle tick, orchestrator-report projection) with no token. */
|
|
18
|
+
actor: { id: string; kind: "component" | "agent" | "system" | "user" | "integration" };
|
|
19
|
+
/** Capability scopes the principal holds (e.g. "agent:write", "command:spawn").
|
|
20
|
+
* Empty for `system` (internal callers bypass scope checks). Consumed by the
|
|
21
|
+
* shutdown/spawn slices; registration only needs `constraints`. */
|
|
22
|
+
scopes: string[];
|
|
23
|
+
/** Signed token constraints — the AUTHORITATIVE source for spawn lineage
|
|
24
|
+
* (`resolveSpawnLineage(ctx.constraints)`), cwd prefixes, spawn-request ids, etc.
|
|
25
|
+
* A client can never forge these; they are baked into the token at issue time. */
|
|
26
|
+
constraints?: TokenConstraints;
|
|
27
|
+
/** The resolved CALLER AGENT ID behind this token — the agent the principal *is*,
|
|
28
|
+
* NOT `actor.id` (the token `sub`). Resolved from the signed constraints via
|
|
29
|
+
* `resolveCallerAgentId` (single `agents` id, else a single spawnRequestId/policy matched
|
|
30
|
+
* to a live agent — the #243 identity family). undefined for admin/server tokens (full
|
|
31
|
+
* reach) and multi-agent tokens. THE basis for the parent→child shutdown gate and the
|
|
32
|
+
* no-grandchild spawn gate (#221) — consumed by the shutdown/spawn/send services. */
|
|
33
|
+
callerAgentId?: string;
|
|
34
|
+
/** The raw component token when the principal authenticated with one. Escape
|
|
35
|
+
* hatch for code still calling `isComponentAuthorizedFor` directly during the
|
|
36
|
+
* transport-convergence migration; new service code should prefer `scopes`/`constraints`. */
|
|
37
|
+
component?: ComponentToken;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function agentsSnapshot(): AgentCard[] {
|
|
41
|
+
return listAgents();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function fromComponent(component: ComponentToken | null | undefined): AuthContext {
|
|
45
|
+
if (!component) return { actor: { id: "anonymous", kind: "user" }, scopes: [] };
|
|
46
|
+
return {
|
|
47
|
+
actor: { id: component.sub, kind: "component" },
|
|
48
|
+
scopes: component.scope ?? [],
|
|
49
|
+
constraints: component.constraints,
|
|
50
|
+
// Lazy: resolveCallerAgentId only scans live agents when a single spawnRequestId/policy
|
|
51
|
+
// must be matched; identity-bearing (`agents`) and admin tokens pay no db cost here.
|
|
52
|
+
callerAgentId: resolveCallerAgentId(component.constraints, agentsSnapshot),
|
|
53
|
+
component,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** True if the request carries any credential (so the in-handler gate should scope it, rather
|
|
58
|
+
* than trust the pipeline). Mirrors the routes layer's `hasPresentedCredential`. */
|
|
59
|
+
function hasPresentedCredential(req: Request): boolean {
|
|
60
|
+
return Boolean(
|
|
61
|
+
req.headers.get("authorization") ||
|
|
62
|
+
req.headers.get("x-agent-relay-token") ||
|
|
63
|
+
new URL(req.url).searchParams.get("token"),
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** HTTP transport adapter: build an AuthContext from a request's auth.
|
|
68
|
+
* - A component token maps to its identity / scopes / signed constraints (the scoped path).
|
|
69
|
+
* - Everything else — the admin token (dashboard), an allow-unauth dev request, OR a
|
|
70
|
+
* credential-less request that the auth PIPELINE already authenticated before dispatch —
|
|
71
|
+
* maps to a full-reach system principal (`*` scope), mirroring authContextFromMcp's `server`
|
|
72
|
+
* branch AND the routes-layer `authorizeRoute` (which no-ops when no credential is presented).
|
|
73
|
+
* This keeps scope-gating services (spawn/shutdown) from re-rejecting a caller the request-level
|
|
74
|
+
* authz already allowed. In production a truly unauthenticated request is stopped at the pipeline,
|
|
75
|
+
* so the credential-less branch only covers in-process callers that bypass it. */
|
|
76
|
+
export function authContextFromRequest(req: Request): AuthContext {
|
|
77
|
+
const component = getComponentAuth(req);
|
|
78
|
+
if (component) return fromComponent(component);
|
|
79
|
+
if (isAuthorized(req) || !hasPresentedCredential(req)) return { actor: { id: "system", kind: "system" }, scopes: ["*"] };
|
|
80
|
+
return fromComponent(null);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** WS-bus transport adapter: build an AuthContext from a socket's component token. */
|
|
84
|
+
export function authContextFromBus(component: ComponentToken | null | undefined): AuthContext {
|
|
85
|
+
return fromComponent(component);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Internal-caller adapter: a fully-trusted system principal with no token
|
|
89
|
+
* (lifecycle-manager tick, orchestrator-report projection). No lineage — these
|
|
90
|
+
* callers act on already-registered agents and never set authoritative parentage. */
|
|
91
|
+
export function authContextFromSystem(): AuthContext {
|
|
92
|
+
return { actor: { id: "system", kind: "system" }, scopes: [] };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** MCP transport adapter. Typed structurally (not against mcp.ts's `McpAuthContext`) so
|
|
96
|
+
* services stay below the transport layer. `server` = an internal/admin caller with no token
|
|
97
|
+
* (full reach, `*` scope); `integration` = an integration token (no caller-agent identity);
|
|
98
|
+
* `component` reuses the same component path as HTTP/bus, so the caller-agent + lineage
|
|
99
|
+
* resolution is identical across every transport. */
|
|
100
|
+
export function authContextFromMcp(auth: {
|
|
101
|
+
kind: "component" | "integration" | "server";
|
|
102
|
+
actor: string;
|
|
103
|
+
scopes?: string[];
|
|
104
|
+
component?: ComponentToken;
|
|
105
|
+
}): AuthContext {
|
|
106
|
+
if (auth.kind === "component") return fromComponent(auth.component);
|
|
107
|
+
if (auth.kind === "server") return { actor: { id: auth.actor, kind: "system" }, scopes: auth.scopes ?? ["*"] };
|
|
108
|
+
return { actor: { id: auth.actor, kind: "integration" }, scopes: auth.scopes ?? [] };
|
|
109
|
+
}
|