agent-relay-server 0.42.0 → 0.43.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 +85 -1
- package/package.json +2 -2
- package/public/assets/{activity-G6l1nOLj.js → activity--LYUyJtT.js} +2 -2
- package/public/assets/{activity-G6l1nOLj.js.map → activity--LYUyJtT.js.map} +1 -1
- package/public/assets/{agents-DBuCRS5A.js → agents-6mhIWvF-.js} +2 -2
- package/public/assets/{agents-DBuCRS5A.js.map → agents-6mhIWvF-.js.map} +1 -1
- package/public/assets/{analytics-BhwMtd0_.js → analytics-W-oZ8bsY.js} +2 -2
- package/public/assets/{analytics-BhwMtd0_.js.map → analytics-W-oZ8bsY.js.map} +1 -1
- package/public/assets/{automation-B7jnsYhH.js → automation-DouRxEN7.js} +2 -2
- package/public/assets/{automation-B7jnsYhH.js.map → automation-DouRxEN7.js.map} +1 -1
- package/public/assets/chat-DVu6bXmE.js +2 -0
- package/public/assets/chat-DVu6bXmE.js.map +1 -0
- package/public/assets/display-ConJ9cJB.js.map +1 -1
- package/public/assets/{formatted-body-impl-BXuR2gfL.js → formatted-body-impl-CthzMcxz.js} +2 -2
- package/public/assets/{formatted-body-impl-BXuR2gfL.js.map → formatted-body-impl-CthzMcxz.js.map} +1 -1
- package/public/assets/{index-CAnJGSBl.js → index-B1s-hGRR.js} +5 -5
- package/public/assets/{index-CAnJGSBl.js.map → index-B1s-hGRR.js.map} +1 -1
- package/public/assets/index-Ryvn1efN.css +2 -0
- package/public/assets/{maintenance-B1bX9ety.js → maintenance-CdDtYHE9.js} +2 -2
- package/public/assets/{maintenance-B1bX9ety.js.map → maintenance-CdDtYHE9.js.map} +1 -1
- package/public/assets/{managed-agents-D12-nGKd.js → managed-agents-DXigb0pj.js} +2 -2
- package/public/assets/{managed-agents-D12-nGKd.js.map → managed-agents-DXigb0pj.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-BWd3swt8.js → markdown-preview-impl-BVES_oc1.js} +2 -2
- package/public/assets/{markdown-preview-impl-BWd3swt8.js.map → markdown-preview-impl-BVES_oc1.js.map} +1 -1
- package/public/assets/{memory-4ERBFJBZ.js → memory-DWAF794H.js} +2 -2
- package/public/assets/{memory-4ERBFJBZ.js.map → memory-DWAF794H.js.map} +1 -1
- package/public/assets/{messages-B3ZES72c.js → messages-D63r1IRE.js} +2 -2
- package/public/assets/{messages-B3ZES72c.js.map → messages-D63r1IRE.js.map} +1 -1
- package/public/assets/{orchestrators-qKPntM-a.js → orchestrators-rbEOs7pu.js} +2 -2
- package/public/assets/{orchestrators-qKPntM-a.js.map → orchestrators-rbEOs7pu.js.map} +1 -1
- package/public/assets/{overview-ekmeyPaO.js → overview-CeUZzyTa.js} +2 -2
- package/public/assets/{overview-ekmeyPaO.js.map → overview-CeUZzyTa.js.map} +1 -1
- package/public/assets/{pairs-B2QdTF0M.js → pairs-DJ_e1R5S.js} +2 -2
- package/public/assets/{pairs-B2QdTF0M.js.map → pairs-DJ_e1R5S.js.map} +1 -1
- package/public/assets/{security-WkMkEhb7.js → security-DyeypCyX.js} +2 -2
- package/public/assets/{security-WkMkEhb7.js.map → security-DyeypCyX.js.map} +1 -1
- package/public/assets/{settings-C5aXX3od.js → settings-CVW329oA.js} +2 -2
- package/public/assets/{settings-C5aXX3od.js.map → settings-CVW329oA.js.map} +1 -1
- package/public/assets/{tasks-ppuVeXkO.js → tasks-CR8BxsPc.js} +2 -2
- package/public/assets/{tasks-ppuVeXkO.js.map → tasks-CR8BxsPc.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-DXycIJKe.js → terminal-viewer-impl-CISUjIl9.js} +2 -2
- package/public/assets/{terminal-viewer-impl-DXycIJKe.js.map → terminal-viewer-impl-CISUjIl9.js.map} +1 -1
- package/public/assets/{work-queue-C8UzeekT.js → work-queue-ipT9bYqc.js} +2 -2
- package/public/assets/{work-queue-C8UzeekT.js.map → work-queue-ipT9bYqc.js.map} +1 -1
- package/public/index.html +2 -2
- package/src/agent-ref.ts +7 -4
- package/src/channel-target.ts +1 -0
- package/src/db/agent-search.ts +6 -0
- package/src/db/agents.ts +11 -2
- package/src/db/channels.ts +2 -1
- package/src/db/delivery.ts +4 -1
- package/src/db/index.ts +1 -0
- package/src/db/mappers.ts +1 -1
- package/src/db/message-reads.ts +10 -1
- package/src/db/migrations.ts +34 -1
- package/src/db/plans.ts +17 -2
- package/src/db/schema.ts +23 -2
- package/src/db/teams.ts +151 -0
- package/src/mcp-plan-tools.ts +1 -0
- package/src/mcp-team-tools.ts +87 -0
- package/src/mcp.ts +6 -1
- package/src/routes/index.ts +4 -0
- package/src/routes/integrations.ts +2 -1
- package/src/routes/messages.ts +1 -1
- package/src/routes/teams.ts +60 -0
- package/src/runtime-tokens.ts +9 -3
- package/src/security.ts +7 -1
- package/src/services/plan-graph.ts +2 -0
- package/src/services/register-agent.ts +3 -1
- package/src/services/spawn-agent.ts +4 -1
- package/src/services/team.ts +69 -0
- package/src/token-db.ts +3 -3
- package/src/validation.ts +2 -0
- package/public/assets/chat-KaK5_x65.js +0 -2
- package/public/assets/chat-KaK5_x65.js.map +0 -1
- package/public/assets/index-DzRKGPXS.css +0 -2
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { McpAuthError, McpNotFoundError } from "./mcp-errors";
|
|
2
|
+
import { authContextFromMcp } from "./services/auth-context";
|
|
3
|
+
import { ValidationError } from "./db";
|
|
4
|
+
import { dissolveCallerTeam, getCallerTeam, setCallerTeamBrief, TeamAuthError, TeamNotFoundError } from "./services/team";
|
|
5
|
+
import { cleanString, optionalEnum } from "./validation";
|
|
6
|
+
import type { Team } from "./types";
|
|
7
|
+
|
|
8
|
+
const OUTCOME_STATUSES = ["success", "partial", "failed"] as const;
|
|
9
|
+
|
|
10
|
+
export const TEAM_TOOLS = [
|
|
11
|
+
{
|
|
12
|
+
name: "relay_team_get",
|
|
13
|
+
description: "Return the caller's team row, live roster, shared brief/definition of done, and any bound plan.",
|
|
14
|
+
requiredScopes: ["teams:read"],
|
|
15
|
+
inputSchema: {
|
|
16
|
+
type: "object",
|
|
17
|
+
properties: { channel: { type: "string" } },
|
|
18
|
+
additionalProperties: false,
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
name: "relay_team_set_brief",
|
|
23
|
+
description: "Create/update the caller's team brief, title, and definition of done. Lazily mints a team when needed.",
|
|
24
|
+
requiredScopes: ["teams:write"],
|
|
25
|
+
inputSchema: {
|
|
26
|
+
type: "object",
|
|
27
|
+
properties: {
|
|
28
|
+
title: { type: "string" },
|
|
29
|
+
brief: { type: "string" },
|
|
30
|
+
definitionOfDone: { type: "string" },
|
|
31
|
+
},
|
|
32
|
+
additionalProperties: false,
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
name: "relay_team_dissolve",
|
|
37
|
+
description: "Dissolve the caller's team and freeze the composition/outcome snapshot. Does not kill members.",
|
|
38
|
+
requiredScopes: ["teams:write"],
|
|
39
|
+
inputSchema: {
|
|
40
|
+
type: "object",
|
|
41
|
+
properties: {
|
|
42
|
+
outcomeStatus: { type: "string", enum: OUTCOME_STATUSES },
|
|
43
|
+
outcomeSummary: { type: "string" },
|
|
44
|
+
landsClean: { type: "boolean" },
|
|
45
|
+
},
|
|
46
|
+
additionalProperties: false,
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
] satisfies Array<{ name: string; description: string; requiredScopes: string[]; inputSchema: Record<string, unknown> }>;
|
|
50
|
+
|
|
51
|
+
type McpAuthLike = Parameters<typeof authContextFromMcp>[0];
|
|
52
|
+
|
|
53
|
+
function mapTeamError(error: unknown): never {
|
|
54
|
+
if (error instanceof TeamAuthError) throw new McpAuthError(error.message);
|
|
55
|
+
if (error instanceof TeamNotFoundError) throw new McpNotFoundError(error.message);
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function relayTeamTool(auth: McpAuthLike, name: string, args: Record<string, unknown>): Record<string, unknown> {
|
|
60
|
+
const ctx = authContextFromMcp(auth);
|
|
61
|
+
try {
|
|
62
|
+
if (name === "relay_team_get") {
|
|
63
|
+
return getCallerTeam(ctx, cleanString(args.channel, "channel", { max: 120 })) as unknown as Record<string, unknown>;
|
|
64
|
+
}
|
|
65
|
+
if (name === "relay_team_set_brief") {
|
|
66
|
+
return setCallerTeamBrief(ctx, {
|
|
67
|
+
title: cleanString(args.title, "title", { max: 240 }),
|
|
68
|
+
brief: cleanString(args.brief, "brief", { max: 16_000 }),
|
|
69
|
+
definitionOfDone: cleanString(args.definitionOfDone, "definitionOfDone", { max: 4000 }),
|
|
70
|
+
}) as unknown as Record<string, unknown>;
|
|
71
|
+
}
|
|
72
|
+
if (name === "relay_team_dissolve") {
|
|
73
|
+
return dissolveCallerTeam(ctx, {
|
|
74
|
+
outcomeStatus: optionalEnum(args.outcomeStatus, "outcomeStatus", OUTCOME_STATUSES) as Team["outcomeStatus"] | undefined,
|
|
75
|
+
outcomeSummary: cleanString(args.outcomeSummary, "outcomeSummary", { max: 4000 }),
|
|
76
|
+
landsClean: args.landsClean === undefined || args.landsClean === null
|
|
77
|
+
? undefined
|
|
78
|
+
: typeof args.landsClean === "boolean"
|
|
79
|
+
? args.landsClean
|
|
80
|
+
: (() => { throw new ValidationError("landsClean must be a boolean"); })(),
|
|
81
|
+
}) as unknown as Record<string, unknown>;
|
|
82
|
+
}
|
|
83
|
+
} catch (error) {
|
|
84
|
+
mapTeamError(error);
|
|
85
|
+
}
|
|
86
|
+
throw new ValidationError(`unknown tool: ${name}`);
|
|
87
|
+
}
|
package/src/mcp.ts
CHANGED
|
@@ -47,6 +47,7 @@ import {
|
|
|
47
47
|
import { sendMessageService } from "./services/send-message";
|
|
48
48
|
import { ServiceAuthError } from "./services/errors";
|
|
49
49
|
import { PLAN_TOOLS, relayPlanTool } from "./mcp-plan-tools";
|
|
50
|
+
import { TEAM_TOOLS, relayTeamTool } from "./mcp-team-tools";
|
|
50
51
|
import type { ActivityKind, ArtifactKind, ArtifactSensitivity, AttachmentRef, Command, SendMessageInput, Message, SpawnApprovalMode, SpawnProvider, WorkspaceAutoMergePolicy, WorkspaceMergeStrategy, WorkspaceMode, WorkspaceRecord } from "./types";
|
|
51
52
|
import { LAND_STRATEGIES, applyWorkspaceAction, waitForWorkspaceStatus, type WorkspaceAction } from "./workspace-actions";
|
|
52
53
|
import { AUTO_MERGE_POLICIES } from "./workspace-merge";
|
|
@@ -101,7 +102,7 @@ const TOOLS: ToolDefinition[] = [
|
|
|
101
102
|
type: "object",
|
|
102
103
|
properties: {
|
|
103
104
|
from: { type: "string", description: "Deprecated/optional. Your identity is taken from your auth token; ignored for managed agents. You never need to set this." },
|
|
104
|
-
to: { type: "string", description: "Target: an agent id, label, name, or a unique id segment (e.g. the number part) — resolved automatically. Or a fan-out selector: tag:, cap:, label:, policy:, or broadcast." },
|
|
105
|
+
to: { type: "string", description: "Target: an agent id, label, name, or a unique id segment (e.g. the number part) — resolved automatically. Or a fan-out selector: tag:, cap:, label:, team:, policy:, or broadcast." },
|
|
105
106
|
body: { type: "string" },
|
|
106
107
|
subject: { type: "string" },
|
|
107
108
|
channel: { type: "string" },
|
|
@@ -232,6 +233,7 @@ const TOOLS: ToolDefinition[] = [
|
|
|
232
233
|
kind: { type: ["string", "array"], items: { type: "string" } },
|
|
233
234
|
status: { type: ["string", "array"], items: { type: "string" }, description: "online/idle/busy/offline/stale, the 'running' shorthand, or 'all'. Default 'running'." },
|
|
234
235
|
spawnedBy: { type: "string", description: "Parent agent id, or 'me' for your own spawned children (#221)." },
|
|
236
|
+
teamId: { type: "string", description: "Durable team id for roster filtering." },
|
|
235
237
|
sortBy: { type: "string", enum: ["lastActive", "created", "label"] },
|
|
236
238
|
order: { type: "string", enum: ["asc", "desc"] },
|
|
237
239
|
limit: { type: "integer", minimum: 1, maximum: 200 },
|
|
@@ -456,6 +458,7 @@ const TOOLS: ToolDefinition[] = [
|
|
|
456
458
|
additionalProperties: false,
|
|
457
459
|
},
|
|
458
460
|
},
|
|
461
|
+
...TEAM_TOOLS,
|
|
459
462
|
...PLAN_TOOLS,
|
|
460
463
|
];
|
|
461
464
|
|
|
@@ -568,6 +571,7 @@ async function callTool(auth: McpAuthContext, params: unknown): Promise<Record<s
|
|
|
568
571
|
else if (name === "relay_workspace_claim") result = relayWorkspaceMutation(auth, "claim", args);
|
|
569
572
|
else if (name === "relay_workspace_release") result = relayWorkspaceMutation(auth, "release-claim", args);
|
|
570
573
|
else if (name === "relay_workspace_land") result = relayWorkspaceMutation(auth, "merge", args);
|
|
574
|
+
else if (name.startsWith("relay_team_")) result = relayTeamTool(auth, name, args);
|
|
571
575
|
else if (name.startsWith("relay_plan_")) result = relayPlanTool(auth, name, args);
|
|
572
576
|
else throw new ValidationError(`unknown tool: ${name}`);
|
|
573
577
|
|
|
@@ -831,6 +835,7 @@ function relayFindAgents(auth: McpAuthContext, args: Record<string, unknown>): R
|
|
|
831
835
|
machine: optionalStringOrArray(args.machine, "machine"),
|
|
832
836
|
kind: optionalStringOrArray(args.kind, "kind"),
|
|
833
837
|
spawnedBy,
|
|
838
|
+
teamId: optionalString(args.teamId, "teamId", 120),
|
|
834
839
|
// Default to running — you can only act on a live agent (consistent with #218).
|
|
835
840
|
status: optionalStringOrArray(args.status, "status") ?? "running",
|
|
836
841
|
};
|
package/src/routes/index.ts
CHANGED
|
@@ -30,6 +30,7 @@ import { getPlanByIdRoute, getPlansRoute } from "./plans";
|
|
|
30
30
|
import { getRecipeByName, getRecipeInstanceArtifacts, getRecipeInstanceById, getRecipeInstances, getRecipes, postRecipeInstanceArtifacts, postRecipeStart, postRecipeStop } from "./recipes";
|
|
31
31
|
import { getStewardConfigRoute, getWorkspaceConfigRoute, putStewardConfigRoute, putWorkspaceConfigRoute } from "./steward";
|
|
32
32
|
import { getTaskById, getTaskEvents, getTasks, patchTaskStatus, postClaimTask, postRenewTaskClaim } from "./tasks";
|
|
33
|
+
import { getMyTeamRoute, postMyTeamDissolveRoute, putMyTeamBriefRoute } from "./teams";
|
|
33
34
|
import { postMcp } from "../mcp";
|
|
34
35
|
import { postOrchestratorBootstrap, postOrchestratorBootstrapExchange } from "./orchestrator-bootstrap";
|
|
35
36
|
|
|
@@ -135,6 +136,9 @@ const routes: Route[] = [
|
|
|
135
136
|
route("DELETE", "/api/workspaces/:id", deleteWorkspaceById),
|
|
136
137
|
|
|
137
138
|
route("POST", "/api/system/broadcast", postSystemBroadcast),
|
|
139
|
+
route("GET", "/api/teams/me", getMyTeamRoute),
|
|
140
|
+
route("PUT", "/api/teams/me/brief", putMyTeamBriefRoute),
|
|
141
|
+
route("POST", "/api/teams/me/dissolve", postMyTeamDissolveRoute),
|
|
138
142
|
route("GET", "/api/recipes", getRecipes),
|
|
139
143
|
route("POST", "/api/recipes/start", postRecipeStart),
|
|
140
144
|
route("GET", "/api/recipes/instances", getRecipeInstances),
|
|
@@ -9,7 +9,7 @@ import { isRecord } from "agent-relay-sdk";
|
|
|
9
9
|
import { parseChannelRouteTarget } from "../channel-target.ts";
|
|
10
10
|
import { type ChannelBinding, type ChannelBindingMode, type ChannelRouteTarget, type ChannelSummary, type IntegrationEventInput, type IntegrationSummary } from "../types";
|
|
11
11
|
|
|
12
|
-
const VALID_CHANNEL_BINDING_TARGET_TYPES = ["agent", "label", "tag", "capability", "broadcast", "orchestrator", "pool", "policy"] as const;
|
|
12
|
+
const VALID_CHANNEL_BINDING_TARGET_TYPES = ["agent", "label", "team", "tag", "capability", "broadcast", "orchestrator", "pool", "policy"] as const;
|
|
13
13
|
|
|
14
14
|
const VALID_CHANNEL_BINDING_MODES = ["exclusive", "broadcast"] as const;
|
|
15
15
|
|
|
@@ -59,6 +59,7 @@ function messageTargetForChannelTarget(target: ChannelRouteTarget, binding?: Cha
|
|
|
59
59
|
return binding.poolAgentId;
|
|
60
60
|
}
|
|
61
61
|
if (target.type === "label") return `label:${target.id}`;
|
|
62
|
+
if (target.type === "team") return `team:${target.id}`;
|
|
62
63
|
if (target.type === "tag") return `tag:${target.id}`;
|
|
63
64
|
if (target.type === "capability") return `cap:${target.id}`;
|
|
64
65
|
if (target.type === "broadcast") return "broadcast";
|
package/src/routes/messages.ts
CHANGED
|
@@ -66,7 +66,7 @@ function normalizeMessageInput(body: unknown): SendMessageInput {
|
|
|
66
66
|
return input;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
const FANOUT_PREFIXES = ["tag:", "cap:", "label:"];
|
|
69
|
+
const FANOUT_PREFIXES = ["tag:", "cap:", "label:", "team:"];
|
|
70
70
|
|
|
71
71
|
function isDirectTarget(to: string): boolean {
|
|
72
72
|
return to !== "broadcast" && !FANOUT_PREFIXES.some((p) => to.startsWith(p));
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { McpAuthError, McpNotFoundError } from "../mcp-errors";
|
|
2
|
+
import { authContextFromRequest } from "../services/auth-context";
|
|
3
|
+
import { ValidationError } from "../db";
|
|
4
|
+
import { dissolveCallerTeam, getCallerTeam, setCallerTeamBrief, TeamAuthError, TeamNotFoundError } from "../services/team";
|
|
5
|
+
import { cleanString, optionalEnum } from "../validation";
|
|
6
|
+
import { error, json, parseBody, type Handler } from "./_shared";
|
|
7
|
+
import type { Team } from "../types";
|
|
8
|
+
|
|
9
|
+
const OUTCOME_STATUSES = ["success", "partial", "failed"] as const;
|
|
10
|
+
|
|
11
|
+
function routeError(err: unknown): Response {
|
|
12
|
+
if (err instanceof TeamAuthError || err instanceof McpAuthError) return error(err.message, 403);
|
|
13
|
+
if (err instanceof TeamNotFoundError || err instanceof McpNotFoundError) return error(err.message, 404);
|
|
14
|
+
if (err instanceof Error) return error(err.message);
|
|
15
|
+
return error("team route failed");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const getMyTeamRoute: Handler = (req) => {
|
|
19
|
+
try {
|
|
20
|
+
const channel = cleanString(new URL(req.url).searchParams.get("channel") ?? undefined, "channel", { max: 120 });
|
|
21
|
+
return json(getCallerTeam(authContextFromRequest(req), channel));
|
|
22
|
+
} catch (err) {
|
|
23
|
+
return routeError(err);
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const putMyTeamBriefRoute: Handler = async (req) => {
|
|
28
|
+
const parsed = await parseBody<Record<string, unknown>>(req);
|
|
29
|
+
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
30
|
+
try {
|
|
31
|
+
const body = parsed.body ?? {};
|
|
32
|
+
return json(setCallerTeamBrief(authContextFromRequest(req), {
|
|
33
|
+
title: cleanString(body.title, "title", { max: 240 }),
|
|
34
|
+
brief: cleanString(body.brief, "brief", { max: 16_000 }),
|
|
35
|
+
definitionOfDone: cleanString(body.definitionOfDone, "definitionOfDone", { max: 4000 }),
|
|
36
|
+
}));
|
|
37
|
+
} catch (err) {
|
|
38
|
+
return routeError(err);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const postMyTeamDissolveRoute: Handler = async (req) => {
|
|
43
|
+
const parsed = await parseBody<Record<string, unknown>>(req);
|
|
44
|
+
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
45
|
+
try {
|
|
46
|
+
const body = parsed.body ?? {};
|
|
47
|
+
const landsClean = body.landsClean;
|
|
48
|
+
return json(dissolveCallerTeam(authContextFromRequest(req), {
|
|
49
|
+
outcomeStatus: optionalEnum(body.outcomeStatus, "outcomeStatus", OUTCOME_STATUSES) as Team["outcomeStatus"] | undefined,
|
|
50
|
+
outcomeSummary: cleanString(body.outcomeSummary, "outcomeSummary", { max: 4000 }),
|
|
51
|
+
landsClean: landsClean === undefined || landsClean === null
|
|
52
|
+
? undefined
|
|
53
|
+
: typeof landsClean === "boolean"
|
|
54
|
+
? landsClean
|
|
55
|
+
: (() => { throw new ValidationError("landsClean must be a boolean"); })(),
|
|
56
|
+
}));
|
|
57
|
+
} catch (err) {
|
|
58
|
+
return routeError(err);
|
|
59
|
+
}
|
|
60
|
+
};
|
package/src/runtime-tokens.ts
CHANGED
|
@@ -10,9 +10,10 @@ import type { TokenRecord } from "./types";
|
|
|
10
10
|
// purpose — that scope must stay for heartbeat/status without implying spawn rights.
|
|
11
11
|
const SPAWN_SCOPES = ["command:spawn", "command:shutdown"] as const;
|
|
12
12
|
|
|
13
|
-
function runnerScopeWithSpawn(canSpawn: boolean): string[] | undefined {
|
|
14
|
-
if (!canSpawn) return undefined; // undefined → createToken falls back to the profile's scope
|
|
13
|
+
function runnerScopeWithSpawn(canSpawn: boolean, agentInitiated?: boolean): string[] | undefined {
|
|
15
14
|
const base = getTokenProfile("provider-agent")?.scope ?? [];
|
|
15
|
+
if (agentInitiated) return base.filter((scope) => scope !== "teams:write");
|
|
16
|
+
if (!canSpawn) return undefined; // undefined → createToken falls back to the profile's scope
|
|
16
17
|
return [...base, ...SPAWN_SCOPES];
|
|
17
18
|
}
|
|
18
19
|
|
|
@@ -101,6 +102,8 @@ export function issueRunnerRuntimeToken(input: {
|
|
|
101
102
|
agentInitiated?: boolean;
|
|
102
103
|
/** Parent agent id — stamped so the child registers with an authoritative `spawnedBy`. */
|
|
103
104
|
spawnedBy?: string;
|
|
105
|
+
/** Durable team id — stamped so the child registers into the coordinator's team. */
|
|
106
|
+
teamId?: string;
|
|
104
107
|
}): RuntimeTokenResult {
|
|
105
108
|
const grant = spawnGrantForProfile(input.profile, input.agentInitiated);
|
|
106
109
|
const subject = input.policyName
|
|
@@ -112,7 +115,7 @@ export function issueRunnerRuntimeToken(input: {
|
|
|
112
115
|
profileId: "provider-agent",
|
|
113
116
|
sub: subject,
|
|
114
117
|
role: "provider",
|
|
115
|
-
scope: runnerScopeWithSpawn(grant.canSpawn),
|
|
118
|
+
scope: runnerScopeWithSpawn(grant.canSpawn, input.agentInitiated),
|
|
116
119
|
constraints: {
|
|
117
120
|
orchestrators: [input.orchestratorId],
|
|
118
121
|
cwdPrefixes: [input.cwd],
|
|
@@ -120,6 +123,7 @@ export function issueRunnerRuntimeToken(input: {
|
|
|
120
123
|
...(input.spawnRequestId ? { spawnRequestIds: [input.spawnRequestId] } : {}),
|
|
121
124
|
...(grant.canSpawn && grant.maxSpawnedAgents ? { maxSpawnedAgents: grant.maxSpawnedAgents } : {}),
|
|
122
125
|
...(input.spawnedBy ? { spawnedBy: input.spawnedBy } : {}),
|
|
126
|
+
...(input.teamId ? { teamId: input.teamId } : {}),
|
|
123
127
|
},
|
|
124
128
|
createdBy: input.createdBy ?? "runtime",
|
|
125
129
|
});
|
|
@@ -133,6 +137,7 @@ function issueChildRunnerRuntimeToken(input: {
|
|
|
133
137
|
label?: string;
|
|
134
138
|
policyName?: string;
|
|
135
139
|
spawnRequestId: string;
|
|
140
|
+
teamId?: string;
|
|
136
141
|
createdBy?: string;
|
|
137
142
|
}): RuntimeTokenResult {
|
|
138
143
|
return createToken({
|
|
@@ -146,6 +151,7 @@ function issueChildRunnerRuntimeToken(input: {
|
|
|
146
151
|
cwdPrefixes: [input.cwd],
|
|
147
152
|
spawnRequestIds: [input.spawnRequestId],
|
|
148
153
|
canDelegate: false,
|
|
154
|
+
...(input.teamId ? { teamId: input.teamId } : {}),
|
|
149
155
|
...(input.policyName ? { policies: [input.policyName] } : {}),
|
|
150
156
|
},
|
|
151
157
|
createdBy: input.createdBy ?? input.parentAgentId,
|
package/src/security.ts
CHANGED
|
@@ -204,6 +204,7 @@ export function requiredScopeFor(method: string, pathname: string): string | nul
|
|
|
204
204
|
if (pathname.startsWith("/api/maintenance")) return "system:admin";
|
|
205
205
|
if (pathname.startsWith("/api/tasks")) return method === "GET" ? "task:read" : "task:write";
|
|
206
206
|
if (pathname.startsWith("/api/pairs")) return method === "GET" ? "pairs:read" : "pairs:write";
|
|
207
|
+
if (pathname.startsWith("/api/teams")) return method === "GET" ? "teams:read" : "teams:write";
|
|
207
208
|
// Insights config (the feature toggle) stays admin-only via the default; only the
|
|
208
209
|
// mechanical observation feed is writable by lower-privilege callers.
|
|
209
210
|
if (pathname === "/api/insights/observations") return method === "GET" ? "insights:read" : "insights:write";
|
|
@@ -278,6 +279,7 @@ export function requiredComponentScopeFor(method: string, pathname: string): str
|
|
|
278
279
|
if (pathname.startsWith("/api/tasks")) return method === "GET" ? "task:read" : "task:write";
|
|
279
280
|
if (pathname.startsWith("/api/orchestrators")) return method === "GET" ? "agent:read" : "command:write";
|
|
280
281
|
if (pathname.startsWith("/api/plans")) return method === "GET" ? "plans:read" : "plans:write";
|
|
282
|
+
if (pathname.startsWith("/api/teams")) return method === "GET" ? "teams:read" : "teams:write";
|
|
281
283
|
// The Runner posts the #184 context-gathering signal here (source:"server") via its
|
|
282
284
|
// provider token. Without this case the path fell through to the system:admin default
|
|
283
285
|
// below and every observation was 403-dropped — the whole Insights feed stayed empty.
|
|
@@ -415,6 +417,10 @@ export function resolveSpawnLineage(constraints: TokenConstraints | undefined):
|
|
|
415
417
|
return constraints?.spawnedBy ?? constraints?.parentAgents?.[0];
|
|
416
418
|
}
|
|
417
419
|
|
|
420
|
+
export function resolveTeamLineage(constraints: TokenConstraints | undefined): string | undefined {
|
|
421
|
+
return constraints?.teamId;
|
|
422
|
+
}
|
|
423
|
+
|
|
418
424
|
function constraintsAllow(constraints: TokenConstraints | undefined, resource: AuthorizationResource | undefined): boolean {
|
|
419
425
|
if (!constraints || !resource) return true;
|
|
420
426
|
const hasConstraints = Object.keys(constraints).length > 0;
|
|
@@ -462,7 +468,7 @@ function isTokenConstraints(value: unknown): value is TokenConstraints {
|
|
|
462
468
|
for (const [key, item] of Object.entries(record)) {
|
|
463
469
|
if (["terminalAttach", "logsRead", "canDelegate"].includes(key)) {
|
|
464
470
|
if (typeof item !== "boolean") return false;
|
|
465
|
-
} else if (key === "cwd" || key === "spawnedBy") {
|
|
471
|
+
} else if (key === "cwd" || key === "spawnedBy" || key === "teamId") {
|
|
466
472
|
if (typeof item !== "string") return false;
|
|
467
473
|
} else if (key === "maxSpawnedAgents") {
|
|
468
474
|
if (typeof item !== "number") return false;
|
|
@@ -30,6 +30,7 @@ export interface PlanNodePatch {
|
|
|
30
30
|
|
|
31
31
|
export interface PlanCreateInput {
|
|
32
32
|
threadId: unknown;
|
|
33
|
+
teamId?: unknown;
|
|
33
34
|
channel?: unknown;
|
|
34
35
|
title?: unknown;
|
|
35
36
|
objective?: unknown;
|
|
@@ -186,6 +187,7 @@ export function createPlan(input: PlanCreateInput, ctx: AuthContext): Plan {
|
|
|
186
187
|
const actor = actorId(ctx);
|
|
187
188
|
const plan = createPlanRow({
|
|
188
189
|
threadId,
|
|
190
|
+
teamId: cleanString(input.teamId, "teamId", { max: 120 }),
|
|
189
191
|
channel,
|
|
190
192
|
title: cleanString(input.title, "title", { max: 240 }),
|
|
191
193
|
objective: cleanString(input.objective, "objective", { max: 4000 }),
|
|
@@ -13,7 +13,7 @@ import { createActivityEvent, getAgent, upsertAgent } from "../db";
|
|
|
13
13
|
import { emitAgentStatus } from "../sse";
|
|
14
14
|
import { noteAgentTimelineEvent } from "../compaction-watch";
|
|
15
15
|
import { notifyAgentReady } from "../agent-lifecycle-events";
|
|
16
|
-
import { resolveSpawnLineage } from "../security";
|
|
16
|
+
import { resolveSpawnLineage, resolveTeamLineage } from "../security";
|
|
17
17
|
import { markManagedAgentRunning } from "./managed-running";
|
|
18
18
|
import type { AuthContext } from "./auth-context";
|
|
19
19
|
import type { AgentCard, RegisterAgentInput } from "../types";
|
|
@@ -28,6 +28,8 @@ export function registerAgent(input: RegisterAgentInput, ctx: AuthContext): Agen
|
|
|
28
28
|
// the client-sent body (a child can't forge its own parent). Set by relay at spawn.
|
|
29
29
|
const lineage = resolveSpawnLineage(ctx.constraints);
|
|
30
30
|
if (lineage) input.spawnedBy = lineage;
|
|
31
|
+
const teamLineage = resolveTeamLineage(ctx.constraints);
|
|
32
|
+
if (teamLineage) input.teamId = teamLineage;
|
|
31
33
|
|
|
32
34
|
const existing = getAgent(input.id);
|
|
33
35
|
const agent = upsertAgent(input);
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
// deliberately out of scope here — this slice converges the two external TRANSPORTS.
|
|
26
26
|
import type { SpawnProvider } from "agent-relay-sdk";
|
|
27
27
|
import { stringValue } from "agent-relay-sdk";
|
|
28
|
-
import { countLiveSpawnedAgents, createActivityEvent, getAgent, listAgents, ValidationError } from "../db";
|
|
28
|
+
import { countLiveSpawnedAgents, createActivityEvent, ensureTeam, getAgent, listAgents, setAgentTeam, ValidationError } from "../db";
|
|
29
29
|
import { createCommand } from "../commands-db";
|
|
30
30
|
import { emitCommandEvent } from "../command-events";
|
|
31
31
|
import { buildSpawnCommand, generateSpawnRequestId, resolveSpawnModelParams } from "../spawn-command";
|
|
@@ -178,6 +178,8 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
|
|
|
178
178
|
// Provenance actor: the resolved caller agent when known, else the transport's label, else
|
|
179
179
|
// the raw token actor. Recorded in the command payload, the token mint, and the audit row.
|
|
180
180
|
const requestedBy = callerId ?? input.requestedBy ?? ctx.actor.id;
|
|
181
|
+
const callerTeam = callerId ? (caller?.teamId ? { id: caller.teamId } : ensureTeam(callerId)) : undefined;
|
|
182
|
+
if (callerId && callerTeam && !caller?.teamId) setAgentTeam(callerId, callerTeam.id);
|
|
181
183
|
|
|
182
184
|
// Child runner token. `agentInitiated` (set whenever an agent is the caller) forces a
|
|
183
185
|
// non-spawn-capable child (no grandchildren); `spawnedBy` stamps authoritative lineage the
|
|
@@ -197,6 +199,7 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
|
|
|
197
199
|
// child regardless of profile (no grandchildren).
|
|
198
200
|
profile: input.profile || undefined,
|
|
199
201
|
...(callerId ? { agentInitiated: true, spawnedBy: callerId } : {}),
|
|
202
|
+
...(callerTeam ? { teamId: callerTeam.id } : {}),
|
|
200
203
|
});
|
|
201
204
|
|
|
202
205
|
const command = createCommand({
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { dissolveTeam, ensureTeam, getAgent, getPlanByTeam, getTeam, getTeamRoster, setTeamBrief } from "../db";
|
|
2
|
+
import type { AuthContext } from "./auth-context";
|
|
3
|
+
import type { Team } from "../types";
|
|
4
|
+
|
|
5
|
+
export class TeamAuthError extends Error {}
|
|
6
|
+
export class TeamNotFoundError extends Error {}
|
|
7
|
+
|
|
8
|
+
export interface TeamPayload {
|
|
9
|
+
team: Team;
|
|
10
|
+
roster: ReturnType<typeof getTeamRoster>;
|
|
11
|
+
brief: string | null;
|
|
12
|
+
definitionOfDone: string | null;
|
|
13
|
+
plan: ReturnType<typeof getPlanByTeam>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface TeamBriefInput {
|
|
17
|
+
title?: string;
|
|
18
|
+
brief?: string;
|
|
19
|
+
definitionOfDone?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface TeamDissolveServiceInput {
|
|
23
|
+
outcomeStatus?: Team["outcomeStatus"];
|
|
24
|
+
outcomeSummary?: string;
|
|
25
|
+
landsClean?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function requireCallerAgentId(ctx: AuthContext): string {
|
|
29
|
+
if (!ctx.callerAgentId) throw new TeamAuthError("team operations require a single caller agent identity");
|
|
30
|
+
return ctx.callerAgentId;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function payloadForAgent(agentId: string, channel?: string): TeamPayload {
|
|
34
|
+
const agent = getAgent(agentId);
|
|
35
|
+
const team = agent?.teamId ? getTeam(agent.teamId) : null;
|
|
36
|
+
if (!team) throw new TeamNotFoundError("caller has no team");
|
|
37
|
+
return {
|
|
38
|
+
team,
|
|
39
|
+
roster: getTeamRoster(team.id),
|
|
40
|
+
brief: team.brief ?? null,
|
|
41
|
+
definitionOfDone: team.definitionOfDone ?? null,
|
|
42
|
+
plan: getPlanByTeam(team.id, channel) ?? null,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function getCallerTeam(ctx: AuthContext, channel?: string): TeamPayload {
|
|
47
|
+
return payloadForAgent(requireCallerAgentId(ctx), channel);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function setCallerTeamBrief(ctx: AuthContext, input: TeamBriefInput): TeamPayload {
|
|
51
|
+
const agentId = requireCallerAgentId(ctx);
|
|
52
|
+
const team = ensureTeam(agentId);
|
|
53
|
+
setTeamBrief({ teamId: team.id, ...input });
|
|
54
|
+
return payloadForAgent(agentId);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function dissolveCallerTeam(ctx: AuthContext, input: TeamDissolveServiceInput): TeamPayload {
|
|
58
|
+
const agentId = requireCallerAgentId(ctx);
|
|
59
|
+
const agent = getAgent(agentId);
|
|
60
|
+
if (!agent?.teamId) throw new TeamNotFoundError("caller has no team");
|
|
61
|
+
const team = dissolveTeam(agent.teamId, input);
|
|
62
|
+
return {
|
|
63
|
+
team,
|
|
64
|
+
roster: getTeamRoster(team.id),
|
|
65
|
+
brief: team.brief ?? null,
|
|
66
|
+
definitionOfDone: team.definitionOfDone ?? null,
|
|
67
|
+
plan: getPlanByTeam(team.id) ?? null,
|
|
68
|
+
};
|
|
69
|
+
}
|
package/src/token-db.ts
CHANGED
|
@@ -57,7 +57,7 @@ const BUILT_IN_PROFILES: Array<Omit<TokenProfile, "createdAt" | "updatedAt">> =
|
|
|
57
57
|
name: "Provider Agent",
|
|
58
58
|
description: "Coding-agent runtime access for messages, commands, tasks, and scoped memory reads.",
|
|
59
59
|
role: "provider",
|
|
60
|
-
scope: ["agent:read", "agent:write", "message:read", "message:send", "command:read", "command:write", "task:read", "task:write", "memory:read", "artifact:read", "artifact:write", "mcp:use", "plans:read", "plans:write", "insights:write"],
|
|
60
|
+
scope: ["agent:read", "agent:write", "message:read", "message:send", "command:read", "command:write", "task:read", "task:write", "memory:read", "artifact:read", "artifact:write", "mcp:use", "plans:read", "plans:write", "teams:read", "teams:write", "insights:write"],
|
|
61
61
|
ttlSeconds: 24 * 60 * 60,
|
|
62
62
|
builtIn: true,
|
|
63
63
|
createdBy: "system",
|
|
@@ -67,7 +67,7 @@ const BUILT_IN_PROFILES: Array<Omit<TokenProfile, "createdAt" | "updatedAt">> =
|
|
|
67
67
|
name: "Provider Child Agent",
|
|
68
68
|
description: "Delegated child-agent runtime access, constrained to its parent and spawn request.",
|
|
69
69
|
role: "provider",
|
|
70
|
-
scope: ["agent:read", "agent:write", "message:read", "message:send", "command:read", "command:write", "task:read", "task:write", "memory:read", "artifact:read", "artifact:write", "mcp:use", "insights:write"],
|
|
70
|
+
scope: ["agent:read", "agent:write", "message:read", "message:send", "command:read", "command:write", "task:read", "task:write", "memory:read", "artifact:read", "artifact:write", "mcp:use", "teams:read", "insights:write"],
|
|
71
71
|
constraints: { canDelegate: false },
|
|
72
72
|
ttlSeconds: 2 * 60 * 60,
|
|
73
73
|
builtIn: true,
|
|
@@ -78,7 +78,7 @@ const BUILT_IN_PROFILES: Array<Omit<TokenProfile, "createdAt" | "updatedAt">> =
|
|
|
78
78
|
name: "Provider Interactive Agent",
|
|
79
79
|
description: "User-launched provider runtime access constrained to its own agent and cwd for long interactive sessions.",
|
|
80
80
|
role: "provider",
|
|
81
|
-
scope: ["agent:read", "agent:write", "message:read", "message:send", "command:read", "command:write", "task:read", "task:write", "memory:read", "artifact:read", "artifact:write", "mcp:use", "insights:write"],
|
|
81
|
+
scope: ["agent:read", "agent:write", "message:read", "message:send", "command:read", "command:write", "task:read", "task:write", "memory:read", "artifact:read", "artifact:write", "mcp:use", "teams:read", "insights:write"],
|
|
82
82
|
constraints: { terminalAttach: false, logsRead: false, canDelegate: false },
|
|
83
83
|
ttlSeconds: 30 * 24 * 60 * 60,
|
|
84
84
|
builtIn: true,
|
package/src/validation.ts
CHANGED
|
@@ -122,6 +122,8 @@ export function cleanTokenConstraints(value: unknown): TokenConstraints | undefi
|
|
|
122
122
|
}
|
|
123
123
|
const cwd = cleanString(value.cwd, "constraints.cwd", { max: 500 });
|
|
124
124
|
if (cwd) constraints.cwd = cwd;
|
|
125
|
+
const teamId = cleanString(value.teamId, "constraints.teamId", { max: 120 });
|
|
126
|
+
if (teamId) constraints.teamId = teamId;
|
|
125
127
|
for (const key of ["terminalAttach", "logsRead", "canDelegate"] as const) {
|
|
126
128
|
if (value[key] !== undefined) {
|
|
127
129
|
if (typeof value[key] !== "boolean") throw new ValidationError(`constraints.${key} must be a boolean`);
|