agent-relay-server 0.36.2 → 0.38.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 +2 -2
- package/public/assets/{activity-C3mkM6AU.js → activity-ClpDglG8.js} +2 -2
- package/public/assets/{activity-C3mkM6AU.js.map → activity-ClpDglG8.js.map} +1 -1
- package/public/assets/{agent-profiles-DS4_jLPT.js → agent-profiles-kb5H23CF.js} +2 -2
- package/public/assets/{agent-profiles-DS4_jLPT.js.map → agent-profiles-kb5H23CF.js.map} +1 -1
- package/public/assets/{agents-CAhQO7JH.js → agents-CHmEJvqV.js} +2 -2
- package/public/assets/{agents-CAhQO7JH.js.map → agents-CHmEJvqV.js.map} +1 -1
- package/public/assets/{analytics-BwihhhNn.js → analytics-2kTjXIj1.js} +3 -3
- package/public/assets/{analytics-BwihhhNn.js.map → analytics-2kTjXIj1.js.map} +1 -1
- package/public/assets/{automation-BLXToUiU.js → automation-B5U_g-1P.js} +2 -2
- package/public/assets/{automation-BLXToUiU.js.map → automation-B5U_g-1P.js.map} +1 -1
- package/public/assets/{branch-state-badge-D8-T2c1K.js → branch-state-badge-B1K7aIzF.js} +2 -2
- package/public/assets/{branch-state-badge-D8-T2c1K.js.map → branch-state-badge-B1K7aIzF.js.map} +1 -1
- package/public/assets/{channels-ppN8k4hu.js → channels-DyPw9JsY.js} +2 -2
- package/public/assets/{channels-ppN8k4hu.js.map → channels-DyPw9JsY.js.map} +1 -1
- package/public/assets/chat-zPXWB-03.js +2 -0
- package/public/assets/chat-zPXWB-03.js.map +1 -0
- package/public/assets/{connectors-CL9BALhF.js → connectors-k7JYCrrl.js} +2 -2
- package/public/assets/{connectors-CL9BALhF.js.map → connectors-k7JYCrrl.js.map} +1 -1
- package/public/assets/display-ConJ9cJB.js.map +1 -1
- package/public/assets/{formatted-body-impl-BHH0wzY7.js → formatted-body-impl-tmf8IBfr.js} +2 -2
- package/public/assets/{formatted-body-impl-BHH0wzY7.js.map → formatted-body-impl-tmf8IBfr.js.map} +1 -1
- package/public/assets/index-B1QUkb_O.js +21 -0
- package/public/assets/index-B1QUkb_O.js.map +1 -0
- package/public/assets/index-Bins8N_5.css +2 -0
- package/public/assets/{integrations-DX55ARy0.js → integrations-BEkyjBAs.js} +2 -2
- package/public/assets/{integrations-DX55ARy0.js.map → integrations-BEkyjBAs.js.map} +1 -1
- package/public/assets/{maintenance-9n_rJCHT.js → maintenance-Tn23oWBF.js} +2 -2
- package/public/assets/{maintenance-9n_rJCHT.js.map → maintenance-Tn23oWBF.js.map} +1 -1
- package/public/assets/{managed-agents-Rp2-xpBx.js → managed-agents-CasacvJX.js} +2 -2
- package/public/assets/{managed-agents-Rp2-xpBx.js.map → managed-agents-CasacvJX.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-YfJsGh6I.js → markdown-preview-impl-D4UIjB3I.js} +2 -2
- package/public/assets/{markdown-preview-impl-YfJsGh6I.js.map → markdown-preview-impl-D4UIjB3I.js.map} +1 -1
- package/public/assets/{memory-BQONtGQS.js → memory-SVCob0fo.js} +2 -2
- package/public/assets/{memory-BQONtGQS.js.map → memory-SVCob0fo.js.map} +1 -1
- package/public/assets/{messages-DGqpkH72.js → messages-CHK24Uxx.js} +2 -2
- package/public/assets/{messages-DGqpkH72.js.map → messages-CHK24Uxx.js.map} +1 -1
- package/public/assets/{orchestrators-b8k9QoGv.js → orchestrators-CQcJb6VE.js} +2 -2
- package/public/assets/{orchestrators-b8k9QoGv.js.map → orchestrators-CQcJb6VE.js.map} +1 -1
- package/public/assets/{overview-DSU_CggA.js → overview-DbyX7k-7.js} +2 -2
- package/public/assets/{overview-DSU_CggA.js.map → overview-DbyX7k-7.js.map} +1 -1
- package/public/assets/{pairs-DGocNC1U.js → pairs-CaL0_ZfW.js} +2 -2
- package/public/assets/{pairs-DGocNC1U.js.map → pairs-CaL0_ZfW.js.map} +1 -1
- package/public/assets/{security-BSh0QxOl.js → security-BogsfkbT.js} +2 -2
- package/public/assets/{security-BSh0QxOl.js.map → security-BogsfkbT.js.map} +1 -1
- package/public/assets/{settings-C03CAJgO.js → settings-BOsnUh5f.js} +2 -2
- package/public/assets/{settings-C03CAJgO.js.map → settings-BOsnUh5f.js.map} +1 -1
- package/public/assets/{store-DKVWC6Uh.js → store-Bo72e9My.js} +2 -2
- package/public/assets/{store-DKVWC6Uh.js.map → store-Bo72e9My.js.map} +1 -1
- package/public/assets/{tasks-rKbuUPOk.js → tasks-CCxQovOv.js} +2 -2
- package/public/assets/{tasks-rKbuUPOk.js.map → tasks-CCxQovOv.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-CA8u4jh3.js → terminal-viewer-impl-BDikdsxs.js} +2 -2
- package/public/assets/{terminal-viewer-impl-CA8u4jh3.js.map → terminal-viewer-impl-BDikdsxs.js.map} +1 -1
- package/public/assets/{work-queue-DOsA9s4M.js → work-queue-fM-tu0iP.js} +2 -2
- package/public/assets/{work-queue-DOsA9s4M.js.map → work-queue-fM-tu0iP.js.map} +1 -1
- package/public/assets/{workspaces-CoC2nflZ.js → workspaces-Df0xJuIo.js} +2 -2
- package/public/assets/{workspaces-CoC2nflZ.js.map → workspaces-Df0xJuIo.js.map} +1 -1
- package/public/index.html +3 -3
- package/runner/src/adapter.ts +7 -0
- package/scripts/orchestrator-spawn-smoke.ts +65 -33
- package/src/agent-ref.ts +28 -1
- package/src/automations.ts +17 -2
- package/src/bus.ts +52 -41
- package/src/cli/index.ts +1 -1
- package/src/cli/workspace.ts +36 -3
- package/src/compaction-watch.ts +7 -0
- package/src/config-store.ts +46 -0
- package/src/index.ts +23 -6
- package/src/lifecycle-manager.ts +33 -71
- package/src/maintenance.ts +6 -1
- package/src/mcp.ts +106 -309
- 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/routes/workspaces.ts +4 -1
- 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 +177 -0
- package/src/services/shutdown-agent.ts +234 -0
- package/src/services/spawn-agent.ts +284 -0
- package/src/workspace-actions.ts +5 -1
- package/src/workspace-merge.ts +102 -3
- package/src/workspace-phase.ts +15 -1
- package/public/assets/chat-8iIPyww9.js +0 -2
- package/public/assets/chat-8iIPyww9.js.map +0 -1
- package/public/assets/index-3pO43nJo.css +0 -2
- package/public/assets/index-CaauKXl9.js +0 -21
- package/public/assets/index-CaauKXl9.js.map +0 -1
package/src/mcp.ts
CHANGED
|
@@ -1,17 +1,15 @@
|
|
|
1
1
|
import { Buffer } from "node:buffer";
|
|
2
2
|
import { getArtifactStorage, maxArtifactBytes, normalizeDigest } from "./artifact-storage";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { spawnAgent, type SpawnAgentInput } from "./services/spawn-agent";
|
|
4
|
+
import { authContextFromMcp } from "./services/auth-context";
|
|
5
5
|
import { isPathWithinBase } from "./utils";
|
|
6
6
|
import { optionalEnum } from "./validation";
|
|
7
|
-
import { listManagedOrchestratorsForAgent } from "./orchestrator-lookup";
|
|
8
7
|
import { bytesToStream, readBodyBytes } from "./http-body";
|
|
9
8
|
import { MAX_BODY_BYTES, VERSION } from "./config";
|
|
10
9
|
import { getManagedAgentState, getSpawnPolicy, listSpawnPolicies } from "./config-store";
|
|
11
10
|
import { buildSpawnTargets, selectSpawnOrchestrator, spawnCapablePrimer } from "./spawn-targets";
|
|
12
11
|
import { McpAuthError, McpNotFoundError } from "./mcp-errors";
|
|
13
12
|
import {
|
|
14
|
-
countLiveSpawnedAgents,
|
|
15
13
|
createArtifact,
|
|
16
14
|
createActivityEvent,
|
|
17
15
|
getAgent,
|
|
@@ -31,27 +29,27 @@ import {
|
|
|
31
29
|
type AgentSearchSort,
|
|
32
30
|
listArtifactsForEntity,
|
|
33
31
|
listOrchestrators,
|
|
34
|
-
sendMessageWithResult,
|
|
35
32
|
upsertArtifactBlob,
|
|
36
33
|
ValidationError,
|
|
37
34
|
} from "./db";
|
|
38
|
-
import {
|
|
35
|
+
import { resolveCallerAgentId, type DeliveryReceipt } from "./agent-ref";
|
|
36
|
+
import { ShutdownAuthError, ShutdownTargetError, shutdownAgent } from "./services/shutdown-agent";
|
|
39
37
|
import { emitRelayEvent } from "./events";
|
|
40
|
-
import { emitMessageQueued, emitNewMessage } from "./sse";
|
|
41
38
|
import {
|
|
42
39
|
getComponentAuth,
|
|
43
40
|
getIntegrationAuth,
|
|
44
41
|
hasComponentScope,
|
|
45
42
|
hasIntegrationScope,
|
|
46
43
|
isComponentAuthorizedFor,
|
|
47
|
-
isIntegrationAllowed,
|
|
48
44
|
} from "./security";
|
|
49
|
-
import
|
|
45
|
+
import { sendMessageService } from "./services/send-message";
|
|
46
|
+
import { ServiceAuthError } from "./services/errors";
|
|
47
|
+
import type { ActivityKind, ArtifactKind, ArtifactSensitivity, AttachmentRef, Command, SendMessageInput, Message, SpawnApprovalMode, SpawnProvider, WorkspaceAutoMergePolicy, WorkspaceMergeStrategy, WorkspaceMode, WorkspaceRecord } from "./types";
|
|
50
48
|
import { LAND_STRATEGIES, applyWorkspaceAction, waitForWorkspaceStatus, type WorkspaceAction } from "./workspace-actions";
|
|
49
|
+
import { AUTO_MERGE_POLICIES } from "./workspace-merge";
|
|
51
50
|
import { describeWorkspacePhase, landReceipt, readyContract, worktreeMcpInstructions } from "./workspace-phase";
|
|
52
51
|
import { type ProviderEffort } from "agent-relay-sdk/provider-catalog";
|
|
53
52
|
import { errMessage, isRecord, stringValue, SPAWN_PROVIDERS, APPROVAL_MODES, VALID_EFFORTS, VALID_WORKSPACE_MODES } from "agent-relay-sdk";
|
|
54
|
-
import { runnerRuntimeTokenEnv } from "./runtime-tokens";
|
|
55
53
|
|
|
56
54
|
type JsonRpcId = string | number | null;
|
|
57
55
|
|
|
@@ -94,7 +92,7 @@ const VALID_ARTIFACT_ENTITY_TYPES = ["message", "task", "recipeRun", "recipeStep
|
|
|
94
92
|
const TOOLS: ToolDefinition[] = [
|
|
95
93
|
{
|
|
96
94
|
name: "relay_send_message",
|
|
97
|
-
description: "Send an Agent Relay message. Returns a delivery receipt (delivered/expectReply/recipients): if expectReply is false, no live recipient exists — don't wait for a reply.
|
|
95
|
+
description: "Send an Agent Relay message. Returns a delivery receipt (delivered/expectReply/recipients): if expectReply is false, no live recipient exists — don't wait for a reply. An ambiguous target is rejected up front. An unknown target is stored for delivery once that id registers (the receipt reports delivered:false with empty recipients and a reason) — never silently dropped.",
|
|
98
96
|
requiredScopes: ["messages:write", "message:send"],
|
|
99
97
|
inputSchema: {
|
|
100
98
|
type: "object",
|
|
@@ -398,10 +396,12 @@ const TOOLS: ToolDefinition[] = [
|
|
|
398
396
|
type: "object",
|
|
399
397
|
properties: {
|
|
400
398
|
workspaceId: { type: "string", description: "Defaults to your own isolated workspace." },
|
|
401
|
-
strategy: { type: "string", enum: ["pr", "rebase-ff", "auto"], description: "Merge strategy (default auto)." },
|
|
399
|
+
strategy: { type: "string", enum: ["pr", "rebase-ff", "auto"], description: "Merge strategy (default auto). Falls back to the instance landing.strategy config when unset." },
|
|
402
400
|
deleteBranch: { type: "boolean", description: "Delete the branch after a successful merge (default true)." },
|
|
403
401
|
prTitle: { type: "string" },
|
|
404
402
|
prBody: { type: "string" },
|
|
403
|
+
autoMerge: { type: "string", enum: AUTO_MERGE_POLICIES, description: "Auto-merge policy for PR-strategy lands. Defaults to 'on-green' when strategy resolves to 'pr'. Ignored for rebase-ff/auto strategies." },
|
|
404
|
+
reviewer: { type: "string", description: "GitHub login or team slug to request as a reviewer on the opened PR (pr strategy only)." },
|
|
405
405
|
detail: { type: "string" },
|
|
406
406
|
},
|
|
407
407
|
additionalProperties: false,
|
|
@@ -531,41 +531,18 @@ async function callTool(auth: McpAuthContext, params: unknown): Promise<Record<s
|
|
|
531
531
|
// (see issueMcpRuntimeToken). That single allowed agent IS the caller's identity, and the
|
|
532
532
|
// security layer already treats it as the only `from` this token may use. Derive it so
|
|
533
533
|
// agents never need to know — or type — their own id.
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
//
|
|
541
|
-
//
|
|
542
|
-
//
|
|
543
|
-
// the orchestrator authenticate with a runner token that carries no `agents` constraint but
|
|
544
|
-
// DOES carry its spawnRequestId/policy — resolve those back to the registered agent card so
|
|
545
|
-
// they never need to pass `from`. Returns undefined for server/admin tokens (unrestricted by
|
|
546
|
-
// design) and multi-agent tokens. Keep `resolveSender`/`relayWhoami` on THIS, not the narrower
|
|
547
|
-
// `senderIdentity`, or managed agents silently lose implicit identity again (the #243 drift).
|
|
534
|
+
// THE caller-identity resolver for MCP: the agent id behind this token, for `from`-autofill,
|
|
535
|
+
// relay_whoami, and spawn/shutdown gating (#221, #243). Delegates to the shared
|
|
536
|
+
// `resolveCallerAgentId` (one home, src/agent-ref.ts) so the bus/HTTP/MCP transports resolve
|
|
537
|
+
// caller identity identically — managed agents (runner token carrying spawnRequestId/policy,
|
|
538
|
+
// no `agents` constraint) resolve back to their registered card; server/admin/multi-agent
|
|
539
|
+
// tokens return undefined. Keep `relay_whoami` + the spawn/shutdown gates on THIS, not a
|
|
540
|
+
// narrower check, or managed agents silently lose implicit identity again (the #243 drift).
|
|
541
|
+
// (The send/reply `from`-autofill now flows through the shared send service's `ctx.callerAgentId`,
|
|
542
|
+
// populated by `authContextFromMcp` from this same resolver — one home across all transports.)
|
|
548
543
|
function callerAgentId(auth: McpAuthContext): string | undefined {
|
|
549
|
-
const direct = senderIdentity(auth);
|
|
550
|
-
if (direct) return direct;
|
|
551
544
|
if (auth.kind !== "component") return undefined;
|
|
552
|
-
|
|
553
|
-
const spawnRequestId = c?.spawnRequestIds?.length === 1 ? c.spawnRequestIds[0] : undefined;
|
|
554
|
-
const policyName = c?.policies?.length === 1 ? c.policies[0] : undefined;
|
|
555
|
-
if (!spawnRequestId && !policyName) return undefined;
|
|
556
|
-
const match = listAgents().find((a) =>
|
|
557
|
-
(spawnRequestId !== undefined && a.meta?.spawnRequestId === spawnRequestId) ||
|
|
558
|
-
(policyName !== undefined && a.meta?.policyName === policyName));
|
|
559
|
-
return match?.id;
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
function resolveSender(auth: McpAuthContext, rawFrom: unknown): string {
|
|
563
|
-
// Token identity wins and cannot be spoofed; any provided `from` is ignored when known.
|
|
564
|
-
// Resolves both constraints.agents tokens AND spawn/policy-managed agents (#243).
|
|
565
|
-
const identity = callerAgentId(auth);
|
|
566
|
-
if (identity) return identity;
|
|
567
|
-
// Server/integration/multi-agent tokens carry no single identity — keep requiring `from`.
|
|
568
|
-
return stringField(rawFrom, "from", { required: true, max: 200 });
|
|
545
|
+
return resolveCallerAgentId(auth.component?.constraints, listAgents);
|
|
569
546
|
}
|
|
570
547
|
|
|
571
548
|
function relayWhoami(auth: McpAuthContext): Record<string, unknown> {
|
|
@@ -580,20 +557,33 @@ function relayWhoami(auth: McpAuthContext): Record<string, unknown> {
|
|
|
580
557
|
};
|
|
581
558
|
}
|
|
582
559
|
|
|
560
|
+
// MCP transport adapter over the shared send service (epic #342). The service owns from-resolution
|
|
561
|
+
// (token identity via ctx.callerAgentId), reply routing, target resolution + the converged
|
|
562
|
+
// store-ahead policy, authorization, persistence, and emit. This adapter only builds the input,
|
|
563
|
+
// the AuthContext (carrying the integration token separately, per the lean-AuthContext contract),
|
|
564
|
+
// and maps the service's typed ServiceAuthError onto the MCP wire error (McpAuthError → 403).
|
|
565
|
+
function runMcpSend(auth: McpAuthContext, input: SendMessageInput): Message & { delivery: DeliveryReceipt } {
|
|
566
|
+
try {
|
|
567
|
+
const result = sendMessageService(
|
|
568
|
+
input,
|
|
569
|
+
authContextFromMcp({ kind: auth.kind, actor: auth.actor, scopes: auth.scopes, component: auth.component }),
|
|
570
|
+
{ integration: auth.integration },
|
|
571
|
+
);
|
|
572
|
+
return { ...result.message, delivery: result.receipt };
|
|
573
|
+
} catch (e) {
|
|
574
|
+
if (e instanceof ServiceAuthError) throw new McpAuthError(e.message);
|
|
575
|
+
throw e; // AmbiguousTargetError/ValidationError → invalid-params via the central JSON-RPC map.
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
583
579
|
function relaySendMessage(auth: McpAuthContext, args: Record<string, unknown>): Message & { delivery: DeliveryReceipt } {
|
|
584
580
|
const attachments = optionalAttachments(args.attachments);
|
|
585
581
|
const payload = payloadWithAttachments(optionalRecord(args.payload, "payload"), attachments);
|
|
586
|
-
const requestedTo = stringField(args.to, "to", { required: true, max: 200 });
|
|
587
|
-
const sender = resolveSender(auth, args.from);
|
|
588
|
-
// Resolve the target to a canonical agent id (so poll-time matching works) and refuse
|
|
589
|
-
// up front when it's unknown or ambiguous — never store a message no one will receive.
|
|
590
|
-
// Exclude the sender so a bare ref can't loop back to its own author (#290).
|
|
591
|
-
const plan = planSend(requestedTo, listAgents(), { excludeId: sender });
|
|
592
|
-
if (plan.kind === "not_found") throw new McpNotFoundError(plan.message);
|
|
593
|
-
if (plan.kind === "ambiguous") throw new ValidationError(plan.message);
|
|
594
582
|
const input: SendMessageInput = {
|
|
595
|
-
from
|
|
596
|
-
|
|
583
|
+
// `from` is resolved by the service from the token identity (callerAgentId wins); the wire
|
|
584
|
+
// value is only a fallback for identity-less tokens.
|
|
585
|
+
from: optionalString(args.from, "from", 200) ?? "",
|
|
586
|
+
to: stringField(args.to, "to", { required: true, max: 200 }),
|
|
597
587
|
body: stringField(args.body, "body", { required: true, maxBytes: MAX_BODY_BYTES }),
|
|
598
588
|
subject: optionalString(args.subject, "subject", 200),
|
|
599
589
|
channel: optionalString(args.channel, "channel", 120),
|
|
@@ -603,11 +593,7 @@ function relaySendMessage(auth: McpAuthContext, args: Record<string, unknown>):
|
|
|
603
593
|
payload,
|
|
604
594
|
meta: optionalRecord(args.meta, "meta"),
|
|
605
595
|
};
|
|
606
|
-
|
|
607
|
-
assertComponentResourceAllowed(auth, { scope: "message:send", resource: { target: input.to, channel: input.channel, agentId: input.from } });
|
|
608
|
-
const result = sendMessageWithResult(input);
|
|
609
|
-
emitMessage(result.message, result.created);
|
|
610
|
-
return { ...result.message, delivery: plan.receipt };
|
|
596
|
+
return runMcpSend(auth, input);
|
|
611
597
|
}
|
|
612
598
|
|
|
613
599
|
function relayReply(auth: McpAuthContext, args: Record<string, unknown>): Message & { delivery: DeliveryReceipt } {
|
|
@@ -620,30 +606,20 @@ function relayReply(auth: McpAuthContext, args: Record<string, unknown>): Messag
|
|
|
620
606
|
const replyPayload = payloadWithAttachments({
|
|
621
607
|
...payload,
|
|
622
608
|
...(format ? { message: { ...(isRecord(payload.message) ? payload.message : {}), format } } : {}),
|
|
623
|
-
...replyContext(parent),
|
|
624
609
|
}, attachments);
|
|
625
610
|
const input: SendMessageInput = {
|
|
626
|
-
from:
|
|
627
|
-
to
|
|
611
|
+
from: optionalString(args.from, "from", 200) ?? "",
|
|
612
|
+
// Empty `to` + replyTo lets the service auto-route to the parent's sender, inherit its
|
|
613
|
+
// channel, and propagate channel replyContext — identical to the HTTP reply path.
|
|
614
|
+
to: "",
|
|
628
615
|
body: stringField(args.body, "body", { required: true, maxBytes: MAX_BODY_BYTES }),
|
|
629
616
|
subject: optionalString(args.subject, "subject", 200),
|
|
630
|
-
channel: parent.channel,
|
|
631
617
|
replyTo: parent.id,
|
|
632
618
|
attachments,
|
|
633
619
|
payload: replyPayload,
|
|
634
620
|
meta: optionalRecord(args.meta, "meta"),
|
|
635
621
|
};
|
|
636
|
-
|
|
637
|
-
assertComponentResourceAllowed(auth, { scope: "message:send", resource: { target: input.to, channel: input.channel, agentId: input.from } });
|
|
638
|
-
const result = sendMessageWithResult(input);
|
|
639
|
-
emitMessage(result.message, result.created);
|
|
640
|
-
// Reply routing is fixed to the parent's sender — never reject, but report whether
|
|
641
|
-
// that original sender is still reachable so the agent doesn't wait forever.
|
|
642
|
-
const plan = planSend(input.to, listAgents(), { excludeId: input.from });
|
|
643
|
-
const delivery: DeliveryReceipt = plan.kind === "not_found" || plan.kind === "ambiguous"
|
|
644
|
-
? { delivered: false, expectReply: false, recipients: [], reason: "original sender no longer reachable" }
|
|
645
|
-
: plan.receipt;
|
|
646
|
-
return { ...result.message, delivery };
|
|
622
|
+
return runMcpSend(auth, input);
|
|
647
623
|
}
|
|
648
624
|
|
|
649
625
|
function relayGetMessage(args: Record<string, unknown>): Record<string, unknown> {
|
|
@@ -788,136 +764,34 @@ function relayFindAgents(auth: McpAuthContext, args: Record<string, unknown>): R
|
|
|
788
764
|
}
|
|
789
765
|
|
|
790
766
|
async function relaySpawnAgent(auth: McpAuthContext, args: Record<string, unknown>): Promise<Record<string, unknown>> {
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
//
|
|
795
|
-
//
|
|
796
|
-
//
|
|
797
|
-
const
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
// read-only reviewer); non-agent/admin callers (no caller record) keep the safe `guarded` default.
|
|
820
|
-
// Precedence: explicit approvalMode > caller mode > guarded.
|
|
821
|
-
const callerApprovalMode = optionalEnum(stringValue(caller?.meta?.approvalMode), "approvalMode", APPROVAL_MODES) as SpawnApprovalMode | undefined;
|
|
822
|
-
const approvalMode = (optionalEnum(args.approvalMode, "approvalMode", APPROVAL_MODES) as SpawnApprovalMode | undefined)
|
|
823
|
-
?? callerApprovalMode
|
|
824
|
-
?? "guarded";
|
|
825
|
-
const spawnRequestId = optionalString(args.spawnRequestId, "spawnRequestId", 160) ?? generateSpawnRequestId();
|
|
826
|
-
const label = optionalString(args.label, "label", 120);
|
|
827
|
-
const policyName = optionalString(args.policyName, "policyName", 120);
|
|
828
|
-
const profile = optionalString(args.profile, "profile", 120);
|
|
829
|
-
// #324 — expose the workspace knob on the MCP spawn surface (payload home always supported it; the
|
|
830
|
-
// handler just never passed it → orchestrator fell back to `inherit`→`shared`, so a worker edited the
|
|
831
|
-
// CALLER's live tree). Agent-initiated spawns (real caller behind the token) default to `isolated` (a
|
|
832
|
-
// branch worker that auto-lands); non-agent callers (admin/server) keep `inherit`. Explicit wins.
|
|
833
|
-
const workspaceMode = (optionalEnum(args.workspaceMode, "workspaceMode", VALID_WORKSPACE_MODES) as WorkspaceMode | undefined) ?? (callerId ? "isolated" : undefined);
|
|
834
|
-
|
|
835
|
-
// #221 runtime gate (belt; the coarse `command:spawn` scope is enforced in callTool, and is
|
|
836
|
-
// granted only to agents whose profile sets maxSpawnedAgents>0 and never to children).
|
|
837
|
-
// Server/admin tokens have no caller identity → unrestricted by design.
|
|
838
|
-
if (callerId) {
|
|
839
|
-
if (caller?.spawnedBy) {
|
|
840
|
-
throw new McpAuthError("spawned agents cannot spawn further agents (no grandchildren)");
|
|
841
|
-
}
|
|
842
|
-
const quota = auth.component?.constraints?.maxSpawnedAgents ?? 0;
|
|
843
|
-
const live = countLiveSpawnedAgents(callerId);
|
|
844
|
-
if (live >= quota) {
|
|
845
|
-
throw new ValidationError(`spawn quota reached (${live}/${quota} live children) — shut one down or wait for one to exit`);
|
|
846
|
-
}
|
|
847
|
-
}
|
|
848
|
-
|
|
849
|
-
// #323 — gate child spawn only on `orchestrators` (the parent's legit bound), NOT its self-scoping
|
|
850
|
-
// spawnRequestIds/cwdPrefixes/policies: those describe the child, not parent-owned resources, so
|
|
851
|
-
// gating on them makes maxSpawnedAgents unreachable for every component token (cwd checked above).
|
|
852
|
-
assertComponentResourceAllowed(auth, { scope: "agent:write", resource: { orchestratorId: orchestrator.id } });
|
|
853
|
-
|
|
854
|
-
// Child runner token: a normal long-living agent that is NOT itself spawn-capable
|
|
855
|
-
// (canSpawn:false → no grandchildren), stamped with authoritative lineage so it registers
|
|
856
|
-
// with spawnedBy = caller (the child can't forge it; it's read from the signed token).
|
|
857
|
-
const env = runnerRuntimeTokenEnv({
|
|
858
|
-
orchestratorId: orchestrator.id,
|
|
859
|
-
cwd: resolvedCwd,
|
|
860
|
-
provider,
|
|
861
|
-
label,
|
|
862
|
-
policyName,
|
|
863
|
-
spawnRequestId,
|
|
864
|
-
createdBy: callerId ?? auth.actor,
|
|
865
|
-
agentInitiated: true,
|
|
866
|
-
...(callerId ? { spawnedBy: callerId } : {}),
|
|
867
|
-
});
|
|
868
|
-
const command = createCommand({
|
|
869
|
-
type: "agent.spawn",
|
|
870
|
-
source: "system",
|
|
871
|
-
target: orchestrator.agentId,
|
|
872
|
-
correlationId: spawnRequestId,
|
|
873
|
-
params: buildSpawnCommand({
|
|
874
|
-
provider,
|
|
875
|
-
modelParams: selection,
|
|
876
|
-
cwd: resolvedCwd,
|
|
877
|
-
...(workspaceMode ? { workspaceMode } : {}),
|
|
878
|
-
label,
|
|
879
|
-
profile: profile || undefined,
|
|
880
|
-
tags: optionalStringArray(args.tags, "tags") ?? [],
|
|
881
|
-
capabilities: optionalStringArray(args.capabilities, "capabilities") ?? [],
|
|
882
|
-
approvalMode,
|
|
883
|
-
permissionMode: approvalMode,
|
|
884
|
-
providerArgs: optionalStringArray(args.providerArgs, "providerArgs") ?? [],
|
|
885
|
-
prompt: optionalString(args.prompt, "prompt", 16_000),
|
|
886
|
-
systemPromptAppend: optionalString(args.systemPromptAppend, "systemPromptAppend", 64_000),
|
|
887
|
-
policyName,
|
|
888
|
-
spawnRequestId,
|
|
889
|
-
env,
|
|
890
|
-
requestedBy: auth.actor,
|
|
891
|
-
requestedVia: "mcp",
|
|
892
|
-
requestedAt: Date.now(),
|
|
893
|
-
orchestratorId: orchestrator.id,
|
|
894
|
-
// #308 — stamp the spawning parent so a failed agent.spawn command can be routed back to
|
|
895
|
-
// it (the child never registers, so there's no agent record to resolve `spawnedBy` from).
|
|
896
|
-
...(callerId ? { extra: { spawnedBy: callerId } } : {}),
|
|
897
|
-
}),
|
|
898
|
-
});
|
|
899
|
-
emitCommand(command);
|
|
900
|
-
|
|
901
|
-
// #255: resolve the spawned agent id once it registers. Spawn is a fire-and-forget command
|
|
902
|
-
// over the bus; the child registers back to THIS relay (same DB) with meta.spawnRequestId set,
|
|
903
|
-
// so a bounded poll links the request to the agent without a separate relay_find_agents round
|
|
904
|
-
// trip. waitForRegistrationMs:0 opts out (pure fire-and-forget); the default is short because
|
|
905
|
-
// isolated-worktree spawns register near-instantly (symlinked deps).
|
|
906
|
-
const waitMs = Math.min(optionalNonNegativeInt(args.waitForRegistrationMs, "waitForRegistrationMs") ?? 8000, 30000);
|
|
907
|
-
const agentId = waitMs > 0 ? await waitForSpawnedAgent(spawnRequestId, waitMs) : null;
|
|
908
|
-
return { ok: true, spawnRequestId, orchestratorId: orchestrator.id, provider, agentId, registered: agentId !== null, command };
|
|
909
|
-
}
|
|
910
|
-
|
|
911
|
-
// Poll the agents table for the child that registers with this spawnRequestId (#255). Returns
|
|
912
|
-
// the resolved agent id, or null on timeout (the caller still has spawnRequestId to poll later).
|
|
913
|
-
async function waitForSpawnedAgent(spawnRequestId: string, timeoutMs: number, pollMs = 300): Promise<string | null> {
|
|
914
|
-
const deadline = Date.now() + timeoutMs;
|
|
915
|
-
for (;;) {
|
|
916
|
-
const match = listAgents().find((a) => a.meta?.spawnRequestId === spawnRequestId);
|
|
917
|
-
if (match) return match.id;
|
|
918
|
-
if (Date.now() >= deadline) return null;
|
|
919
|
-
await new Promise<void>((resolve) => setTimeout(resolve, Math.min(pollMs, Math.max(0, deadline - Date.now()))));
|
|
920
|
-
}
|
|
767
|
+
// Thin transport: parse wire → build AuthContext → call the spawnAgent service → serialize.
|
|
768
|
+
// ALL policy + side effects (caller-context inheritance, the #221 no-grandchild + quota gate,
|
|
769
|
+
// the #323 resource gate, the command:spawn scope assert, authoritative lineage stamping, the
|
|
770
|
+
// command payload + emit + audit) live in src/services/spawn-agent.ts so the MCP and HTTP spawn
|
|
771
|
+
// paths cannot drift (epic #342). `waitForRegistrationMs` defaults to a short wait here because
|
|
772
|
+
// isolated-worktree spawns register near-instantly (symlinked deps); 0 opts out.
|
|
773
|
+
const input: SpawnAgentInput = {
|
|
774
|
+
provider: enumField(args.provider, "provider", SPAWN_PROVIDERS) as SpawnProvider,
|
|
775
|
+
orchestratorId: optionalString(args.orchestratorId, "orchestratorId", 200),
|
|
776
|
+
cwd: optionalString(args.cwd, "cwd", 500),
|
|
777
|
+
model: optionalString(args.model, "model", 120),
|
|
778
|
+
effort: optionalEnum(args.effort, "effort", VALID_EFFORTS) as ProviderEffort | undefined,
|
|
779
|
+
approvalMode: optionalEnum(args.approvalMode, "approvalMode", APPROVAL_MODES) as SpawnApprovalMode | undefined,
|
|
780
|
+
workspaceMode: optionalEnum(args.workspaceMode, "workspaceMode", VALID_WORKSPACE_MODES) as WorkspaceMode | undefined,
|
|
781
|
+
label: optionalString(args.label, "label", 120),
|
|
782
|
+
policyName: optionalString(args.policyName, "policyName", 120),
|
|
783
|
+
profile: optionalString(args.profile, "profile", 120),
|
|
784
|
+
prompt: optionalString(args.prompt, "prompt", 16_000),
|
|
785
|
+
systemPromptAppend: optionalString(args.systemPromptAppend, "systemPromptAppend", 64_000),
|
|
786
|
+
tags: optionalStringArray(args.tags, "tags"),
|
|
787
|
+
capabilities: optionalStringArray(args.capabilities, "capabilities"),
|
|
788
|
+
providerArgs: optionalStringArray(args.providerArgs, "providerArgs"),
|
|
789
|
+
spawnRequestId: optionalString(args.spawnRequestId, "spawnRequestId", 160),
|
|
790
|
+
requestedVia: "mcp",
|
|
791
|
+
waitForRegistrationMs: optionalNonNegativeInt(args.waitForRegistrationMs, "waitForRegistrationMs") ?? 8000,
|
|
792
|
+
};
|
|
793
|
+
const result = await spawnAgent(input, authContextFromMcp(auth));
|
|
794
|
+
return { ...result };
|
|
921
795
|
}
|
|
922
796
|
|
|
923
797
|
function relayShutdownAgent(auth: McpAuthContext, args: Record<string, unknown>): Record<string, unknown> {
|
|
@@ -929,50 +803,32 @@ function relayShutdownAgent(auth: McpAuthContext, args: Record<string, unknown>)
|
|
|
929
803
|
throw new ValidationError("agentId, policyName, spawnRequestId, or tmuxSession required");
|
|
930
804
|
}
|
|
931
805
|
|
|
932
|
-
// #221
|
|
933
|
-
//
|
|
934
|
-
//
|
|
935
|
-
//
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
806
|
+
// The #221 parent→child gate, control-orchestrator resolution, and the canonical
|
|
807
|
+
// agent.shutdown payload + side-effects all live in the shutdownAgent service now —
|
|
808
|
+
// shared byte-for-byte with the HTTP route and the bus command frame (epic #342, #347).
|
|
809
|
+
// Map the service's transport-neutral errors back to MCP's JSON-RPC error codes.
|
|
810
|
+
try {
|
|
811
|
+
const result = shutdownAgent(
|
|
812
|
+
{
|
|
813
|
+
agentId,
|
|
814
|
+
policyName,
|
|
815
|
+
spawnRequestId,
|
|
816
|
+
tmuxSession,
|
|
817
|
+
orchestratorId: optionalString(args.orchestratorId, "orchestratorId", 200),
|
|
818
|
+
graceful: true,
|
|
819
|
+
timeoutMs: optionalPositiveInt(args.timeoutMs, "timeoutMs") ?? 10_000,
|
|
820
|
+
reason: optionalString(args.reason, "reason", 200) ?? "mcp-shutdown",
|
|
821
|
+
requestedVia: "mcp",
|
|
822
|
+
requestedBy: auth.actor,
|
|
823
|
+
},
|
|
824
|
+
authContextFromMcp(auth),
|
|
825
|
+
);
|
|
826
|
+
return { ok: true, action: "shutdown", orchestratorId: result.orchestratorId, command: result.command };
|
|
827
|
+
} catch (e) {
|
|
828
|
+
if (e instanceof ShutdownAuthError) throw new McpAuthError(e.message);
|
|
829
|
+
if (e instanceof ShutdownTargetError) throw new McpNotFoundError(e.message);
|
|
830
|
+
throw e;
|
|
945
831
|
}
|
|
946
|
-
const orchestrator = selectControlOrchestrator({
|
|
947
|
-
orchestratorId: optionalString(args.orchestratorId, "orchestratorId", 200),
|
|
948
|
-
agentId,
|
|
949
|
-
policyName,
|
|
950
|
-
spawnRequestId,
|
|
951
|
-
tmuxSession,
|
|
952
|
-
});
|
|
953
|
-
const timeoutMs = optionalPositiveInt(args.timeoutMs, "timeoutMs") ?? 10_000;
|
|
954
|
-
const command = createCommand({
|
|
955
|
-
type: "agent.shutdown",
|
|
956
|
-
source: "system",
|
|
957
|
-
target: orchestrator.agentId,
|
|
958
|
-
correlationId: spawnRequestId,
|
|
959
|
-
params: {
|
|
960
|
-
action: "shutdown",
|
|
961
|
-
agentId,
|
|
962
|
-
policyName,
|
|
963
|
-
spawnRequestId,
|
|
964
|
-
tmuxSession,
|
|
965
|
-
graceful: true,
|
|
966
|
-
timeoutMs,
|
|
967
|
-
reason: optionalString(args.reason, "reason", 200) ?? "mcp-shutdown",
|
|
968
|
-
requestedBy: auth.actor,
|
|
969
|
-
requestedVia: "mcp",
|
|
970
|
-
requestedAt: Date.now(),
|
|
971
|
-
orchestratorId: orchestrator.id,
|
|
972
|
-
},
|
|
973
|
-
});
|
|
974
|
-
emitCommand(command);
|
|
975
|
-
return { ok: true, action: "shutdown", orchestratorId: orchestrator.id, command };
|
|
976
832
|
}
|
|
977
833
|
|
|
978
834
|
// --- Workspace lifecycle tools (#215) -------------------------------------
|
|
@@ -1066,6 +922,8 @@ function relayWorkspaceMutation(auth: McpAuthContext, action: WorkspaceAction, a
|
|
|
1066
922
|
deleteBranch: action === "merge" ? optionalBoolean(args.deleteBranch, "deleteBranch") : undefined,
|
|
1067
923
|
prTitle: optionalString(args.prTitle, "prTitle", 240),
|
|
1068
924
|
prBody: optionalString(args.prBody, "prBody", 8000),
|
|
925
|
+
autoMerge: action === "merge" ? (optionalEnum(args.autoMerge, "autoMerge", AUTO_MERGE_POLICIES) as WorkspaceAutoMergePolicy | undefined) : undefined,
|
|
926
|
+
reviewer: action === "merge" ? optionalString(args.reviewer, "reviewer", 240) : undefined,
|
|
1069
927
|
purpose: optionalString(args.purpose, "purpose", 120),
|
|
1070
928
|
checkOnly: action === "deps-refresh" ? optionalBoolean(args.checkOnly, "checkOnly") === true : undefined,
|
|
1071
929
|
auditMetadata: { via: "mcp", actor: auth.actor },
|
|
@@ -1088,16 +946,6 @@ function relayWorkspaceMutation(auth: McpAuthContext, action: WorkspaceAction, a
|
|
|
1088
946
|
return payload;
|
|
1089
947
|
}
|
|
1090
948
|
|
|
1091
|
-
function replyContext(parent: Message): Record<string, unknown> {
|
|
1092
|
-
const parentPayload = parent.payload ?? {};
|
|
1093
|
-
if (parentPayload.schema !== "agent-relay.channel.v1" && !parentPayload.conversation) return {};
|
|
1094
|
-
const context: Record<string, unknown> = {};
|
|
1095
|
-
if (parent.channel) context.channelId = parent.channel;
|
|
1096
|
-
if (isRecord(parentPayload.conversation)) context.conversationId = parentPayload.conversation.id;
|
|
1097
|
-
if (isRecord(parentPayload.event)) context.parentEventId = parentPayload.event.id;
|
|
1098
|
-
if (parentPayload.source) context.source = parentPayload.source;
|
|
1099
|
-
return { replyContext: context };
|
|
1100
|
-
}
|
|
1101
949
|
|
|
1102
950
|
function payloadWithAttachments(
|
|
1103
951
|
payload: Record<string, unknown> | undefined,
|
|
@@ -1146,45 +994,6 @@ function relaySpawnTargets(auth: McpAuthContext): Record<string, unknown> {
|
|
|
1146
994
|
});
|
|
1147
995
|
}
|
|
1148
996
|
|
|
1149
|
-
function selectControlOrchestrator(input: {
|
|
1150
|
-
orchestratorId?: string;
|
|
1151
|
-
agentId?: string;
|
|
1152
|
-
policyName?: string;
|
|
1153
|
-
spawnRequestId?: string;
|
|
1154
|
-
tmuxSession?: string;
|
|
1155
|
-
}): NonNullable<ReturnType<typeof getOrchestrator>> {
|
|
1156
|
-
if (input.orchestratorId) {
|
|
1157
|
-
const orchestrator = getOrchestrator(input.orchestratorId);
|
|
1158
|
-
if (!orchestrator) throw new McpNotFoundError(`orchestrator ${input.orchestratorId} not found`);
|
|
1159
|
-
if (orchestrator.status !== "online") throw new ValidationError("orchestrator is offline");
|
|
1160
|
-
return orchestrator;
|
|
1161
|
-
}
|
|
1162
|
-
const agent = input.agentId ? getAgent(input.agentId) : null;
|
|
1163
|
-
const orchestrator = agent ? managedControlOrchestrator(agent, input) : (listManagedOrchestratorsForAgent(input)[0] ?? null);
|
|
1164
|
-
if (!orchestrator) throw new McpNotFoundError("no orchestrator found for agent control target");
|
|
1165
|
-
if (orchestrator.status !== "online") throw new ValidationError("orchestrator is offline");
|
|
1166
|
-
return orchestrator;
|
|
1167
|
-
}
|
|
1168
|
-
|
|
1169
|
-
function managedControlOrchestrator(
|
|
1170
|
-
agent: AgentCard,
|
|
1171
|
-
input: { policyName?: string; spawnRequestId?: string; tmuxSession?: string },
|
|
1172
|
-
): NonNullable<ReturnType<typeof getOrchestrator>> | null {
|
|
1173
|
-
const str = (v: unknown): string | undefined => (typeof v === "string" ? v : undefined);
|
|
1174
|
-
return listManagedOrchestratorsForAgent({
|
|
1175
|
-
agentId: agent.id,
|
|
1176
|
-
policyName: input.policyName ?? str(agent.meta?.policyName),
|
|
1177
|
-
spawnRequestId: input.spawnRequestId ?? str(agent.meta?.spawnRequestId),
|
|
1178
|
-
tmuxSession: input.tmuxSession ?? str(agent.meta?.tmuxSession),
|
|
1179
|
-
})[0] ?? (agent.machine ? getOrchestrator(agent.machine) : null);
|
|
1180
|
-
}
|
|
1181
|
-
|
|
1182
|
-
function providerSelection(provider: SpawnProvider, args: Record<string, unknown>): SpawnModelParams {
|
|
1183
|
-
const model = optionalString(args.model, "model", 120);
|
|
1184
|
-
const effort = optionalEnum(args.effort, "effort", VALID_EFFORTS) as ProviderEffort | undefined;
|
|
1185
|
-
return resolveSpawnModelParams(provider, model, effort);
|
|
1186
|
-
}
|
|
1187
|
-
|
|
1188
997
|
function artifactBytes(args: Record<string, unknown>): Uint8Array {
|
|
1189
998
|
const hasContent = args.content !== undefined && args.content !== null;
|
|
1190
999
|
const hasBase64 = args.base64 !== undefined && args.base64 !== null;
|
|
@@ -1232,12 +1041,6 @@ function sniffMediaType(bytes: Uint8Array, hinted?: string, filename?: string):
|
|
|
1232
1041
|
return hinted || "text/plain";
|
|
1233
1042
|
}
|
|
1234
1043
|
|
|
1235
|
-
function emitMessage(message: Message, created: boolean): void {
|
|
1236
|
-
if (!created) return;
|
|
1237
|
-
if (message.deliveryStatus === "queued") emitMessageQueued(message);
|
|
1238
|
-
else emitNewMessage(message);
|
|
1239
|
-
}
|
|
1240
|
-
|
|
1241
1044
|
function visibleTools(auth: McpAuthContext): Array<Record<string, unknown>> {
|
|
1242
1045
|
return TOOLS
|
|
1243
1046
|
.filter((tool) => hasAnyScope(auth, tool.requiredScopes))
|
|
@@ -1264,12 +1067,6 @@ function jsonRpcError(id: JsonRpcId, code: number, message: string, data?: unkno
|
|
|
1264
1067
|
return { jsonrpc: "2.0", id, error: { code, message, ...(data ? { data } : {}) } };
|
|
1265
1068
|
}
|
|
1266
1069
|
|
|
1267
|
-
function assertIntegrationTargetAllowed(auth: McpAuthContext, target?: string, channel?: string): void {
|
|
1268
|
-
if (auth.integration && !isIntegrationAllowed(auth.integration, { target, channel })) {
|
|
1269
|
-
throw new McpAuthError("integration token cannot target this message");
|
|
1270
|
-
}
|
|
1271
|
-
}
|
|
1272
|
-
|
|
1273
1070
|
function assertComponentResourceAllowed(
|
|
1274
1071
|
auth: McpAuthContext,
|
|
1275
1072
|
check: Parameters<typeof isComponentAuthorizedFor>[1],
|
|
@@ -10,6 +10,8 @@ import { emitAgentStatus, emitNewMessage } from "../sse";
|
|
|
10
10
|
import { getAgentProfile, getSpawnPolicy } from "../config-store";
|
|
11
11
|
import { listManagedOrchestratorsForAgent } from "../orchestrator-lookup";
|
|
12
12
|
import { runnerRuntimeTokenEnv } from "../runtime-tokens";
|
|
13
|
+
import { authContextFromRequest } from "../services/auth-context";
|
|
14
|
+
import { ShutdownAuthError, ShutdownTargetError, shutdownAgent } from "../services/shutdown-agent";
|
|
13
15
|
import { type AgentCard, type SpawnApprovalMode } from "../types";
|
|
14
16
|
import { type ProviderEffort } from "agent-relay-sdk/provider-catalog";
|
|
15
17
|
|
|
@@ -207,7 +209,40 @@ export const postAgentAction: Handler = async (req, params) => {
|
|
|
207
209
|
if (!agent) return error("agent not found", 404);
|
|
208
210
|
if (!agentCanReceiveControlAction(agent, action)) return error(`agent does not support ${action}`, 400);
|
|
209
211
|
|
|
210
|
-
|
|
212
|
+
// Shutdown converges on the shutdownAgent service (epic #342, #347): one #221
|
|
213
|
+
// parent→child gate + canonical orchestrator resolution + payload, shared with the
|
|
214
|
+
// bus + MCP surfaces. The route still enforces the coarse `agent:write` scope +
|
|
215
|
+
// constraint-list; the service adds the fine parent→child gate.
|
|
216
|
+
if (action === "shutdown") {
|
|
217
|
+
const denied = authorizeRoute(req, {
|
|
218
|
+
scope: "agent:write",
|
|
219
|
+
resource: {
|
|
220
|
+
agentId: agent.id,
|
|
221
|
+
policyName: typeof agent.meta?.policyName === "string" ? agent.meta.policyName : undefined,
|
|
222
|
+
spawnRequestId: typeof agent.meta?.spawnRequestId === "string" ? agent.meta.spawnRequestId : undefined,
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
if (denied) return denied;
|
|
226
|
+
try {
|
|
227
|
+
const result = shutdownAgent(
|
|
228
|
+
{
|
|
229
|
+
agentId: agent.id,
|
|
230
|
+
requestedVia: "http",
|
|
231
|
+
requestedBy: "dashboard",
|
|
232
|
+
auditMetadata: { ...authAuditMetadata(req), ...dashboardAttribution(req, parsed.body.surface) },
|
|
233
|
+
},
|
|
234
|
+
authContextFromRequest(req),
|
|
235
|
+
);
|
|
236
|
+
return json({ ok: true, action, command: result.command }, 202);
|
|
237
|
+
} catch (e) {
|
|
238
|
+
if (e instanceof ShutdownAuthError) return error(e.message, 403);
|
|
239
|
+
if (e instanceof ShutdownTargetError) return error(e.message, 404);
|
|
240
|
+
throw e;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// "shutdown" is handled above by the shutdownAgent service and returns early.
|
|
245
|
+
const orchestrator = (action === "restart" || action === "resume") ? managedControlOrchestrator(agent) : null;
|
|
211
246
|
const metaSessionName = typeof agent.meta?.sessionName === "string" ? agent.meta.sessionName : undefined;
|
|
212
247
|
const metaTmuxSession = typeof agent.meta?.tmuxSession === "string" ? agent.meta.tmuxSession : undefined;
|
|
213
248
|
const metaPolicyName = typeof agent.meta?.policyName === "string" ? agent.meta.policyName : undefined;
|
|
@@ -240,8 +275,8 @@ export const postAgentAction: Handler = async (req, params) => {
|
|
|
240
275
|
requestedAt: Date.now(),
|
|
241
276
|
},
|
|
242
277
|
});
|
|
243
|
-
if (action === "
|
|
244
|
-
const lifecycleAction = action === "
|
|
278
|
+
if (action === "restart" || action === "resume") {
|
|
279
|
+
const lifecycleAction = action === "resume" ? "resuming" : "restarting";
|
|
245
280
|
markReady(agent.id, false);
|
|
246
281
|
mergeAgentMeta(agent.id, { lifecycleAction, lifecycleActionAt: Date.now(), lifecycleCommandId: command.id });
|
|
247
282
|
emitAgentStatus(agent.id);
|