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
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,26 @@ 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, WorkspaceMergeStrategy, WorkspaceMode, WorkspaceRecord } from "./types";
|
|
50
48
|
import { LAND_STRATEGIES, applyWorkspaceAction, waitForWorkspaceStatus, type WorkspaceAction } from "./workspace-actions";
|
|
51
49
|
import { describeWorkspacePhase, landReceipt, readyContract, worktreeMcpInstructions } from "./workspace-phase";
|
|
52
50
|
import { type ProviderEffort } from "agent-relay-sdk/provider-catalog";
|
|
53
51
|
import { errMessage, isRecord, stringValue, SPAWN_PROVIDERS, APPROVAL_MODES, VALID_EFFORTS, VALID_WORKSPACE_MODES } from "agent-relay-sdk";
|
|
54
|
-
import { runnerRuntimeTokenEnv } from "./runtime-tokens";
|
|
55
52
|
|
|
56
53
|
type JsonRpcId = string | number | null;
|
|
57
54
|
|
|
@@ -94,7 +91,7 @@ const VALID_ARTIFACT_ENTITY_TYPES = ["message", "task", "recipeRun", "recipeStep
|
|
|
94
91
|
const TOOLS: ToolDefinition[] = [
|
|
95
92
|
{
|
|
96
93
|
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.
|
|
94
|
+
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
95
|
requiredScopes: ["messages:write", "message:send"],
|
|
99
96
|
inputSchema: {
|
|
100
97
|
type: "object",
|
|
@@ -531,41 +528,18 @@ async function callTool(auth: McpAuthContext, params: unknown): Promise<Record<s
|
|
|
531
528
|
// (see issueMcpRuntimeToken). That single allowed agent IS the caller's identity, and the
|
|
532
529
|
// security layer already treats it as the only `from` this token may use. Derive it so
|
|
533
530
|
// 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).
|
|
531
|
+
// THE caller-identity resolver for MCP: the agent id behind this token, for `from`-autofill,
|
|
532
|
+
// relay_whoami, and spawn/shutdown gating (#221, #243). Delegates to the shared
|
|
533
|
+
// `resolveCallerAgentId` (one home, src/agent-ref.ts) so the bus/HTTP/MCP transports resolve
|
|
534
|
+
// caller identity identically — managed agents (runner token carrying spawnRequestId/policy,
|
|
535
|
+
// no `agents` constraint) resolve back to their registered card; server/admin/multi-agent
|
|
536
|
+
// tokens return undefined. Keep `relay_whoami` + the spawn/shutdown gates on THIS, not a
|
|
537
|
+
// narrower check, or managed agents silently lose implicit identity again (the #243 drift).
|
|
538
|
+
// (The send/reply `from`-autofill now flows through the shared send service's `ctx.callerAgentId`,
|
|
539
|
+
// populated by `authContextFromMcp` from this same resolver — one home across all transports.)
|
|
548
540
|
function callerAgentId(auth: McpAuthContext): string | undefined {
|
|
549
|
-
const direct = senderIdentity(auth);
|
|
550
|
-
if (direct) return direct;
|
|
551
541
|
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 });
|
|
542
|
+
return resolveCallerAgentId(auth.component?.constraints, listAgents);
|
|
569
543
|
}
|
|
570
544
|
|
|
571
545
|
function relayWhoami(auth: McpAuthContext): Record<string, unknown> {
|
|
@@ -580,20 +554,33 @@ function relayWhoami(auth: McpAuthContext): Record<string, unknown> {
|
|
|
580
554
|
};
|
|
581
555
|
}
|
|
582
556
|
|
|
557
|
+
// MCP transport adapter over the shared send service (epic #342). The service owns from-resolution
|
|
558
|
+
// (token identity via ctx.callerAgentId), reply routing, target resolution + the converged
|
|
559
|
+
// store-ahead policy, authorization, persistence, and emit. This adapter only builds the input,
|
|
560
|
+
// the AuthContext (carrying the integration token separately, per the lean-AuthContext contract),
|
|
561
|
+
// and maps the service's typed ServiceAuthError onto the MCP wire error (McpAuthError → 403).
|
|
562
|
+
function runMcpSend(auth: McpAuthContext, input: SendMessageInput): Message & { delivery: DeliveryReceipt } {
|
|
563
|
+
try {
|
|
564
|
+
const result = sendMessageService(
|
|
565
|
+
input,
|
|
566
|
+
authContextFromMcp({ kind: auth.kind, actor: auth.actor, scopes: auth.scopes, component: auth.component }),
|
|
567
|
+
{ integration: auth.integration },
|
|
568
|
+
);
|
|
569
|
+
return { ...result.message, delivery: result.receipt };
|
|
570
|
+
} catch (e) {
|
|
571
|
+
if (e instanceof ServiceAuthError) throw new McpAuthError(e.message);
|
|
572
|
+
throw e; // AmbiguousTargetError/ValidationError → invalid-params via the central JSON-RPC map.
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
583
576
|
function relaySendMessage(auth: McpAuthContext, args: Record<string, unknown>): Message & { delivery: DeliveryReceipt } {
|
|
584
577
|
const attachments = optionalAttachments(args.attachments);
|
|
585
578
|
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
579
|
const input: SendMessageInput = {
|
|
595
|
-
from
|
|
596
|
-
|
|
580
|
+
// `from` is resolved by the service from the token identity (callerAgentId wins); the wire
|
|
581
|
+
// value is only a fallback for identity-less tokens.
|
|
582
|
+
from: optionalString(args.from, "from", 200) ?? "",
|
|
583
|
+
to: stringField(args.to, "to", { required: true, max: 200 }),
|
|
597
584
|
body: stringField(args.body, "body", { required: true, maxBytes: MAX_BODY_BYTES }),
|
|
598
585
|
subject: optionalString(args.subject, "subject", 200),
|
|
599
586
|
channel: optionalString(args.channel, "channel", 120),
|
|
@@ -603,11 +590,7 @@ function relaySendMessage(auth: McpAuthContext, args: Record<string, unknown>):
|
|
|
603
590
|
payload,
|
|
604
591
|
meta: optionalRecord(args.meta, "meta"),
|
|
605
592
|
};
|
|
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 };
|
|
593
|
+
return runMcpSend(auth, input);
|
|
611
594
|
}
|
|
612
595
|
|
|
613
596
|
function relayReply(auth: McpAuthContext, args: Record<string, unknown>): Message & { delivery: DeliveryReceipt } {
|
|
@@ -620,30 +603,20 @@ function relayReply(auth: McpAuthContext, args: Record<string, unknown>): Messag
|
|
|
620
603
|
const replyPayload = payloadWithAttachments({
|
|
621
604
|
...payload,
|
|
622
605
|
...(format ? { message: { ...(isRecord(payload.message) ? payload.message : {}), format } } : {}),
|
|
623
|
-
...replyContext(parent),
|
|
624
606
|
}, attachments);
|
|
625
607
|
const input: SendMessageInput = {
|
|
626
|
-
from:
|
|
627
|
-
to
|
|
608
|
+
from: optionalString(args.from, "from", 200) ?? "",
|
|
609
|
+
// Empty `to` + replyTo lets the service auto-route to the parent's sender, inherit its
|
|
610
|
+
// channel, and propagate channel replyContext — identical to the HTTP reply path.
|
|
611
|
+
to: "",
|
|
628
612
|
body: stringField(args.body, "body", { required: true, maxBytes: MAX_BODY_BYTES }),
|
|
629
613
|
subject: optionalString(args.subject, "subject", 200),
|
|
630
|
-
channel: parent.channel,
|
|
631
614
|
replyTo: parent.id,
|
|
632
615
|
attachments,
|
|
633
616
|
payload: replyPayload,
|
|
634
617
|
meta: optionalRecord(args.meta, "meta"),
|
|
635
618
|
};
|
|
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 };
|
|
619
|
+
return runMcpSend(auth, input);
|
|
647
620
|
}
|
|
648
621
|
|
|
649
622
|
function relayGetMessage(args: Record<string, unknown>): Record<string, unknown> {
|
|
@@ -788,136 +761,34 @@ function relayFindAgents(auth: McpAuthContext, args: Record<string, unknown>): R
|
|
|
788
761
|
}
|
|
789
762
|
|
|
790
763
|
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
|
-
}
|
|
764
|
+
// Thin transport: parse wire → build AuthContext → call the spawnAgent service → serialize.
|
|
765
|
+
// ALL policy + side effects (caller-context inheritance, the #221 no-grandchild + quota gate,
|
|
766
|
+
// the #323 resource gate, the command:spawn scope assert, authoritative lineage stamping, the
|
|
767
|
+
// command payload + emit + audit) live in src/services/spawn-agent.ts so the MCP and HTTP spawn
|
|
768
|
+
// paths cannot drift (epic #342). `waitForRegistrationMs` defaults to a short wait here because
|
|
769
|
+
// isolated-worktree spawns register near-instantly (symlinked deps); 0 opts out.
|
|
770
|
+
const input: SpawnAgentInput = {
|
|
771
|
+
provider: enumField(args.provider, "provider", SPAWN_PROVIDERS) as SpawnProvider,
|
|
772
|
+
orchestratorId: optionalString(args.orchestratorId, "orchestratorId", 200),
|
|
773
|
+
cwd: optionalString(args.cwd, "cwd", 500),
|
|
774
|
+
model: optionalString(args.model, "model", 120),
|
|
775
|
+
effort: optionalEnum(args.effort, "effort", VALID_EFFORTS) as ProviderEffort | undefined,
|
|
776
|
+
approvalMode: optionalEnum(args.approvalMode, "approvalMode", APPROVAL_MODES) as SpawnApprovalMode | undefined,
|
|
777
|
+
workspaceMode: optionalEnum(args.workspaceMode, "workspaceMode", VALID_WORKSPACE_MODES) as WorkspaceMode | undefined,
|
|
778
|
+
label: optionalString(args.label, "label", 120),
|
|
779
|
+
policyName: optionalString(args.policyName, "policyName", 120),
|
|
780
|
+
profile: optionalString(args.profile, "profile", 120),
|
|
781
|
+
prompt: optionalString(args.prompt, "prompt", 16_000),
|
|
782
|
+
systemPromptAppend: optionalString(args.systemPromptAppend, "systemPromptAppend", 64_000),
|
|
783
|
+
tags: optionalStringArray(args.tags, "tags"),
|
|
784
|
+
capabilities: optionalStringArray(args.capabilities, "capabilities"),
|
|
785
|
+
providerArgs: optionalStringArray(args.providerArgs, "providerArgs"),
|
|
786
|
+
spawnRequestId: optionalString(args.spawnRequestId, "spawnRequestId", 160),
|
|
787
|
+
requestedVia: "mcp",
|
|
788
|
+
waitForRegistrationMs: optionalNonNegativeInt(args.waitForRegistrationMs, "waitForRegistrationMs") ?? 8000,
|
|
789
|
+
};
|
|
790
|
+
const result = await spawnAgent(input, authContextFromMcp(auth));
|
|
791
|
+
return { ...result };
|
|
921
792
|
}
|
|
922
793
|
|
|
923
794
|
function relayShutdownAgent(auth: McpAuthContext, args: Record<string, unknown>): Record<string, unknown> {
|
|
@@ -929,50 +800,32 @@ function relayShutdownAgent(auth: McpAuthContext, args: Record<string, unknown>)
|
|
|
929
800
|
throw new ValidationError("agentId, policyName, spawnRequestId, or tmuxSession required");
|
|
930
801
|
}
|
|
931
802
|
|
|
932
|
-
// #221
|
|
933
|
-
//
|
|
934
|
-
//
|
|
935
|
-
//
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
803
|
+
// The #221 parent→child gate, control-orchestrator resolution, and the canonical
|
|
804
|
+
// agent.shutdown payload + side-effects all live in the shutdownAgent service now —
|
|
805
|
+
// shared byte-for-byte with the HTTP route and the bus command frame (epic #342, #347).
|
|
806
|
+
// Map the service's transport-neutral errors back to MCP's JSON-RPC error codes.
|
|
807
|
+
try {
|
|
808
|
+
const result = shutdownAgent(
|
|
809
|
+
{
|
|
810
|
+
agentId,
|
|
811
|
+
policyName,
|
|
812
|
+
spawnRequestId,
|
|
813
|
+
tmuxSession,
|
|
814
|
+
orchestratorId: optionalString(args.orchestratorId, "orchestratorId", 200),
|
|
815
|
+
graceful: true,
|
|
816
|
+
timeoutMs: optionalPositiveInt(args.timeoutMs, "timeoutMs") ?? 10_000,
|
|
817
|
+
reason: optionalString(args.reason, "reason", 200) ?? "mcp-shutdown",
|
|
818
|
+
requestedVia: "mcp",
|
|
819
|
+
requestedBy: auth.actor,
|
|
820
|
+
},
|
|
821
|
+
authContextFromMcp(auth),
|
|
822
|
+
);
|
|
823
|
+
return { ok: true, action: "shutdown", orchestratorId: result.orchestratorId, command: result.command };
|
|
824
|
+
} catch (e) {
|
|
825
|
+
if (e instanceof ShutdownAuthError) throw new McpAuthError(e.message);
|
|
826
|
+
if (e instanceof ShutdownTargetError) throw new McpNotFoundError(e.message);
|
|
827
|
+
throw e;
|
|
945
828
|
}
|
|
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
829
|
}
|
|
977
830
|
|
|
978
831
|
// --- Workspace lifecycle tools (#215) -------------------------------------
|
|
@@ -1088,16 +941,6 @@ function relayWorkspaceMutation(auth: McpAuthContext, action: WorkspaceAction, a
|
|
|
1088
941
|
return payload;
|
|
1089
942
|
}
|
|
1090
943
|
|
|
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
944
|
|
|
1102
945
|
function payloadWithAttachments(
|
|
1103
946
|
payload: Record<string, unknown> | undefined,
|
|
@@ -1146,45 +989,6 @@ function relaySpawnTargets(auth: McpAuthContext): Record<string, unknown> {
|
|
|
1146
989
|
});
|
|
1147
990
|
}
|
|
1148
991
|
|
|
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
992
|
function artifactBytes(args: Record<string, unknown>): Uint8Array {
|
|
1189
993
|
const hasContent = args.content !== undefined && args.content !== null;
|
|
1190
994
|
const hasBase64 = args.base64 !== undefined && args.base64 !== null;
|
|
@@ -1232,12 +1036,6 @@ function sniffMediaType(bytes: Uint8Array, hinted?: string, filename?: string):
|
|
|
1232
1036
|
return hinted || "text/plain";
|
|
1233
1037
|
}
|
|
1234
1038
|
|
|
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
1039
|
function visibleTools(auth: McpAuthContext): Array<Record<string, unknown>> {
|
|
1242
1040
|
return TOOLS
|
|
1243
1041
|
.filter((tool) => hasAnyScope(auth, tool.requiredScopes))
|
|
@@ -1264,12 +1062,6 @@ function jsonRpcError(id: JsonRpcId, code: number, message: string, data?: unkno
|
|
|
1264
1062
|
return { jsonrpc: "2.0", id, error: { code, message, ...(data ? { data } : {}) } };
|
|
1265
1063
|
}
|
|
1266
1064
|
|
|
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
1065
|
function assertComponentResourceAllowed(
|
|
1274
1066
|
auth: McpAuthContext,
|
|
1275
1067
|
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);
|