agent-relay-server 0.20.0 → 0.22.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 +4 -1
- package/package.json +2 -2
- package/public/index.html +698 -140
- package/runner/src/adapter.ts +1 -1
- package/scripts/install-bin-shim.cjs +16 -3
- package/src/agent-ref.ts +217 -0
- package/src/automations.ts +4 -1
- package/src/cli.ts +68 -8
- package/src/config-store.ts +35 -1
- package/src/context-router.ts +7 -7
- package/src/db.ts +111 -29
- package/src/maintenance.ts +9 -4
- package/src/managed-policy.ts +4 -1
- package/src/mcp.ts +452 -69
- package/src/routes.ts +94 -170
- package/src/runtime-tokens.ts +26 -1
- package/src/security.ts +3 -1
- package/src/workspace-actions.ts +336 -0
- package/src/workspace-phase.ts +181 -0
package/src/routes.ts
CHANGED
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
getAgentTimeline,
|
|
6
6
|
listAgents,
|
|
7
7
|
listPendingReplyObligations,
|
|
8
|
-
|
|
8
|
+
searchAgents,
|
|
9
9
|
setStatus,
|
|
10
10
|
setLabel,
|
|
11
11
|
setTags,
|
|
@@ -124,6 +124,7 @@ import {
|
|
|
124
124
|
setAgentProfile,
|
|
125
125
|
setConfig,
|
|
126
126
|
setStewardConfig,
|
|
127
|
+
spawnGrantForProfile,
|
|
127
128
|
upsertManagedAgentState,
|
|
128
129
|
updateManagedAgentState,
|
|
129
130
|
} from "./config-store";
|
|
@@ -157,6 +158,7 @@ import type { ActivityEventInput, ActivityKind, AgentCard, AgentKind, AgentProfi
|
|
|
157
158
|
import { getIntegrationTokens, INTEGRATION_RATE_LIMIT_PER_MINUTE, MAX_BODY_BYTES, VERSION, type IntegrationTokenConfig } from "./config";
|
|
158
159
|
import { CONTRACT_VERSIONS, parseRuntimeCapabilities, parseRuntimeContracts, parseRuntimePackage, type RuntimeCapabilities, type RuntimeContracts, type RuntimePackageMetadata } from "./contracts";
|
|
159
160
|
import { listHostDirectories } from "./agent-spawn";
|
|
161
|
+
import { planSend } from "./agent-ref";
|
|
160
162
|
import { defaultProviderConfig, loadProviderConfig, providerConfigPublic, writeProviderConfig } from "../runner/src/config";
|
|
161
163
|
import type { ProviderConfig } from "../runner/src/adapter";
|
|
162
164
|
import { type ProviderEffort } from "agent-relay-sdk/provider-catalog";
|
|
@@ -166,6 +168,14 @@ import { buildManagedSpawnParams, effectiveManagedPolicyWorkspaceMode } from "./
|
|
|
166
168
|
import { buildSpawnCommand, generateSpawnRequestId, resolveSpawnModelParams, type SpawnModelParams } from "./spawn-command";
|
|
167
169
|
import { requestWorkspaceMerge } from "./workspace-merge";
|
|
168
170
|
import { claimMetadataPatch, workspaceActiveClaim } from "./workspace-claim";
|
|
171
|
+
import {
|
|
172
|
+
applyWorkspaceAction,
|
|
173
|
+
buildWorkspaceCleanupCommand,
|
|
174
|
+
buildWorkspaceDepsRefreshCommand,
|
|
175
|
+
waitForWorkspaceStatus,
|
|
176
|
+
WORKSPACE_ACTIONS,
|
|
177
|
+
} from "./workspace-actions";
|
|
178
|
+
import { describeWorkspacePhase, landReceipt, TERMINAL_WORKSPACE_STATUSES } from "./workspace-phase";
|
|
169
179
|
import type { WorkspaceDiagnostics, WorkspaceGitState, WorkspaceRecord } from "./types";
|
|
170
180
|
import {
|
|
171
181
|
getComponentAuth,
|
|
@@ -1240,6 +1250,12 @@ const postAgent: Handler = async (req) => {
|
|
|
1240
1250
|
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
1241
1251
|
try {
|
|
1242
1252
|
const input = normalizeAgentInput(parsed.body);
|
|
1253
|
+
// Lineage is authoritative from the registering token's signed constraints — never the
|
|
1254
|
+
// client-sent body (a child can't forge its own parent). Set by relay at spawn for
|
|
1255
|
+
// agent-initiated spawns (`spawnedBy`) and the delegating-component path (`parentAgents`).
|
|
1256
|
+
const constraints = getComponentAuth(req)?.constraints;
|
|
1257
|
+
const lineage = constraints?.spawnedBy ?? constraints?.parentAgents?.[0];
|
|
1258
|
+
if (lineage) input.spawnedBy = lineage;
|
|
1243
1259
|
const existing = getAgent(input.id);
|
|
1244
1260
|
const agent = upsertAgent(input);
|
|
1245
1261
|
const policyName = metaString(agent.meta, "policyName");
|
|
@@ -1305,8 +1321,8 @@ const findAgents: Handler = (req) => {
|
|
|
1305
1321
|
const url = new URL(req.url);
|
|
1306
1322
|
const capability = url.searchParams.get("capability");
|
|
1307
1323
|
if (!capability) return error("capability query param required");
|
|
1308
|
-
const
|
|
1309
|
-
return json(
|
|
1324
|
+
const includeAll = url.searchParams.get("all") === "true";
|
|
1325
|
+
return json(searchAgents({ capability, status: includeAll ? "all" : "running" }));
|
|
1310
1326
|
};
|
|
1311
1327
|
|
|
1312
1328
|
const postRouteAdvice: Handler = async (req) => {
|
|
@@ -2538,6 +2554,8 @@ const postAgentSpawn: Handler = async (req) => {
|
|
|
2538
2554
|
const cwd = cleanString(parsed.body.cwd, "cwd", { max: 500 });
|
|
2539
2555
|
const label = cleanString(parsed.body.label, "label", { max: 120 });
|
|
2540
2556
|
const workspaceMode = optionalEnum(parsed.body.workspaceMode, "workspaceMode", VALID_WORKSPACE_MODES, "inherit") as WorkspaceMode;
|
|
2557
|
+
const profile = cleanString(parsed.body.profile, "profile", { max: 120 });
|
|
2558
|
+
const grant = spawnGrantForProfile(profile);
|
|
2541
2559
|
|
|
2542
2560
|
const orchestrators = listOrchestrators().filter(
|
|
2543
2561
|
(o) => o.status === "online" && o.providers.includes(provider as SpawnProvider),
|
|
@@ -2565,6 +2583,7 @@ const postAgentSpawn: Handler = async (req) => {
|
|
|
2565
2583
|
workspaceMode,
|
|
2566
2584
|
label,
|
|
2567
2585
|
approvalMode,
|
|
2586
|
+
profile: profile || undefined,
|
|
2568
2587
|
spawnRequestId: requestId,
|
|
2569
2588
|
requestedBy: "dashboard",
|
|
2570
2589
|
requestedAt: Date.now(),
|
|
@@ -2575,6 +2594,8 @@ const postAgentSpawn: Handler = async (req) => {
|
|
|
2575
2594
|
label,
|
|
2576
2595
|
spawnRequestId: requestId,
|
|
2577
2596
|
createdBy: "dashboard",
|
|
2597
|
+
canSpawn: grant.canSpawn,
|
|
2598
|
+
maxSpawnedAgents: grant.maxSpawnedAgents,
|
|
2578
2599
|
}),
|
|
2579
2600
|
}),
|
|
2580
2601
|
});
|
|
@@ -3855,8 +3876,6 @@ const getWorkspaceDiff: Handler = (req, params) => {
|
|
|
3855
3876
|
return proxyWorkspaceHostGet(params.id!, "/api/workspace/diff", patch === "0" ? { patch: "0" } : undefined);
|
|
3856
3877
|
};
|
|
3857
3878
|
|
|
3858
|
-
const TERMINAL_WORKSPACE_STATUSES = new Set<WorkspaceStatus>(["cleaned", "merged", "abandoned"]);
|
|
3859
|
-
|
|
3860
3879
|
// Worktrees found on disk (agent/* branches) with no live workspace row — left
|
|
3861
3880
|
// behind by crashes or failed cleanups. Probes each known repo's owning host
|
|
3862
3881
|
// and subtracts active DB rows. Reclaim them via POST .../orphans/reclaim.
|
|
@@ -3935,59 +3954,8 @@ const postWorkspaceOrphanReclaim: Handler = async (req) => {
|
|
|
3935
3954
|
}
|
|
3936
3955
|
};
|
|
3937
3956
|
|
|
3938
|
-
//
|
|
3939
|
-
//
|
|
3940
|
-
// the owner and shape params identically. Queues (no TTL) when the owner is offline;
|
|
3941
|
-
// hard-fails only when no orchestrator owns the path (DELETE is then the escape).
|
|
3942
|
-
function buildWorkspaceCleanupCommand(workspace: WorkspaceRecord, requestedBy: string): { ok: true; command: Command } | { ok: false; status: number; error: string } {
|
|
3943
|
-
const owners = listOrchestrators().filter((candidate) => isPathWithinBase(workspace.sourceCwd, candidate.baseDir));
|
|
3944
|
-
const owner = owners.find((candidate) => candidate.status === "online") ?? owners[0];
|
|
3945
|
-
if (!owner) return { ok: false, status: 409, error: "no orchestrator owns this workspace path; use DELETE /api/workspaces/:id to purge the record" };
|
|
3946
|
-
const command = createCommand({
|
|
3947
|
-
type: "workspace.cleanup",
|
|
3948
|
-
source: "system",
|
|
3949
|
-
target: owner.agentId,
|
|
3950
|
-
correlationId: workspace.id,
|
|
3951
|
-
params: {
|
|
3952
|
-
action: "cleanup",
|
|
3953
|
-
workspaceId: workspace.id,
|
|
3954
|
-
repoRoot: workspace.repoRoot,
|
|
3955
|
-
worktreePath: workspace.worktreePath,
|
|
3956
|
-
branch: workspace.branch,
|
|
3957
|
-
requestedBy,
|
|
3958
|
-
requestedAt: Date.now(),
|
|
3959
|
-
deleteBranch: true,
|
|
3960
|
-
queued: owner.status !== "online",
|
|
3961
|
-
},
|
|
3962
|
-
});
|
|
3963
|
-
return { ok: true, command };
|
|
3964
|
-
}
|
|
3965
|
-
|
|
3966
|
-
// Build a `workspace.deps-refresh` command for a worktree's owning orchestrator
|
|
3967
|
-
// (issue #51). Re-provisions deps the shared symlinked node_modules has gone stale
|
|
3968
|
-
// on, or — checkOnly — just reports staleness. Same owner-resolution as cleanup.
|
|
3969
|
-
function buildWorkspaceDepsRefreshCommand(workspace: WorkspaceRecord, requestedBy: string, checkOnly: boolean): { ok: true; command: Command } | { ok: false; status: number; error: string } {
|
|
3970
|
-
const owners = listOrchestrators().filter((candidate) => isPathWithinBase(workspace.sourceCwd, candidate.baseDir));
|
|
3971
|
-
const owner = owners.find((candidate) => candidate.status === "online") ?? owners[0];
|
|
3972
|
-
if (!owner) return { ok: false, status: 409, error: "no online orchestrator owns this workspace path" };
|
|
3973
|
-
const command = createCommand({
|
|
3974
|
-
type: "workspace.deps-refresh",
|
|
3975
|
-
source: "system",
|
|
3976
|
-
target: owner.agentId,
|
|
3977
|
-
correlationId: workspace.id,
|
|
3978
|
-
params: {
|
|
3979
|
-
action: "deps-refresh",
|
|
3980
|
-
workspaceId: workspace.id,
|
|
3981
|
-
repoRoot: workspace.repoRoot,
|
|
3982
|
-
worktreePath: workspace.worktreePath,
|
|
3983
|
-
checkOnly,
|
|
3984
|
-
requestedBy,
|
|
3985
|
-
requestedAt: Date.now(),
|
|
3986
|
-
queued: owner.status !== "online",
|
|
3987
|
-
},
|
|
3988
|
-
});
|
|
3989
|
-
return { ok: true, command };
|
|
3990
|
-
}
|
|
3957
|
+
// buildWorkspaceCleanupCommand / buildWorkspaceDepsRefreshCommand now live in
|
|
3958
|
+
// ./workspace-actions (shared with the relay_workspace_* MCP tools) — imported above.
|
|
3991
3959
|
|
|
3992
3960
|
// Fetch + parse a workspace's live git state from its owning host, or report why
|
|
3993
3961
|
// it's unavailable. Thin typed wrapper over the same host route the proxy uses.
|
|
@@ -4025,6 +3993,13 @@ function recommendWorkspaceAction(input: { workspace: WorkspaceRecord; ownerOnli
|
|
|
4025
3993
|
if ((gitState.dirtyCount ?? 0) > 0) return { action: "review", confidence: "medium", reason: `${gitState.dirtyCount} uncommitted change(s)` };
|
|
4026
3994
|
if (ahead === 0 || landed) {
|
|
4027
3995
|
if (!ownerOnline) return { action: "cleanup", confidence: "high", reason: landed ? "work already landed; owner offline" : "no unmerged commits; owner offline" };
|
|
3996
|
+
// Owner active but the workspace is awaiting attention with nothing to land (#230):
|
|
3997
|
+
// landing it is a safe no-op that resolves it to terminal `merged`, clearing the
|
|
3998
|
+
// queue (the host keeps a live owner's worktree). Otherwise there's genuinely
|
|
3999
|
+
// nothing to do.
|
|
4000
|
+
if (workspace.status === "review_requested" || workspace.status === "conflict") {
|
|
4001
|
+
return { action: "merge", confidence: "high", reason: landed ? "work already landed; resolve the no-op" : "nothing to merge; resolve the no-op" };
|
|
4002
|
+
}
|
|
4028
4003
|
return { action: "none", confidence: "medium", reason: "nothing to merge; owner active" };
|
|
4029
4004
|
}
|
|
4030
4005
|
if (ownerOnline && workspace.status !== "review_requested" && workspace.status !== "conflict") {
|
|
@@ -4127,129 +4102,64 @@ const postWorkspaceAction: Handler = async (req, params) => {
|
|
|
4127
4102
|
if (!isRecord(parsed.body)) return error("body required");
|
|
4128
4103
|
const workspace = getWorkspace(params.id!);
|
|
4129
4104
|
if (!workspace) return error("workspace not found", 404);
|
|
4130
|
-
const action = optionalEnum(parsed.body.action, "action",
|
|
4105
|
+
const action = optionalEnum(parsed.body.action, "action", WORKSPACE_ACTIONS);
|
|
4131
4106
|
if (!action) return error("action required", 400);
|
|
4132
4107
|
const agentId = cleanString(parsed.body.agentId, "agentId", { max: 240 });
|
|
4133
|
-
const detail = cleanString(parsed.body.detail, "detail", { max: 4000 });
|
|
4134
|
-
const metadata = cleanMeta(parsed.body.metadata) ?? {};
|
|
4135
4108
|
const requiresCommand = action === "cleanup" || action === "merge";
|
|
4136
|
-
// Shared-mode rows are occupancy markers with no worktree —
|
|
4137
|
-
//
|
|
4109
|
+
// Shared-mode rows are occupancy markers with no worktree — reject host commands
|
|
4110
|
+
// up front, before authz, preserving the original 422-before-auth ordering.
|
|
4138
4111
|
if (requiresCommand && (workspace.mode !== "isolated" || !workspace.worktreePath)) {
|
|
4139
4112
|
return error(`workspace ${workspace.id} has no worktree to ${action}`, 422);
|
|
4140
4113
|
}
|
|
4141
4114
|
const denied = authorizeRoute(req, { scope: requiresCommand ? "command:write" : "agent:write", resource: { agentId, cwd: workspace.worktreePath } });
|
|
4142
4115
|
if (denied) return denied;
|
|
4143
|
-
|
|
4144
|
-
//
|
|
4145
|
-
//
|
|
4146
|
-
|
|
4147
|
-
|
|
4148
|
-
|
|
4149
|
-
|
|
4150
|
-
|
|
4151
|
-
|
|
4152
|
-
|
|
4153
|
-
|
|
4154
|
-
|
|
4155
|
-
|
|
4156
|
-
|
|
4157
|
-
|
|
4158
|
-
|
|
4159
|
-
|
|
4160
|
-
|
|
4161
|
-
|
|
4162
|
-
|
|
4163
|
-
|
|
4164
|
-
// Base merges go through the shared helper (lease + command + bind), the same
|
|
4165
|
-
// path the auto-merge job uses, so both serialize per repo (issue #157).
|
|
4166
|
-
if (action === "merge") {
|
|
4167
|
-
const strategy = optionalEnum(parsed.body.strategy, "strategy", ["pr", "rebase-ff", "auto"] as const, "auto") as WorkspaceMergeStrategy;
|
|
4168
|
-
const result = requestWorkspaceMerge(workspace, {
|
|
4169
|
-
requestedBy: agentId ?? "dashboard",
|
|
4170
|
-
strategy,
|
|
4171
|
-
deleteBranch: parsed.body.deleteBranch !== false,
|
|
4172
|
-
prTitle: cleanString(parsed.body.prTitle, "prTitle", { max: 240 }),
|
|
4173
|
-
prBody: cleanString(parsed.body.prBody, "prBody", { max: 8000 }),
|
|
4174
|
-
metadata: { ...metadata, ...(detail ? { detail } : {}), ...(agentId ? { updatedByAgentId: agentId } : {}) },
|
|
4175
|
-
});
|
|
4176
|
-
if (!result.ok) return error(result.error, result.status);
|
|
4177
|
-
emitCommand(result.command);
|
|
4178
|
-
auditEvent({
|
|
4179
|
-
clientId: `workspace-merge-${workspace.id}-${Date.now()}`,
|
|
4180
|
-
kind: "state",
|
|
4181
|
-
title: "Workspace merge",
|
|
4182
|
-
body: detail ?? workspace.worktreePath,
|
|
4183
|
-
meta: workspace.branch ?? workspace.id,
|
|
4184
|
-
icon: "ti-git-merge",
|
|
4185
|
-
view: "orchestrators",
|
|
4186
|
-
agentId,
|
|
4187
|
-
metadata: { action: "merge", workspaceId: workspace.id, repoRoot: workspace.repoRoot, worktreePath: workspace.worktreePath, status: result.workspace.status, commandId: result.command.id, ...authAuditMetadata(req) },
|
|
4188
|
-
});
|
|
4189
|
-
return json({ workspace: result.workspace, command: result.command }, 202);
|
|
4190
|
-
}
|
|
4191
|
-
// Deps refresh (#51): self-service for the owning agent — re-provision deps the
|
|
4192
|
-
// shared symlinked node_modules has gone stale on. Emits a host command but is
|
|
4193
|
-
// authorized at agent:write (it only touches the agent's own worktree), so the
|
|
4194
|
-
// agent can run it themselves the moment typecheck reports a missing module.
|
|
4195
|
-
if (action === "deps-refresh") {
|
|
4196
|
-
if (workspace.mode !== "isolated" || !workspace.worktreePath) {
|
|
4197
|
-
return error(`workspace ${workspace.id} has no isolated worktree to refresh`, 422);
|
|
4116
|
+
|
|
4117
|
+
// status: optionally long-poll until the workspace transitions — after a `ready`,
|
|
4118
|
+
// block until the auto-merge job lands it (→ merged/recycled-to-active) or it
|
|
4119
|
+
// stalls into conflict/review_requested, instead of the caller busy-polling.
|
|
4120
|
+
if (action === "status") {
|
|
4121
|
+
if (parsed.body.wait === true) {
|
|
4122
|
+
const timeoutSeconds = typeof parsed.body.timeoutSeconds === "number" && parsed.body.timeoutSeconds > 0 ? parsed.body.timeoutSeconds : undefined;
|
|
4123
|
+
const waited = await waitForWorkspaceStatus(workspace.id, timeoutSeconds ? { timeoutMs: timeoutSeconds * 1000 } : {});
|
|
4124
|
+
if (!waited.workspace) return error("workspace not found", 404);
|
|
4125
|
+
const landed = waited.transitioned ? landReceipt(waited.fromStatus, waited.workspace) : null;
|
|
4126
|
+
// Mirror the relay_workspace_status MCP tool: bare record (legacy fields)
|
|
4127
|
+
// plus the directive projection + land receipt so the CLI `--wait` and any
|
|
4128
|
+
// HTTP caller get the same legible answer.
|
|
4129
|
+
return json({
|
|
4130
|
+
workspace: waited.workspace,
|
|
4131
|
+
guidance: describeWorkspacePhase(waited.workspace),
|
|
4132
|
+
...(landed ? { landed } : {}),
|
|
4133
|
+
fromStatus: waited.fromStatus,
|
|
4134
|
+
transitioned: waited.transitioned,
|
|
4135
|
+
timedOut: waited.timedOut,
|
|
4136
|
+
});
|
|
4198
4137
|
}
|
|
4199
|
-
|
|
4200
|
-
const built = buildWorkspaceDepsRefreshCommand(workspace, agentId ?? "agent", checkOnly);
|
|
4201
|
-
if (!built.ok) return error(built.error, built.status);
|
|
4202
|
-
emitCommand(built.command);
|
|
4203
|
-
auditEvent({
|
|
4204
|
-
clientId: `workspace-deps-refresh-${workspace.id}-${Date.now()}`,
|
|
4205
|
-
kind: "state",
|
|
4206
|
-
title: checkOnly ? "Workspace deps check" : "Workspace deps refresh",
|
|
4207
|
-
body: detail ?? workspace.worktreePath,
|
|
4208
|
-
meta: workspace.branch ?? workspace.id,
|
|
4209
|
-
icon: "ti-package",
|
|
4210
|
-
view: "orchestrators",
|
|
4211
|
-
agentId,
|
|
4212
|
-
metadata: { action, workspaceId: workspace.id, repoRoot: workspace.repoRoot, worktreePath: workspace.worktreePath, checkOnly, commandId: built.command.id, ...authAuditMetadata(req) },
|
|
4213
|
-
});
|
|
4214
|
-
return json({ workspace, command: built.command }, 202);
|
|
4215
|
-
}
|
|
4216
|
-
const statusByAction: Record<string, WorkspaceStatus | undefined> = {
|
|
4217
|
-
status: undefined,
|
|
4218
|
-
ready: "ready",
|
|
4219
|
-
"conflict-found": "conflict",
|
|
4220
|
-
"request-review": "review_requested",
|
|
4221
|
-
"merge-plan": "merge_planned",
|
|
4222
|
-
abandon: "abandoned",
|
|
4223
|
-
cleanup: "cleanup_requested",
|
|
4224
|
-
};
|
|
4225
|
-
const updated = updateWorkspaceStatus(workspace.id, statusByAction[action]!, {
|
|
4226
|
-
...metadata,
|
|
4227
|
-
...(detail ? { detail } : {}),
|
|
4228
|
-
...(agentId ? { updatedByAgentId: agentId } : {}),
|
|
4229
|
-
lastWorkspaceAction: action,
|
|
4230
|
-
lastWorkspaceActionAt: Date.now(),
|
|
4231
|
-
});
|
|
4232
|
-
if (!updated) return error("workspace not found", 404);
|
|
4233
|
-
let command: Command | undefined;
|
|
4234
|
-
if (requiresCommand) {
|
|
4235
|
-
// Only `cleanup` reaches here — `merge` returned early via the shared helper.
|
|
4236
|
-
const built = buildWorkspaceCleanupCommand(workspace, agentId ?? "dashboard");
|
|
4237
|
-
if (!built.ok) return error(built.error, built.status);
|
|
4238
|
-
command = built.command;
|
|
4239
|
-
emitCommand(command);
|
|
4138
|
+
return json(workspace);
|
|
4240
4139
|
}
|
|
4241
|
-
|
|
4242
|
-
|
|
4243
|
-
|
|
4244
|
-
|
|
4245
|
-
|
|
4246
|
-
|
|
4247
|
-
icon: action === "cleanup" ? "ti-trash" : action === "conflict-found" ? "ti-alert-triangle" : "ti-git-branch",
|
|
4248
|
-
view: "orchestrators",
|
|
4140
|
+
|
|
4141
|
+
// Everything else delegates to the shared core (one home, shared with the
|
|
4142
|
+
// relay_workspace_* MCP tools); the core self-audits and returns any command to
|
|
4143
|
+
// emit, mirroring requestWorkspaceMerge's "caller emits" contract.
|
|
4144
|
+
const result = applyWorkspaceAction(workspace, {
|
|
4145
|
+
action,
|
|
4249
4146
|
agentId,
|
|
4250
|
-
|
|
4147
|
+
detail: cleanString(parsed.body.detail, "detail", { max: 4000 }),
|
|
4148
|
+
metadata: cleanMeta(parsed.body.metadata) ?? {},
|
|
4149
|
+
strategy: optionalEnum(parsed.body.strategy, "strategy", ["pr", "rebase-ff", "auto"] as const, "auto") as WorkspaceMergeStrategy,
|
|
4150
|
+
deleteBranch: typeof parsed.body.deleteBranch === "boolean" ? parsed.body.deleteBranch : undefined,
|
|
4151
|
+
prTitle: cleanString(parsed.body.prTitle, "prTitle", { max: 240 }),
|
|
4152
|
+
prBody: cleanString(parsed.body.prBody, "prBody", { max: 8000 }),
|
|
4153
|
+
purpose: cleanString(parsed.body.purpose, "purpose", { max: 120 }),
|
|
4154
|
+
checkOnly: parsed.body.checkOnly === true,
|
|
4155
|
+
auditMetadata: authAuditMetadata(req),
|
|
4251
4156
|
});
|
|
4252
|
-
|
|
4157
|
+
if (!result.ok) return error(result.error, result.httpStatus);
|
|
4158
|
+
if (result.command) emitCommand(result.command);
|
|
4159
|
+
const payload: Record<string, unknown> = { workspace: result.workspace };
|
|
4160
|
+
if (result.command) payload.command = result.command;
|
|
4161
|
+
if (result.claim !== undefined) payload.claim = result.claim;
|
|
4162
|
+
return json(payload, result.httpStatus);
|
|
4253
4163
|
} catch (e) {
|
|
4254
4164
|
if (e instanceof ValidationError) return error(e.message, 400);
|
|
4255
4165
|
throw e;
|
|
@@ -5273,11 +5183,25 @@ const postMessage: Handler = async (req) => {
|
|
|
5273
5183
|
resource: { target: input.to, channel: input.channel, agentId: input.from },
|
|
5274
5184
|
});
|
|
5275
5185
|
if (denied) return denied;
|
|
5186
|
+
// Resolve the target through the shared planner — the SAME matcher the MCP send tool
|
|
5187
|
+
// uses (planSend → matchAgents) — so a bare label / name / id-segment that matches an
|
|
5188
|
+
// existing agent is rewritten to its canonical id. Poll-time matching is exact, so
|
|
5189
|
+
// without this a bare label is stored verbatim and reaches no one (#234). An ambiguous
|
|
5190
|
+
// direct ref can't be delivered, so reject it up front. Unknown targets are left
|
|
5191
|
+
// untouched on purpose: sending to an id that isn't registered yet is a supported
|
|
5192
|
+
// "store-ahead" pattern (delivered once that agent registers and polls). Fan-out and
|
|
5193
|
+
// reserved/policy targets carry through unchanged (and may legitimately match zero now).
|
|
5194
|
+
//
|
|
5276
5195
|
// "session" = observed assistant turn (Phase 1 live-session lane). It is captured
|
|
5277
5196
|
// from the provider transcript and stored for the dashboard chat; it must persist
|
|
5278
5197
|
// regardless of target liveness and never be re-delivered into a session.
|
|
5279
5198
|
const bypassKinds = ["system", "control", "session"];
|
|
5280
|
-
if (
|
|
5199
|
+
if (!bypassKinds.includes(input.kind ?? "")) {
|
|
5200
|
+
const plan = planSend(input.to, listAgents());
|
|
5201
|
+
if (plan.kind === "ambiguous") return error(plan.message, 409);
|
|
5202
|
+
if (plan.kind !== "not_found") input.to = plan.to;
|
|
5203
|
+
// Long-standing guard: refuse a direct send to a known-offline agent (now also
|
|
5204
|
+
// catches a ref that resolved to an offline agent by label/segment).
|
|
5281
5205
|
const target = getAgent(input.to);
|
|
5282
5206
|
if (target && target.status === "offline") {
|
|
5283
5207
|
return error(`agent "${input.to}" is offline`, 422);
|
package/src/runtime-tokens.ts
CHANGED
|
@@ -1,7 +1,20 @@
|
|
|
1
|
-
import { createToken, revokeToken } from "./token-db";
|
|
1
|
+
import { createToken, getTokenProfile, revokeToken } from "./token-db";
|
|
2
2
|
import { verifyComponentTokenAllowExpired } from "./security";
|
|
3
3
|
import type { TokenRecord } from "./types";
|
|
4
4
|
|
|
5
|
+
// Scopes that turn an agent's runtime token into a spawn-capable one (#221). Appended to the
|
|
6
|
+
// provider-agent base scope only when the resolved profile permits (maxSpawnedAgents > 0).
|
|
7
|
+
// Children never receive these (no grandchildren), so a child's token can never gate the
|
|
8
|
+
// spawn/shutdown MCP tools open. Kept distinct from `agent:write` (self-management) on
|
|
9
|
+
// purpose — that scope must stay for heartbeat/status without implying spawn rights.
|
|
10
|
+
const SPAWN_SCOPES = ["command:spawn", "command:shutdown"] as const;
|
|
11
|
+
|
|
12
|
+
function runnerScopeWithSpawn(canSpawn: boolean): string[] | undefined {
|
|
13
|
+
if (!canSpawn) return undefined; // undefined → createToken falls back to the profile's scope
|
|
14
|
+
const base = getTokenProfile("provider-agent")?.scope ?? [];
|
|
15
|
+
return [...base, ...SPAWN_SCOPES];
|
|
16
|
+
}
|
|
17
|
+
|
|
5
18
|
interface RuntimeTokenResult {
|
|
6
19
|
token: string;
|
|
7
20
|
record: TokenRecord;
|
|
@@ -76,6 +89,12 @@ export function issueRunnerRuntimeToken(input: {
|
|
|
76
89
|
policyName?: string;
|
|
77
90
|
spawnRequestId?: string;
|
|
78
91
|
createdBy?: string;
|
|
92
|
+
/** Grant the spawn/shutdown scopes (resolved from the agent's profile maxSpawnedAgents>0). */
|
|
93
|
+
canSpawn?: boolean;
|
|
94
|
+
/** Live-children quota baked into the token for the runtime spawn check. */
|
|
95
|
+
maxSpawnedAgents?: number;
|
|
96
|
+
/** Parent agent id — stamped so the child registers with an authoritative `spawnedBy`. */
|
|
97
|
+
spawnedBy?: string;
|
|
79
98
|
}): RuntimeTokenResult {
|
|
80
99
|
const subject = input.policyName
|
|
81
100
|
? `runner:policy:${input.policyName}`
|
|
@@ -86,11 +105,14 @@ export function issueRunnerRuntimeToken(input: {
|
|
|
86
105
|
profileId: "provider-agent",
|
|
87
106
|
sub: subject,
|
|
88
107
|
role: "provider",
|
|
108
|
+
scope: runnerScopeWithSpawn(input.canSpawn ?? false),
|
|
89
109
|
constraints: {
|
|
90
110
|
orchestrators: [input.orchestratorId],
|
|
91
111
|
cwdPrefixes: [input.cwd],
|
|
92
112
|
...(input.policyName ? { policies: [input.policyName] } : {}),
|
|
93
113
|
...(input.spawnRequestId ? { spawnRequestIds: [input.spawnRequestId] } : {}),
|
|
114
|
+
...(input.canSpawn && input.maxSpawnedAgents ? { maxSpawnedAgents: input.maxSpawnedAgents } : {}),
|
|
115
|
+
...(input.spawnedBy ? { spawnedBy: input.spawnedBy } : {}),
|
|
94
116
|
},
|
|
95
117
|
createdBy: input.createdBy ?? "runtime",
|
|
96
118
|
});
|
|
@@ -204,6 +226,9 @@ export function runnerRuntimeTokenEnv(input: {
|
|
|
204
226
|
policyName?: string;
|
|
205
227
|
spawnRequestId?: string;
|
|
206
228
|
createdBy?: string;
|
|
229
|
+
canSpawn?: boolean;
|
|
230
|
+
maxSpawnedAgents?: number;
|
|
231
|
+
spawnedBy?: string;
|
|
207
232
|
}): Record<string, string> {
|
|
208
233
|
const issued = issueRunnerRuntimeToken(input);
|
|
209
234
|
return {
|
package/src/security.ts
CHANGED
|
@@ -438,8 +438,10 @@ function isTokenConstraints(value: unknown): value is TokenConstraints {
|
|
|
438
438
|
for (const [key, item] of Object.entries(record)) {
|
|
439
439
|
if (["terminalAttach", "logsRead", "canDelegate"].includes(key)) {
|
|
440
440
|
if (typeof item !== "boolean") return false;
|
|
441
|
-
} else if (key === "cwd") {
|
|
441
|
+
} else if (key === "cwd" || key === "spawnedBy") {
|
|
442
442
|
if (typeof item !== "string") return false;
|
|
443
|
+
} else if (key === "maxSpawnedAgents") {
|
|
444
|
+
if (typeof item !== "number") return false;
|
|
443
445
|
} else if (!Array.isArray(item) || !item.every((entry) => typeof entry === "string")) {
|
|
444
446
|
return false;
|
|
445
447
|
}
|