agent-relay-server 0.32.2 → 0.32.4
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/package.json +2 -2
- package/public/assets/display-JI19Vc7L.js.map +1 -1
- package/src/branch-landed.ts +38 -2
- package/src/cli.ts +3 -3
- package/src/maintenance.ts +21 -21
- package/src/mcp.ts +2 -2
- package/src/ratchet-files.ts +37 -0
- package/src/routes/_shared.ts +376 -0
- package/src/routes/activity.ts +61 -0
- package/src/routes/agent-profiles.ts +47 -0
- package/src/routes/agent-sessions.ts +488 -0
- package/src/routes/agents-spawn.ts +274 -0
- package/src/routes/agents.ts +251 -0
- package/src/routes/artifacts.ts +226 -0
- package/src/routes/automations.ts +83 -0
- package/src/routes/commands.ts +317 -0
- package/src/routes/config.ts +66 -0
- package/src/routes/connectors.ts +108 -0
- package/src/routes/inbox.ts +142 -0
- package/src/routes/index.ts +293 -0
- package/src/routes/insights.ts +81 -0
- package/src/routes/integrations.ts +592 -0
- package/src/routes/memory.ts +337 -0
- package/src/routes/messages.ts +529 -0
- package/src/routes/orchestrator-bootstrap.ts +100 -0
- package/src/routes/orchestrator-proxy.ts +160 -0
- package/src/routes/orchestrator.ts +490 -0
- package/src/routes/pairs.ts +197 -0
- package/src/routes/provider-config.ts +112 -0
- package/src/routes/recipes.ts +113 -0
- package/src/routes/spawn-policy.ts +231 -0
- package/src/routes/spec.ts +54 -0
- package/src/routes/sse.ts +9 -0
- package/src/routes/stats.ts +32 -0
- package/src/routes/steward.ts +45 -0
- package/src/routes/tasks.ts +174 -0
- package/src/routes/tokens.ts +311 -0
- package/src/routes/workspaces.ts +364 -0
- package/src/routes.ts +3 -6822
- package/src/validation.ts +134 -0
- package/src/workspace-actions.ts +7 -1
- package/src/workspace-merge.ts +12 -1
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Auto-split from routes.ts (#299). Domain: agent-profiles.
|
|
2
|
+
import { ValidationError } from "../db";
|
|
3
|
+
import { cleanString } from "../validation";
|
|
4
|
+
import { deleteAgentProfile, getAgentProfile, listAgentProfiles, setAgentProfile } from "../config-store";
|
|
5
|
+
import { emitConfigChanged } from "../sse";
|
|
6
|
+
import { error, json, normalizeConfigPathParam, parseBody, type Handler } from "./_shared";
|
|
7
|
+
import { isRecord } from "agent-relay-sdk";
|
|
8
|
+
import { type AgentProfile } from "../types";
|
|
9
|
+
|
|
10
|
+
export const getAgentProfilesRoute: Handler = () => json(listAgentProfiles());
|
|
11
|
+
|
|
12
|
+
export const getAgentProfileRoute: Handler = (_req, params) => {
|
|
13
|
+
const name = normalizeConfigPathParam(params.name, "name");
|
|
14
|
+
const profile = getAgentProfile(name);
|
|
15
|
+
return profile ? json(profile) : error("agent profile not found", 404);
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const putAgentProfileRoute: Handler = async (req, params) => {
|
|
19
|
+
const parsed = await parseBody<unknown>(req);
|
|
20
|
+
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
21
|
+
try {
|
|
22
|
+
if (!isRecord(parsed.body)) throw new ValidationError("agent profile body required");
|
|
23
|
+
const name = normalizeConfigPathParam(params.name, "name");
|
|
24
|
+
const updatedBy = cleanString(parsed.body.updatedBy, "updatedBy", { max: 200 });
|
|
25
|
+
const profile = setAgentProfile({ ...parsed.body, name } as AgentProfile, updatedBy);
|
|
26
|
+
emitConfigChanged(profile.namespace, profile.key, profile.version);
|
|
27
|
+
return json(profile, profile.version === 1 ? 201 : 200);
|
|
28
|
+
} catch (e) {
|
|
29
|
+
if (e instanceof ValidationError) return error(e.message, 400);
|
|
30
|
+
throw e;
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const deleteAgentProfileRoute: Handler = (req, params) => {
|
|
35
|
+
try {
|
|
36
|
+
const name = normalizeConfigPathParam(params.name, "name");
|
|
37
|
+
const existing = getAgentProfile(name);
|
|
38
|
+
if (!existing || existing.value.builtIn) return error(existing ? "built-in agent profiles are managed by Relay" : "agent profile not found", existing ? 400 : 404);
|
|
39
|
+
const updatedBy = cleanString(new URL(req.url).searchParams.get("updatedBy") ?? undefined, "updatedBy", { max: 200 });
|
|
40
|
+
deleteAgentProfile(name, updatedBy);
|
|
41
|
+
emitConfigChanged(existing.namespace, existing.key, existing.version + 1);
|
|
42
|
+
return json({ ok: true });
|
|
43
|
+
} catch (e) {
|
|
44
|
+
if (e instanceof ValidationError) return error(e.message, 400);
|
|
45
|
+
throw e;
|
|
46
|
+
}
|
|
47
|
+
};
|
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
// Auto-split from routes.ts (#299). Domain: agent-sessions.
|
|
2
|
+
import { RELAY_TOKEN_HEADER, isRecord } from "agent-relay-sdk";
|
|
3
|
+
import { VALID_AGENT_ACTIONS, agentControlActionIcon, auditEvent, authAuditMetadata, authorizeRoute, dashboardAttribution, emitCommand, error, json, metaString, parseBody, spawnRequestId, type AgentControlAction, type Handler } from "./_shared";
|
|
4
|
+
import { ValidationError, getAgent, getAgentTimeline, getOrchestrator, markReady, mergeAgentMeta, sendMessage } from "../db";
|
|
5
|
+
import { buildManagedSpawnParams } from "../managed-policy";
|
|
6
|
+
import { buildSpawnCommand, generateSpawnRequestId, resolveSpawnModelParams } from "../spawn-command";
|
|
7
|
+
import { cleanString, optionalEnum } from "../validation";
|
|
8
|
+
import { createCommand } from "../commands-db";
|
|
9
|
+
import { emitAgentStatus, emitNewMessage } from "../sse";
|
|
10
|
+
import { getAgentProfile, getSpawnPolicy } from "../config-store";
|
|
11
|
+
import { listManagedOrchestratorsForAgent } from "../orchestrator-lookup";
|
|
12
|
+
import { runnerRuntimeTokenEnv } from "../runtime-tokens";
|
|
13
|
+
import { type AgentCard, type SpawnApprovalMode } from "../types";
|
|
14
|
+
import { type ProviderEffort } from "agent-relay-sdk/provider-catalog";
|
|
15
|
+
|
|
16
|
+
const CLAUDE_RESUME_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
17
|
+
|
|
18
|
+
function metaStringArray(meta: Record<string, unknown> | undefined, key: string): string[] {
|
|
19
|
+
const value = meta?.[key];
|
|
20
|
+
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string" && item.trim().length > 0) : [];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function agentControlActionCommandType(action: AgentControlAction): "agent.restart" | "agent.shutdown" | "agent.reconnect" | "agent.compact" | "agent.clearContext" | "agent.interrupt" {
|
|
24
|
+
if (action === "restart" || action === "resume") return "agent.restart";
|
|
25
|
+
if (action === "shutdown") return "agent.shutdown";
|
|
26
|
+
if (action === "compact") return "agent.compact";
|
|
27
|
+
if (action === "clearContext") return "agent.clearContext";
|
|
28
|
+
if (action === "interrupt") return "agent.interrupt";
|
|
29
|
+
return "agent.reconnect";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function agentControlActionRequestedTitle(action: AgentControlAction): string {
|
|
33
|
+
if (action === "restart") return "Agent restart requested";
|
|
34
|
+
if (action === "resume") return "Agent resume requested";
|
|
35
|
+
if (action === "shutdown") return "Agent shutdown requested";
|
|
36
|
+
if (action === "compact") return "Agent compaction requested";
|
|
37
|
+
if (action === "clearContext") return "Agent context clear requested";
|
|
38
|
+
if (action === "interrupt") return "Agent interrupt requested";
|
|
39
|
+
return "Agent reconnect requested";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function agentIsControlEligible(agent: AgentCard): boolean {
|
|
43
|
+
return agent.id !== "user" &&
|
|
44
|
+
agent.id !== "system" &&
|
|
45
|
+
agent.meta?.kind !== "channel" &&
|
|
46
|
+
!agent.tags.includes("channel");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function agentCanReceiveControlAction(agent: AgentCard, action: AgentControlAction): boolean {
|
|
50
|
+
if (!agentIsControlEligible(agent)) return false;
|
|
51
|
+
if (action === "resume") return agentRuntimeProvider(agent) === "claude" && (agent.status === "offline" || agent.status === "stale");
|
|
52
|
+
// Interrupt only makes sense while the provider is mid-turn, and only if the
|
|
53
|
+
// provider advertises it can be interrupted from the dashboard.
|
|
54
|
+
if (action === "interrupt") return agent.providerCapabilities?.liveSession?.interrupt === true && agent.status === "busy";
|
|
55
|
+
const lifecycle = agent.providerCapabilities?.lifecycle;
|
|
56
|
+
if (lifecycle) {
|
|
57
|
+
if (action === "restart") return lifecycle.restartHard === true;
|
|
58
|
+
if (action === "shutdown") return lifecycle.shutdownHard === true;
|
|
59
|
+
if (action === "reconnect") return lifecycle.reconnect === true;
|
|
60
|
+
}
|
|
61
|
+
if (action === "compact") return agent.providerCapabilities?.context?.compact === true;
|
|
62
|
+
if (action === "clearContext") return agent.providerCapabilities?.context?.clear === true;
|
|
63
|
+
return agent.meta?.runnerManaged === true && (action === "restart" || action === "shutdown");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function agentRuntimeProvider(agent: AgentCard): string | undefined {
|
|
67
|
+
return metaString(agent.meta, "provider") ?? agent.providerCapabilities?.model?.provider;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function managedControlOrchestrator(agent: AgentCard): NonNullable<ReturnType<typeof getOrchestrator>> | null {
|
|
71
|
+
if (agent.meta?.runnerManaged !== true) return null;
|
|
72
|
+
const str = (v: unknown): string | undefined => (typeof v === "string" ? v : undefined);
|
|
73
|
+
const candidates = [
|
|
74
|
+
...listManagedOrchestratorsForAgent({
|
|
75
|
+
agentId: agent.id,
|
|
76
|
+
sessionName: str(agent.meta.sessionName),
|
|
77
|
+
tmuxSession: str(agent.meta.tmuxSession),
|
|
78
|
+
policyName: str(agent.meta.policyName),
|
|
79
|
+
spawnRequestId: str(agent.meta.spawnRequestId),
|
|
80
|
+
}),
|
|
81
|
+
...(agent.machine ? [getOrchestrator(agent.machine)].filter((orch): orch is NonNullable<ReturnType<typeof getOrchestrator>> => Boolean(orch)) : []),
|
|
82
|
+
];
|
|
83
|
+
return candidates.find((orch) => orch.status === "online") ?? null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function restartSpawnParamsForAgent(
|
|
87
|
+
agent: AgentCard,
|
|
88
|
+
orchestrator: NonNullable<ReturnType<typeof getOrchestrator>> | null,
|
|
89
|
+
policyName?: string,
|
|
90
|
+
spawnRequestId?: string,
|
|
91
|
+
opts: { resumeId?: string } = {},
|
|
92
|
+
): Record<string, unknown> | undefined {
|
|
93
|
+
if (!orchestrator) return undefined;
|
|
94
|
+
const requestId = spawnRequestId ?? spawnRequestIdForRestart();
|
|
95
|
+
const policy = policyName ? getSpawnPolicy(policyName) : null;
|
|
96
|
+
const requestedBy = opts.resumeId ? "dashboard-resume" : "dashboard-restart";
|
|
97
|
+
if (policy) {
|
|
98
|
+
const params = { ...buildManagedSpawnParams(policy.value, requestId, { createdBy: "managed-agent" }), agentId: agent.id, requestedBy };
|
|
99
|
+
return opts.resumeId ? withClaudeResumeParams(params, opts.resumeId, agent.id) : params;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const provider = metaString(agent.meta, "provider");
|
|
103
|
+
if (provider !== "claude" && provider !== "codex") return undefined;
|
|
104
|
+
const cwd = metaString(agent.meta, "cwd") ?? orchestrator.baseDir;
|
|
105
|
+
const label = metaString(agent.meta, "label") ?? agent.label;
|
|
106
|
+
const approvalMode = metaString(agent.meta, "approvalMode") as SpawnApprovalMode | undefined;
|
|
107
|
+
const model = metaString(agent.meta, "model");
|
|
108
|
+
const effort = metaString(agent.meta, "effort") as ProviderEffort | undefined;
|
|
109
|
+
const providerArgs = metaStringArray(agent.meta, "providerArgs");
|
|
110
|
+
const profileName = metaString(agent.meta, "profile");
|
|
111
|
+
const agentProfile = profileName ? getAgentProfile(profileName)?.value : undefined;
|
|
112
|
+
const workspaceMode = metaString(agent.meta, "workspaceMode") ?? "inherit";
|
|
113
|
+
const resolvedModel = resolveSpawnModelParams(provider, model, effort, { onError: "passthrough", skipDefaultWhenEmpty: true });
|
|
114
|
+
const params = buildSpawnCommand({
|
|
115
|
+
provider,
|
|
116
|
+
modelParams: resolvedModel,
|
|
117
|
+
cwd,
|
|
118
|
+
workspaceMode,
|
|
119
|
+
profile: profileName || undefined,
|
|
120
|
+
agentProfile,
|
|
121
|
+
label: label || undefined,
|
|
122
|
+
agentId: agent.id,
|
|
123
|
+
tags: agent.tags,
|
|
124
|
+
capabilities: agent.capabilities,
|
|
125
|
+
approvalMode: approvalMode ?? "guarded",
|
|
126
|
+
permissionMode: approvalMode ?? "guarded",
|
|
127
|
+
providerArgs: providerArgs.length ? providerArgs : undefined,
|
|
128
|
+
policyName: policyName || undefined,
|
|
129
|
+
headless: true,
|
|
130
|
+
spawnRequestId: requestId,
|
|
131
|
+
env: runnerRuntimeTokenEnv({
|
|
132
|
+
orchestratorId: orchestrator.id,
|
|
133
|
+
cwd,
|
|
134
|
+
provider,
|
|
135
|
+
label,
|
|
136
|
+
policyName,
|
|
137
|
+
spawnRequestId: requestId,
|
|
138
|
+
createdBy: requestedBy,
|
|
139
|
+
profile: profileName || undefined,
|
|
140
|
+
}),
|
|
141
|
+
requestedBy,
|
|
142
|
+
requestedAt: Date.now(),
|
|
143
|
+
});
|
|
144
|
+
return opts.resumeId ? withClaudeResumeParams(params, opts.resumeId, agent.id) : params;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function spawnRequestIdForRestart(): string {
|
|
148
|
+
return generateSpawnRequestId();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function withClaudeResumeParams(params: Record<string, unknown>, resumeId: string, agentId: string): Record<string, unknown> {
|
|
152
|
+
return {
|
|
153
|
+
...params,
|
|
154
|
+
providerArgs: providerArgsWithClaudeResume(recordStringArray(params.providerArgs), resumeId),
|
|
155
|
+
resumeOfAgentId: agentId,
|
|
156
|
+
claudeResumeId: resumeId,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function providerArgsWithClaudeResume(args: string[], resumeId: string): string[] {
|
|
161
|
+
const cleaned: string[] = [];
|
|
162
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
163
|
+
const arg = args[i];
|
|
164
|
+
if (!arg) continue;
|
|
165
|
+
if (arg === "--resume") {
|
|
166
|
+
i += 1;
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
if (arg.startsWith("--resume=")) continue;
|
|
170
|
+
cleaned.push(arg);
|
|
171
|
+
}
|
|
172
|
+
return [...cleaned, "--resume", resumeId];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function recordStringArray(value: unknown): string[] {
|
|
176
|
+
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string" && item.trim().length > 0) : [];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function latestClaudeResumeIdForAgent(agent: AgentCard): string | undefined {
|
|
180
|
+
for (const entry of getAgentTimeline(agent.id, { limit: 50 })) {
|
|
181
|
+
const metadata = entry.metadata;
|
|
182
|
+
if (!metadata) continue;
|
|
183
|
+
const provider = metaString(metadata, "provider");
|
|
184
|
+
const resumeId = metaString(metadata, "claudeResumeId");
|
|
185
|
+
if (provider === "claude" && resumeId && CLAUDE_RESUME_ID_RE.test(resumeId)) return resumeId;
|
|
186
|
+
}
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function compactMemoryParams(agent: AgentCard): Record<string, unknown> {
|
|
191
|
+
const alwaysReload = agent.tags
|
|
192
|
+
.filter((tag) => tag.startsWith("memory-reload:"))
|
|
193
|
+
.map((tag) => tag.slice("memory-reload:".length).trim())
|
|
194
|
+
.filter(Boolean);
|
|
195
|
+
if (!alwaysReload.length) return {};
|
|
196
|
+
return { memory: { alwaysReload } };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export const postAgentAction: Handler = async (req, params) => {
|
|
200
|
+
const parsed = await parseBody<unknown>(req);
|
|
201
|
+
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
202
|
+
try {
|
|
203
|
+
if (!isRecord(parsed.body)) return error("action required");
|
|
204
|
+
const action = optionalEnum(parsed.body.action, "action", VALID_AGENT_ACTIONS);
|
|
205
|
+
if (!action) return error("action required");
|
|
206
|
+
const agent = getAgent(params.id!);
|
|
207
|
+
if (!agent) return error("agent not found", 404);
|
|
208
|
+
if (!agentCanReceiveControlAction(agent, action)) return error(`agent does not support ${action}`, 400);
|
|
209
|
+
|
|
210
|
+
const orchestrator = (action === "restart" || action === "shutdown" || action === "resume") ? managedControlOrchestrator(agent) : null;
|
|
211
|
+
const metaSessionName = typeof agent.meta?.sessionName === "string" ? agent.meta.sessionName : undefined;
|
|
212
|
+
const metaTmuxSession = typeof agent.meta?.tmuxSession === "string" ? agent.meta.tmuxSession : undefined;
|
|
213
|
+
const metaPolicyName = typeof agent.meta?.policyName === "string" ? agent.meta.policyName : undefined;
|
|
214
|
+
const metaSpawnRequestId = typeof agent.meta?.spawnRequestId === "string" ? agent.meta.spawnRequestId : undefined;
|
|
215
|
+
const resumeId = action === "resume" ? latestClaudeResumeIdForAgent(agent) : undefined;
|
|
216
|
+
if (action === "resume" && !orchestrator) return error("no online orchestrator available to resume agent", 409);
|
|
217
|
+
if (action === "resume" && !resumeId) return error("no Claude resume id recorded for agent", 422);
|
|
218
|
+
const denied = authorizeRoute(req, {
|
|
219
|
+
scope: "agent:write",
|
|
220
|
+
resource: { agentId: agent.id, orchestratorId: orchestrator?.id, policyName: metaPolicyName, spawnRequestId: metaSpawnRequestId },
|
|
221
|
+
});
|
|
222
|
+
if (denied) return denied;
|
|
223
|
+
const command = createCommand({
|
|
224
|
+
type: agentControlActionCommandType(action),
|
|
225
|
+
source: "system",
|
|
226
|
+
target: orchestrator?.agentId ?? agent.id,
|
|
227
|
+
correlationId: metaSpawnRequestId,
|
|
228
|
+
params: {
|
|
229
|
+
action,
|
|
230
|
+
agentId: agent.id,
|
|
231
|
+
...(orchestrator ? { orchestratorId: orchestrator.id } : {}),
|
|
232
|
+
...(metaSessionName ? { sessionName: metaSessionName } : {}),
|
|
233
|
+
...(metaTmuxSession ? { tmuxSession: metaTmuxSession } : {}),
|
|
234
|
+
...(metaPolicyName ? { policyName: metaPolicyName } : {}),
|
|
235
|
+
...(metaSpawnRequestId ? { spawnRequestId: metaSpawnRequestId } : {}),
|
|
236
|
+
...(action === "restart" || action === "resume" ? { restartSpawn: restartSpawnParamsForAgent(agent, orchestrator, metaPolicyName, metaSpawnRequestId, { resumeId }) } : {}),
|
|
237
|
+
...(action === "compact" ? compactMemoryParams(agent) : {}),
|
|
238
|
+
...(resumeId ? { claudeResumeId: resumeId } : {}),
|
|
239
|
+
requestedBy: "dashboard",
|
|
240
|
+
requestedAt: Date.now(),
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
if (action === "shutdown" || action === "restart" || action === "resume") {
|
|
244
|
+
const lifecycleAction = action === "shutdown" ? "shutting-down" : action === "resume" ? "resuming" : "restarting";
|
|
245
|
+
markReady(agent.id, false);
|
|
246
|
+
mergeAgentMeta(agent.id, { lifecycleAction, lifecycleActionAt: Date.now(), lifecycleCommandId: command.id });
|
|
247
|
+
emitAgentStatus(agent.id);
|
|
248
|
+
}
|
|
249
|
+
emitCommand(command);
|
|
250
|
+
auditEvent({
|
|
251
|
+
clientId: "server-agent-" + agent.id + "-action-" + action + "-" + command.id,
|
|
252
|
+
kind: "state",
|
|
253
|
+
title: agentControlActionRequestedTitle(action),
|
|
254
|
+
body: action,
|
|
255
|
+
meta: agent.id,
|
|
256
|
+
icon: agentControlActionIcon(action),
|
|
257
|
+
view: "agents",
|
|
258
|
+
agentId: agent.id,
|
|
259
|
+
metadata: { action, commandId: command.id, ...(orchestrator ? { orchestratorId: orchestrator.id } : {}), ...authAuditMetadata(req), ...dashboardAttribution(req, parsed.body.surface) },
|
|
260
|
+
});
|
|
261
|
+
return json({ ok: true, action, command }, 202);
|
|
262
|
+
} catch (e) {
|
|
263
|
+
if (e instanceof ValidationError) return error(e.message, 400);
|
|
264
|
+
throw e;
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
export const postAgentPrompt: Handler = async (req, params) => {
|
|
269
|
+
const parsed = await parseBody<unknown>(req);
|
|
270
|
+
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
271
|
+
try {
|
|
272
|
+
if (!isRecord(parsed.body)) return error("body required");
|
|
273
|
+
const body = cleanString(parsed.body.body, "body", { required: true, max: 100_000 })!;
|
|
274
|
+
const agent = getAgent(params.id!);
|
|
275
|
+
if (!agent) return error("agent not found", 404);
|
|
276
|
+
if (agent.status === "offline") return error("agent is offline", 422);
|
|
277
|
+
const denied = authorizeRoute(req, {
|
|
278
|
+
scope: "message:send",
|
|
279
|
+
resource: { target: agent.id, agentId: "user" },
|
|
280
|
+
});
|
|
281
|
+
if (denied) return denied;
|
|
282
|
+
const message = sendMessage({
|
|
283
|
+
from: "user",
|
|
284
|
+
to: agent.id,
|
|
285
|
+
kind: "session",
|
|
286
|
+
body,
|
|
287
|
+
});
|
|
288
|
+
const command = createCommand({
|
|
289
|
+
type: "prompt.inject",
|
|
290
|
+
source: "dashboard",
|
|
291
|
+
target: agent.id,
|
|
292
|
+
params: { body, messageId: message.id },
|
|
293
|
+
});
|
|
294
|
+
emitCommand(command);
|
|
295
|
+
emitNewMessage(message);
|
|
296
|
+
auditEvent({
|
|
297
|
+
clientId: "server-prompt-inject-" + message.id,
|
|
298
|
+
kind: "message",
|
|
299
|
+
title: "Prompt injected",
|
|
300
|
+
body,
|
|
301
|
+
meta: `user -> ${agent.id}`,
|
|
302
|
+
icon: "ti-message-bolt",
|
|
303
|
+
view: "messages",
|
|
304
|
+
messageId: message.id,
|
|
305
|
+
agentId: agent.id,
|
|
306
|
+
});
|
|
307
|
+
return json({ ok: true, messageId: message.id, commandId: command.id }, 201);
|
|
308
|
+
} catch (e) {
|
|
309
|
+
if (e instanceof ValidationError) return error(e.message, 400);
|
|
310
|
+
throw e;
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
export const postAgentPermissionDecision: Handler = async (req, params) => {
|
|
315
|
+
const parsed = await parseBody<unknown>(req);
|
|
316
|
+
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
317
|
+
try {
|
|
318
|
+
if (!isRecord(parsed.body)) return error("permission decision required");
|
|
319
|
+
const approvalId = cleanString(parsed.body.approvalId, "approvalId", { required: true, max: 240 })!;
|
|
320
|
+
const decision = optionalEnum(parsed.body.decision, "decision", ["approve", "approve-session", "deny", "abort", "answer"] as const);
|
|
321
|
+
if (!decision) return error("decision required");
|
|
322
|
+
const reason = cleanString(parsed.body.reason, "reason", { max: 500 });
|
|
323
|
+
// AskUserQuestion answers: { "<question text>": "<chosen label(s)>" }
|
|
324
|
+
const answers = decision === "answer" && isRecord(parsed.body.answers)
|
|
325
|
+
? Object.fromEntries(
|
|
326
|
+
Object.entries(parsed.body.answers)
|
|
327
|
+
.filter(([, v]) => typeof v === "string")
|
|
328
|
+
.map(([k, v]) => [k.slice(0, 2000), (v as string).slice(0, 4000)]),
|
|
329
|
+
)
|
|
330
|
+
: undefined;
|
|
331
|
+
if (decision === "answer" && (!answers || Object.keys(answers).length === 0)) {
|
|
332
|
+
return error("answers required for answer decision");
|
|
333
|
+
}
|
|
334
|
+
const agent = getAgent(params.id!);
|
|
335
|
+
if (!agent) return error("agent not found", 404);
|
|
336
|
+
if (!agentIsControlEligible(agent)) return error("agent cannot receive permission decisions", 400);
|
|
337
|
+
const pendingApproval = isRecord(agent.meta?.providerState) && isRecord(agent.meta.providerState.pendingApproval)
|
|
338
|
+
? agent.meta.providerState.pendingApproval
|
|
339
|
+
: null;
|
|
340
|
+
const pendingId = typeof pendingApproval?.id === "string" ? pendingApproval.id : "";
|
|
341
|
+
if (pendingId && pendingId !== approvalId) return error("approval request is no longer current", 409);
|
|
342
|
+
const metaPolicyName = typeof agent.meta?.policyName === "string" ? agent.meta.policyName : undefined;
|
|
343
|
+
const metaSpawnRequestId = typeof agent.meta?.spawnRequestId === "string" ? agent.meta.spawnRequestId : undefined;
|
|
344
|
+
const denied = authorizeRoute(req, {
|
|
345
|
+
scope: "agent:write",
|
|
346
|
+
resource: { agentId: agent.id, policyName: metaPolicyName, spawnRequestId: metaSpawnRequestId },
|
|
347
|
+
});
|
|
348
|
+
if (denied) return denied;
|
|
349
|
+
const command = createCommand({
|
|
350
|
+
type: "agent.permissionDecision",
|
|
351
|
+
source: "system",
|
|
352
|
+
target: agent.id,
|
|
353
|
+
correlationId: approvalId,
|
|
354
|
+
ttlMs: 15 * 60 * 1000,
|
|
355
|
+
params: {
|
|
356
|
+
agentId: agent.id,
|
|
357
|
+
approvalId,
|
|
358
|
+
decision,
|
|
359
|
+
...(reason ? { reason } : {}),
|
|
360
|
+
...(answers ? { answers } : {}),
|
|
361
|
+
requestedBy: "dashboard",
|
|
362
|
+
requestedAt: Date.now(),
|
|
363
|
+
},
|
|
364
|
+
});
|
|
365
|
+
emitCommand(command);
|
|
366
|
+
auditEvent({
|
|
367
|
+
clientId: "server-agent-" + agent.id + "-permission-" + approvalId + "-" + command.id,
|
|
368
|
+
kind: "state",
|
|
369
|
+
title: "Agent permission decision sent",
|
|
370
|
+
body: decision,
|
|
371
|
+
meta: agent.id,
|
|
372
|
+
icon: decision === "deny" || decision === "abort" ? "ti-circle-x" : "ti-circle-check",
|
|
373
|
+
view: "chat",
|
|
374
|
+
agentId: agent.id,
|
|
375
|
+
metadata: { decision, approvalId, commandId: command.id, ...authAuditMetadata(req) },
|
|
376
|
+
});
|
|
377
|
+
return json({ ok: true, decision, command }, 202);
|
|
378
|
+
} catch (e) {
|
|
379
|
+
if (e instanceof ValidationError) return error(e.message, 400);
|
|
380
|
+
throw e;
|
|
381
|
+
}
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
export const postAgentTerminalSession: Handler = async (req, params) => {
|
|
385
|
+
try {
|
|
386
|
+
const agent = getAgent(params.id!);
|
|
387
|
+
if (!agent) return error("agent not found", 404);
|
|
388
|
+
const orchestrator = managedControlOrchestrator(agent);
|
|
389
|
+
if (!orchestrator) return error("agent does not have an online orchestrator", 422);
|
|
390
|
+
const metaTmuxSession = typeof agent.meta?.tmuxSession === "string" ? agent.meta.tmuxSession : undefined;
|
|
391
|
+
const metaPolicyName = typeof agent.meta?.policyName === "string" ? agent.meta.policyName : undefined;
|
|
392
|
+
const metaSpawnRequestId = typeof agent.meta?.spawnRequestId === "string" ? agent.meta.spawnRequestId : undefined;
|
|
393
|
+
const denied = authorizeRoute(req, {
|
|
394
|
+
scope: "terminal:attach",
|
|
395
|
+
resource: { agentId: agent.id, orchestratorId: orchestrator.id, policyName: metaPolicyName, spawnRequestId: metaSpawnRequestId, terminalAttach: true },
|
|
396
|
+
});
|
|
397
|
+
if (denied) return denied;
|
|
398
|
+
if (agent.providerCapabilities?.terminal?.live?.read && metaTmuxSession) {
|
|
399
|
+
return json({
|
|
400
|
+
mode: "live",
|
|
401
|
+
orchestratorId: orchestrator.id,
|
|
402
|
+
session: metaTmuxSession,
|
|
403
|
+
interactive: agent.providerCapabilities.terminal.live.write === true,
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
if (agent.providerCapabilities?.terminal?.attach?.create !== true) return error("agent does not support terminal attach", 422);
|
|
407
|
+
if (!orchestrator.apiUrl) return error("orchestrator does not expose an API", 422);
|
|
408
|
+
|
|
409
|
+
const body = {
|
|
410
|
+
agentId: agent.id,
|
|
411
|
+
...(metaTmuxSession ? { tmuxSession: metaTmuxSession } : {}),
|
|
412
|
+
...(metaPolicyName ? { policyName: metaPolicyName } : {}),
|
|
413
|
+
...(metaSpawnRequestId ? { spawnRequestId: metaSpawnRequestId } : {}),
|
|
414
|
+
};
|
|
415
|
+
const res = await proxyOrchestratorJson(orchestrator.id, "/api/terminal-guests", "POST", body);
|
|
416
|
+
if (res instanceof Response) return res;
|
|
417
|
+
return json({ ...res, orchestratorId: orchestrator.id });
|
|
418
|
+
} catch (e) {
|
|
419
|
+
if (e instanceof ValidationError) return error(e.message, 400);
|
|
420
|
+
throw e;
|
|
421
|
+
}
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
export const deleteAgentTerminalSession: Handler = async (req, params) => {
|
|
425
|
+
const agent = getAgent(params.id!);
|
|
426
|
+
if (!agent) return error("agent not found", 404);
|
|
427
|
+
const orchestrator = managedControlOrchestrator(agent);
|
|
428
|
+
if (!orchestrator) return error("agent does not have an online orchestrator", 422);
|
|
429
|
+
const denied = authorizeRoute(req, {
|
|
430
|
+
scope: "terminal:attach",
|
|
431
|
+
resource: {
|
|
432
|
+
agentId: agent.id,
|
|
433
|
+
orchestratorId: orchestrator.id,
|
|
434
|
+
policyName: typeof agent.meta?.policyName === "string" ? agent.meta.policyName : undefined,
|
|
435
|
+
spawnRequestId: typeof agent.meta?.spawnRequestId === "string" ? agent.meta.spawnRequestId : undefined,
|
|
436
|
+
terminalAttach: true,
|
|
437
|
+
},
|
|
438
|
+
});
|
|
439
|
+
if (denied) return denied;
|
|
440
|
+
const session = params.session!;
|
|
441
|
+
return proxyOrchestratorDelete(req, orchestrator.id, `/api/terminal-guests/${encodeURIComponent(session)}`);
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
async function proxyOrchestratorDelete(req: Request, orchestratorId: string, path: string): Promise<Response> {
|
|
445
|
+
const orch = getOrchestrator(orchestratorId);
|
|
446
|
+
if (!orch) return error("orchestrator not found", 404);
|
|
447
|
+
if (!orch.apiUrl) return error("orchestrator does not expose an API", 422);
|
|
448
|
+
if (orch.status !== "online") return error("orchestrator is offline", 422);
|
|
449
|
+
const incoming = new URL(req.url);
|
|
450
|
+
const proxyUrl = `${orch.apiUrl}${path}${incoming.search}`;
|
|
451
|
+
const headers: Record<string, string> = {};
|
|
452
|
+
const relayToken = process.env.AGENT_RELAY_TOKEN;
|
|
453
|
+
if (relayToken) headers[RELAY_TOKEN_HEADER] = relayToken;
|
|
454
|
+
try {
|
|
455
|
+
const res = await fetch(proxyUrl, {
|
|
456
|
+
method: "DELETE",
|
|
457
|
+
headers,
|
|
458
|
+
signal: AbortSignal.timeout(10_000),
|
|
459
|
+
});
|
|
460
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
461
|
+
return new Response(await res.text(), { status: res.status, headers: { "Content-Type": contentType || "application/json" } });
|
|
462
|
+
} catch (e) {
|
|
463
|
+
return error(`Failed to reach orchestrator API: ${(e as Error).message}`, 502);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
async function proxyOrchestratorJson(orchestratorId: string, path: string, method: "POST", body: unknown): Promise<Record<string, unknown> | Response> {
|
|
468
|
+
const orch = getOrchestrator(orchestratorId);
|
|
469
|
+
if (!orch) return error("orchestrator not found", 404);
|
|
470
|
+
if (!orch.apiUrl) return error("orchestrator does not expose an API", 422);
|
|
471
|
+
if (orch.status !== "online") return error("orchestrator is offline", 422);
|
|
472
|
+
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
473
|
+
const relayToken = process.env.AGENT_RELAY_TOKEN;
|
|
474
|
+
if (relayToken) headers[RELAY_TOKEN_HEADER] = relayToken;
|
|
475
|
+
try {
|
|
476
|
+
const res = await fetch(`${orch.apiUrl}${path}`, {
|
|
477
|
+
method,
|
|
478
|
+
headers,
|
|
479
|
+
body: JSON.stringify(body),
|
|
480
|
+
signal: AbortSignal.timeout(10_000),
|
|
481
|
+
});
|
|
482
|
+
const payload = await res.json().catch(() => null) as unknown;
|
|
483
|
+
if (!res.ok) return json(payload ?? { error: `orchestrator returned ${res.status}` }, res.status);
|
|
484
|
+
return payload && typeof payload === "object" && !Array.isArray(payload) ? payload as Record<string, unknown> : {};
|
|
485
|
+
} catch (e) {
|
|
486
|
+
return error(`Failed to reach orchestrator API: ${(e as Error).message}`, 502);
|
|
487
|
+
}
|
|
488
|
+
}
|