agent-relay-server 0.11.8 → 0.12.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 +182 -1
- package/package.json +2 -2
- package/public/index.html +6349 -1109
- package/runner/src/adapter.ts +34 -0
- package/src/bus.ts +42 -0
- package/src/config-store.ts +58 -0
- package/src/maintenance.ts +281 -6
- package/src/routes.ts +217 -87
- package/src/runtime-tokens.ts +44 -1
- package/src/security.ts +17 -0
- package/src/steward.ts +117 -0
- package/src/workspace-merge.ts +108 -0
package/src/routes.ts
CHANGED
|
@@ -85,8 +85,6 @@ import {
|
|
|
85
85
|
getWorkspace,
|
|
86
86
|
listWorkspaces,
|
|
87
87
|
updateWorkspaceStatus,
|
|
88
|
-
acquireMergeLease,
|
|
89
|
-
setMergeLeaseCommand,
|
|
90
88
|
releaseMergeLease,
|
|
91
89
|
listRepoStewards,
|
|
92
90
|
listMergeLeases,
|
|
@@ -107,11 +105,13 @@ import {
|
|
|
107
105
|
getAgentProfile,
|
|
108
106
|
getManagedAgentState,
|
|
109
107
|
getSpawnPolicy,
|
|
108
|
+
getStewardConfigEntry,
|
|
110
109
|
listAgentProfiles,
|
|
111
110
|
listSpawnPolicies,
|
|
112
111
|
listConfig,
|
|
113
112
|
setAgentProfile,
|
|
114
113
|
setConfig,
|
|
114
|
+
setStewardConfig,
|
|
115
115
|
upsertManagedAgentState,
|
|
116
116
|
updateManagedAgentState,
|
|
117
117
|
} from "./config-store";
|
|
@@ -140,7 +140,7 @@ import {
|
|
|
140
140
|
updateAutomation,
|
|
141
141
|
type AutomationDispatchResult,
|
|
142
142
|
} from "./automations";
|
|
143
|
-
import type { ActivityEventInput, ActivityKind, AgentCard, AgentKind, AgentProfile, AgentSessionGuard, ChannelBinding, ChannelBindingMode, ChannelDirection, ChannelRouteTarget, ChannelSummary, Command, CommandStatus, CreateCommandInput, CreatePairInput, IntegrationEventInput, IntegrationSummary, ManagedAgent, ManagedSessionExitDiagnostics, Message, OrchestratorRuntimeInput, PairActionInput, PairMessageInput, PairStatus, RegisterAgentInput, RegisterOrchestratorInput, SendMessageInput, SpawnApprovalMode, SpawnPolicy, SpawnProvider, TaskStatus, TaskStatusInput, WorkspaceMetadata, WorkspaceMode, WorkspaceOrphan, WorkspaceProbe, WorkspaceStatus } from "./types";
|
|
143
|
+
import type { ActivityEventInput, ActivityKind, AgentCard, AgentKind, AgentProfile, AgentSessionGuard, ChannelBinding, ChannelBindingMode, ChannelDirection, ChannelRouteTarget, ChannelSummary, Command, CommandStatus, CreateCommandInput, CreatePairInput, IntegrationEventInput, IntegrationSummary, ManagedAgent, ManagedSessionExitDiagnostics, Message, OrchestratorRuntimeInput, PairActionInput, PairMessageInput, PairStatus, RegisterAgentInput, RegisterOrchestratorInput, SendMessageInput, SpawnApprovalMode, SpawnPolicy, SpawnProvider, TaskStatus, TaskStatusInput, WorkspaceMergeStrategy, WorkspaceMetadata, WorkspaceMode, WorkspaceOrphan, WorkspaceProbe, WorkspaceStatus } from "./types";
|
|
144
144
|
import { getIntegrationTokens, INTEGRATION_RATE_LIMIT_PER_MINUTE, MAX_BODY_BYTES, VERSION, type IntegrationTokenConfig } from "./config";
|
|
145
145
|
import { CONTRACT_VERSIONS, parseRuntimeCapabilities, parseRuntimeContracts, parseRuntimePackage, type RuntimeCapabilities, type RuntimeContracts, type RuntimePackageMetadata } from "./contracts";
|
|
146
146
|
import { listHostDirectories } from "./agent-spawn";
|
|
@@ -149,6 +149,7 @@ import type { ProviderConfig } from "../runner/src/adapter";
|
|
|
149
149
|
import { resolveProviderSelection, type ProviderEffort } from "agent-relay-sdk/provider-catalog";
|
|
150
150
|
import { effectiveProviderCatalogList } from "./provider-catalog-store";
|
|
151
151
|
import { buildManagedSpawnParams, effectiveManagedPolicyWorkspaceMode } from "./managed-policy";
|
|
152
|
+
import { requestWorkspaceMerge } from "./workspace-merge";
|
|
152
153
|
import {
|
|
153
154
|
getComponentAuth,
|
|
154
155
|
getIntegrationAuth,
|
|
@@ -187,7 +188,7 @@ import { postMcp } from "./mcp";
|
|
|
187
188
|
import { readFileSync } from "node:fs";
|
|
188
189
|
import { isAbsolute, relative, resolve } from "node:path";
|
|
189
190
|
import type { ArtifactKind, ArtifactSensitivity, AttachmentRef, ContextBudget, CreateMemoryInput, MemoryBrokerContext, MemoryConfidence, MemoryQuery, MemoryRedactionState, MemorySensitivity, MemoryType, MemoryVisibility, TaskRoutingHints, TokenConstraints, UpdateMemoryInput } from "./types";
|
|
190
|
-
import { issueIntegrationRuntimeToken, issueInteractiveRunnerRuntimeToken, issueMcpRuntimeToken, issueOrchestratorRuntimeToken, runnerRuntimeTokenEnv } from "./runtime-tokens";
|
|
191
|
+
import { issueIntegrationRuntimeToken, issueInteractiveRunnerRuntimeToken, issueMcpRuntimeToken, issueOrchestratorRuntimeToken, reissueRunnerRuntimeToken, runnerRuntimeTokenEnv } from "./runtime-tokens";
|
|
191
192
|
import { listMaintenanceJobs, runLegacyMaintenanceReaper, runMaintenanceJobNow } from "./maintenance";
|
|
192
193
|
|
|
193
194
|
type Handler = (
|
|
@@ -319,7 +320,8 @@ const VALID_CHANNEL_BINDING_TARGET_TYPES = ["agent", "label", "tag", "capability
|
|
|
319
320
|
const VALID_WORKSPACE_MODES = ["isolated", "shared", "inherit"] as const;
|
|
320
321
|
const VALID_WORKSPACE_STATUSES = ["active", "ready", "conflict", "review_requested", "merge_planned", "merged", "abandoned", "cleanup_requested", "cleaned"] as const;
|
|
321
322
|
const VALID_CHANNEL_BINDING_MODES = ["exclusive", "broadcast"] as const;
|
|
322
|
-
const VALID_AGENT_ACTIONS = ["restart", "shutdown", "reconnect", "compact", "clearContext"] as const;
|
|
323
|
+
const VALID_AGENT_ACTIONS = ["restart", "shutdown", "reconnect", "compact", "clearContext", "resume", "interrupt"] as const;
|
|
324
|
+
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;
|
|
323
325
|
const VALID_AGENT_SPAWN_PROVIDERS = ["codex"] as const;
|
|
324
326
|
const VALID_CODEX_SPAWN_APPROVALS = ["open", "guarded", "read-only"] as const;
|
|
325
327
|
const VALID_PROVIDER_EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const;
|
|
@@ -1248,7 +1250,10 @@ function auditEvent(input: ActivityEventInput): void {
|
|
|
1248
1250
|
function auditCommandOutcome(command: Command): void {
|
|
1249
1251
|
if (command.status !== "succeeded" && command.status !== "failed") return;
|
|
1250
1252
|
if (command.type !== "agent.restart" && command.type !== "agent.shutdown" && command.type !== "agent.reconnect") return;
|
|
1251
|
-
const
|
|
1253
|
+
const paramAction = typeof command.params?.action === "string" && (VALID_AGENT_ACTIONS as readonly string[]).includes(command.params.action)
|
|
1254
|
+
? command.params.action as AgentControlAction
|
|
1255
|
+
: null;
|
|
1256
|
+
const action = paramAction ?? agentControlActionFromCommandType(command.type);
|
|
1252
1257
|
if (!action) return;
|
|
1253
1258
|
const agentId = typeof command.params?.agentId === "string" ? command.params.agentId : command.target;
|
|
1254
1259
|
const succeeded = command.status === "succeeded";
|
|
@@ -2142,11 +2147,12 @@ const deleteAgentById: Handler = (_req, params) => {
|
|
|
2142
2147
|
|
|
2143
2148
|
type AgentControlAction = (typeof VALID_AGENT_ACTIONS)[number];
|
|
2144
2149
|
|
|
2145
|
-
function agentControlActionCommandType(action: AgentControlAction): "agent.restart" | "agent.shutdown" | "agent.reconnect" | "agent.compact" | "agent.clearContext" {
|
|
2146
|
-
if (action === "restart") return "agent.restart";
|
|
2150
|
+
function agentControlActionCommandType(action: AgentControlAction): "agent.restart" | "agent.shutdown" | "agent.reconnect" | "agent.compact" | "agent.clearContext" | "agent.interrupt" {
|
|
2151
|
+
if (action === "restart" || action === "resume") return "agent.restart";
|
|
2147
2152
|
if (action === "shutdown") return "agent.shutdown";
|
|
2148
2153
|
if (action === "compact") return "agent.compact";
|
|
2149
2154
|
if (action === "clearContext") return "agent.clearContext";
|
|
2155
|
+
if (action === "interrupt") return "agent.interrupt";
|
|
2150
2156
|
return "agent.reconnect";
|
|
2151
2157
|
}
|
|
2152
2158
|
|
|
@@ -2156,22 +2162,27 @@ function agentControlActionFromCommandType(type: string): AgentControlAction | n
|
|
|
2156
2162
|
if (type === "agent.reconnect") return "reconnect";
|
|
2157
2163
|
if (type === "agent.compact") return "compact";
|
|
2158
2164
|
if (type === "agent.clearContext") return "clearContext";
|
|
2165
|
+
if (type === "agent.interrupt") return "interrupt";
|
|
2159
2166
|
return null;
|
|
2160
2167
|
}
|
|
2161
2168
|
|
|
2162
2169
|
function agentControlActionRequestedTitle(action: AgentControlAction): string {
|
|
2163
2170
|
if (action === "restart") return "Agent restart requested";
|
|
2171
|
+
if (action === "resume") return "Agent resume requested";
|
|
2164
2172
|
if (action === "shutdown") return "Agent shutdown requested";
|
|
2165
2173
|
if (action === "compact") return "Agent compaction requested";
|
|
2166
2174
|
if (action === "clearContext") return "Agent context clear requested";
|
|
2175
|
+
if (action === "interrupt") return "Agent interrupt requested";
|
|
2167
2176
|
return "Agent reconnect requested";
|
|
2168
2177
|
}
|
|
2169
2178
|
|
|
2170
2179
|
function agentControlActionCompletedTitle(action: AgentControlAction): string {
|
|
2171
2180
|
if (action === "restart") return "Agent restarted";
|
|
2181
|
+
if (action === "resume") return "Agent resumed";
|
|
2172
2182
|
if (action === "shutdown") return "Agent shut down";
|
|
2173
2183
|
if (action === "compact") return "Agent compacted";
|
|
2174
2184
|
if (action === "clearContext") return "Agent context cleared";
|
|
2185
|
+
if (action === "interrupt") return "Agent interrupted";
|
|
2175
2186
|
return "Agent reconnected";
|
|
2176
2187
|
}
|
|
2177
2188
|
|
|
@@ -2179,6 +2190,8 @@ function agentControlActionIcon(action: AgentControlAction): string {
|
|
|
2179
2190
|
if (action === "shutdown") return "ti-power";
|
|
2180
2191
|
if (action === "compact") return "ti-compress";
|
|
2181
2192
|
if (action === "clearContext") return "ti-eraser";
|
|
2193
|
+
if (action === "resume") return "ti-player-play";
|
|
2194
|
+
if (action === "interrupt") return "ti-player-stop";
|
|
2182
2195
|
return "ti-refresh";
|
|
2183
2196
|
}
|
|
2184
2197
|
|
|
@@ -2191,6 +2204,10 @@ function agentIsControlEligible(agent: AgentCard): boolean {
|
|
|
2191
2204
|
|
|
2192
2205
|
function agentCanReceiveControlAction(agent: AgentCard, action: AgentControlAction): boolean {
|
|
2193
2206
|
if (!agentIsControlEligible(agent)) return false;
|
|
2207
|
+
if (action === "resume") return agentRuntimeProvider(agent) === "claude" && (agent.status === "offline" || agent.status === "stale");
|
|
2208
|
+
// Interrupt only makes sense while the provider is mid-turn, and only if the
|
|
2209
|
+
// provider advertises it can be interrupted from the dashboard.
|
|
2210
|
+
if (action === "interrupt") return agent.providerCapabilities?.liveSession?.interrupt === true && agent.status === "busy";
|
|
2194
2211
|
const lifecycle = agent.providerCapabilities?.lifecycle;
|
|
2195
2212
|
if (lifecycle) {
|
|
2196
2213
|
if (action === "restart") return lifecycle.restartHard === true;
|
|
@@ -2202,6 +2219,10 @@ function agentCanReceiveControlAction(agent: AgentCard, action: AgentControlActi
|
|
|
2202
2219
|
return agent.meta?.runnerManaged === true && (action === "restart" || action === "shutdown");
|
|
2203
2220
|
}
|
|
2204
2221
|
|
|
2222
|
+
function agentRuntimeProvider(agent: AgentCard): string | undefined {
|
|
2223
|
+
return metaString(agent.meta, "provider") ?? agent.providerCapabilities?.model?.provider;
|
|
2224
|
+
}
|
|
2225
|
+
|
|
2205
2226
|
function managedControlOrchestrator(agent: AgentCard): NonNullable<ReturnType<typeof getOrchestrator>> | null {
|
|
2206
2227
|
if (agent.meta?.runnerManaged !== true) return null;
|
|
2207
2228
|
const metaSessionName = typeof agent.meta.sessionName === "string" ? agent.meta.sessionName : "";
|
|
@@ -2226,11 +2247,16 @@ function restartSpawnParamsForAgent(
|
|
|
2226
2247
|
orchestrator: NonNullable<ReturnType<typeof getOrchestrator>> | null,
|
|
2227
2248
|
policyName?: string,
|
|
2228
2249
|
spawnRequestId?: string,
|
|
2250
|
+
opts: { resumeId?: string } = {},
|
|
2229
2251
|
): Record<string, unknown> | undefined {
|
|
2230
2252
|
if (!orchestrator) return undefined;
|
|
2231
2253
|
const requestId = spawnRequestId ?? spawnRequestIdForRestart();
|
|
2232
2254
|
const policy = policyName ? getSpawnPolicy(policyName) : null;
|
|
2233
|
-
|
|
2255
|
+
const requestedBy = opts.resumeId ? "dashboard-resume" : "dashboard-restart";
|
|
2256
|
+
if (policy) {
|
|
2257
|
+
const params = { ...managedSpawnParams(policy.value, requestId), agentId: agent.id, requestedBy };
|
|
2258
|
+
return opts.resumeId ? withClaudeResumeParams(params, opts.resumeId, agent.id) : params;
|
|
2259
|
+
}
|
|
2234
2260
|
|
|
2235
2261
|
const provider = metaString(agent.meta, "provider");
|
|
2236
2262
|
if (provider !== "claude" && provider !== "codex") return undefined;
|
|
@@ -2259,7 +2285,7 @@ function restartSpawnParamsForAgent(
|
|
|
2259
2285
|
};
|
|
2260
2286
|
}
|
|
2261
2287
|
}
|
|
2262
|
-
|
|
2288
|
+
const params = {
|
|
2263
2289
|
action: "spawn",
|
|
2264
2290
|
provider,
|
|
2265
2291
|
...resolvedModel,
|
|
@@ -2284,17 +2310,57 @@ function restartSpawnParamsForAgent(
|
|
|
2284
2310
|
label,
|
|
2285
2311
|
policyName,
|
|
2286
2312
|
spawnRequestId: requestId,
|
|
2287
|
-
createdBy:
|
|
2313
|
+
createdBy: requestedBy,
|
|
2288
2314
|
}),
|
|
2289
|
-
requestedBy
|
|
2315
|
+
requestedBy,
|
|
2290
2316
|
requestedAt: Date.now(),
|
|
2291
2317
|
};
|
|
2318
|
+
return opts.resumeId ? withClaudeResumeParams(params, opts.resumeId, agent.id) : params;
|
|
2292
2319
|
}
|
|
2293
2320
|
|
|
2294
2321
|
function spawnRequestIdForRestart(): string {
|
|
2295
2322
|
return `sp_${crypto.randomUUID()}`;
|
|
2296
2323
|
}
|
|
2297
2324
|
|
|
2325
|
+
function withClaudeResumeParams(params: Record<string, unknown>, resumeId: string, agentId: string): Record<string, unknown> {
|
|
2326
|
+
return {
|
|
2327
|
+
...params,
|
|
2328
|
+
providerArgs: providerArgsWithClaudeResume(recordStringArray(params.providerArgs), resumeId),
|
|
2329
|
+
resumeOfAgentId: agentId,
|
|
2330
|
+
claudeResumeId: resumeId,
|
|
2331
|
+
};
|
|
2332
|
+
}
|
|
2333
|
+
|
|
2334
|
+
function providerArgsWithClaudeResume(args: string[], resumeId: string): string[] {
|
|
2335
|
+
const cleaned: string[] = [];
|
|
2336
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
2337
|
+
const arg = args[i];
|
|
2338
|
+
if (!arg) continue;
|
|
2339
|
+
if (arg === "--resume") {
|
|
2340
|
+
i += 1;
|
|
2341
|
+
continue;
|
|
2342
|
+
}
|
|
2343
|
+
if (arg.startsWith("--resume=")) continue;
|
|
2344
|
+
cleaned.push(arg);
|
|
2345
|
+
}
|
|
2346
|
+
return [...cleaned, "--resume", resumeId];
|
|
2347
|
+
}
|
|
2348
|
+
|
|
2349
|
+
function recordStringArray(value: unknown): string[] {
|
|
2350
|
+
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string" && item.trim().length > 0) : [];
|
|
2351
|
+
}
|
|
2352
|
+
|
|
2353
|
+
function latestClaudeResumeIdForAgent(agent: AgentCard): string | undefined {
|
|
2354
|
+
for (const entry of getAgentTimeline(agent.id, { limit: 50 })) {
|
|
2355
|
+
const metadata = entry.metadata;
|
|
2356
|
+
if (!metadata) continue;
|
|
2357
|
+
const provider = metaString(metadata, "provider");
|
|
2358
|
+
const resumeId = metaString(metadata, "claudeResumeId");
|
|
2359
|
+
if (provider === "claude" && resumeId && CLAUDE_RESUME_ID_RE.test(resumeId)) return resumeId;
|
|
2360
|
+
}
|
|
2361
|
+
return undefined;
|
|
2362
|
+
}
|
|
2363
|
+
|
|
2298
2364
|
function compactMemoryParams(agent: AgentCard): Record<string, unknown> {
|
|
2299
2365
|
const alwaysReload = agent.tags
|
|
2300
2366
|
.filter((tag) => tag.startsWith("memory-reload:"))
|
|
@@ -2315,11 +2381,14 @@ const postAgentAction: Handler = async (req, params) => {
|
|
|
2315
2381
|
if (!agent) return error("agent not found", 404);
|
|
2316
2382
|
if (!agentCanReceiveControlAction(agent, action)) return error(`agent does not support ${action}`, 400);
|
|
2317
2383
|
|
|
2318
|
-
const orchestrator = (action === "restart" || action === "shutdown") ? managedControlOrchestrator(agent) : null;
|
|
2384
|
+
const orchestrator = (action === "restart" || action === "shutdown" || action === "resume") ? managedControlOrchestrator(agent) : null;
|
|
2319
2385
|
const metaSessionName = typeof agent.meta?.sessionName === "string" ? agent.meta.sessionName : undefined;
|
|
2320
2386
|
const metaTmuxSession = typeof agent.meta?.tmuxSession === "string" ? agent.meta.tmuxSession : undefined;
|
|
2321
2387
|
const metaPolicyName = typeof agent.meta?.policyName === "string" ? agent.meta.policyName : undefined;
|
|
2322
2388
|
const metaSpawnRequestId = typeof agent.meta?.spawnRequestId === "string" ? agent.meta.spawnRequestId : undefined;
|
|
2389
|
+
const resumeId = action === "resume" ? latestClaudeResumeIdForAgent(agent) : undefined;
|
|
2390
|
+
if (action === "resume" && !orchestrator) return error("no online orchestrator available to resume agent", 409);
|
|
2391
|
+
if (action === "resume" && !resumeId) return error("no Claude resume id recorded for agent", 422);
|
|
2323
2392
|
const denied = authorizeRoute(req, {
|
|
2324
2393
|
scope: "agent:write",
|
|
2325
2394
|
resource: { agentId: agent.id, orchestratorId: orchestrator?.id, policyName: metaPolicyName, spawnRequestId: metaSpawnRequestId },
|
|
@@ -2338,14 +2407,15 @@ const postAgentAction: Handler = async (req, params) => {
|
|
|
2338
2407
|
...(metaTmuxSession ? { tmuxSession: metaTmuxSession } : {}),
|
|
2339
2408
|
...(metaPolicyName ? { policyName: metaPolicyName } : {}),
|
|
2340
2409
|
...(metaSpawnRequestId ? { spawnRequestId: metaSpawnRequestId } : {}),
|
|
2341
|
-
...(action === "restart" ? { restartSpawn: restartSpawnParamsForAgent(agent, orchestrator, metaPolicyName, metaSpawnRequestId) } : {}),
|
|
2410
|
+
...(action === "restart" || action === "resume" ? { restartSpawn: restartSpawnParamsForAgent(agent, orchestrator, metaPolicyName, metaSpawnRequestId, { resumeId }) } : {}),
|
|
2342
2411
|
...(action === "compact" ? compactMemoryParams(agent) : {}),
|
|
2412
|
+
...(resumeId ? { claudeResumeId: resumeId } : {}),
|
|
2343
2413
|
requestedBy: "dashboard",
|
|
2344
2414
|
requestedAt: Date.now(),
|
|
2345
2415
|
},
|
|
2346
2416
|
});
|
|
2347
|
-
if (action === "shutdown" || action === "restart") {
|
|
2348
|
-
const lifecycleAction = action === "shutdown" ? "shutting-down" : "restarting";
|
|
2417
|
+
if (action === "shutdown" || action === "restart" || action === "resume") {
|
|
2418
|
+
const lifecycleAction = action === "shutdown" ? "shutting-down" : action === "resume" ? "resuming" : "restarting";
|
|
2349
2419
|
markReady(agent.id, false);
|
|
2350
2420
|
mergeAgentMeta(agent.id, { lifecycleAction, lifecycleActionAt: Date.now(), lifecycleCommandId: command.id });
|
|
2351
2421
|
emitAgentStatus(agent.id);
|
|
@@ -2826,6 +2896,40 @@ const postRuntimeTokenRenew: Handler = async (req) => {
|
|
|
2826
2896
|
}, 201);
|
|
2827
2897
|
};
|
|
2828
2898
|
|
|
2899
|
+
// Orchestrator-mediated runner-token re-mint. A live runner whose runtime token
|
|
2900
|
+
// has expired (e.g. the relay was unreachable across the renewal window) cannot
|
|
2901
|
+
// self-renew — its dead token can't authenticate. Its orchestrator, which holds
|
|
2902
|
+
// a long-lived credential, proxies the runner's expired token here. We verify the
|
|
2903
|
+
// token is a genuine, non-revoked runner token owned by THIS orchestrator and
|
|
2904
|
+
// mint a fresh one cloning its scope, so the session self-heals without a restart.
|
|
2905
|
+
const postOrchestratorRunnerToken: Handler = async (req, params) => {
|
|
2906
|
+
const orch = getOrchestrator(params.id!);
|
|
2907
|
+
if (!orch) return error("orchestrator not found", 404);
|
|
2908
|
+
const denied = authorizeRoute(req, { scope: "command:write", resource: { orchestratorId: orch.id } });
|
|
2909
|
+
if (denied) return denied;
|
|
2910
|
+
const parsed = await parseBody<unknown>(req);
|
|
2911
|
+
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
2912
|
+
if (!isRecord(parsed.body)) return error("body required");
|
|
2913
|
+
const token = cleanString(parsed.body.token, "token", { required: true, max: 8192 })!;
|
|
2914
|
+
const result = reissueRunnerRuntimeToken({
|
|
2915
|
+
expiredToken: token,
|
|
2916
|
+
orchestratorId: orch.id,
|
|
2917
|
+
createdBy: `orchestrator-remint:${orch.id}`,
|
|
2918
|
+
});
|
|
2919
|
+
if ("error" in result) return error(result.error, 403);
|
|
2920
|
+
auditEvent({
|
|
2921
|
+
clientId: "server-runner-token-remint-" + result.record.jti + "-" + Date.now(),
|
|
2922
|
+
kind: "state",
|
|
2923
|
+
title: "Runner token re-minted",
|
|
2924
|
+
body: result.record.sub,
|
|
2925
|
+
meta: result.record.jti,
|
|
2926
|
+
icon: "ti-key",
|
|
2927
|
+
view: "security",
|
|
2928
|
+
metadata: { orchestratorId: orch.id, jti: result.record.jti, sub: result.record.sub, ...authAuditMetadata(req) },
|
|
2929
|
+
});
|
|
2930
|
+
return json(result, 201);
|
|
2931
|
+
};
|
|
2932
|
+
|
|
2829
2933
|
const postInteractiveRunnerRuntimeToken: Handler = async (req) => {
|
|
2830
2934
|
if (!isRootCredentialRequest(req)) return error("root credential required for runtime token exchange", 403);
|
|
2831
2935
|
const parsed = await parseBody<unknown>(req);
|
|
@@ -3940,7 +4044,6 @@ const postWorkspaceOrphanReclaim: Handler = async (req) => {
|
|
|
3940
4044
|
const postWorkspaceAction: Handler = async (req, params) => {
|
|
3941
4045
|
const parsed = await parseBody<unknown>(req);
|
|
3942
4046
|
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
3943
|
-
let mergeLeaseRepo: string | undefined;
|
|
3944
4047
|
try {
|
|
3945
4048
|
if (!isRecord(parsed.body)) return error("body required");
|
|
3946
4049
|
const workspace = getWorkspace(params.id!);
|
|
@@ -3959,17 +4062,32 @@ const postWorkspaceAction: Handler = async (req, params) => {
|
|
|
3959
4062
|
const denied = authorizeRoute(req, { scope: requiresCommand ? "command:write" : "agent:write", resource: { agentId, cwd: workspace.worktreePath } });
|
|
3960
4063
|
if (denied) return denied;
|
|
3961
4064
|
if (action === "status") return json(workspace);
|
|
3962
|
-
//
|
|
3963
|
-
//
|
|
4065
|
+
// Base merges go through the shared helper (lease + command + bind), the same
|
|
4066
|
+
// path the auto-merge job uses, so both serialize per repo (issue #157).
|
|
3964
4067
|
if (action === "merge") {
|
|
3965
|
-
const
|
|
3966
|
-
|
|
3967
|
-
|
|
3968
|
-
|
|
3969
|
-
|
|
3970
|
-
)
|
|
3971
|
-
|
|
3972
|
-
|
|
4068
|
+
const strategy = cleanEnum(parsed.body.strategy, "strategy", ["pr", "rebase-ff", "auto"] as const, "auto") as WorkspaceMergeStrategy;
|
|
4069
|
+
const result = requestWorkspaceMerge(workspace, {
|
|
4070
|
+
requestedBy: agentId ?? "dashboard",
|
|
4071
|
+
strategy,
|
|
4072
|
+
deleteBranch: parsed.body.deleteBranch !== false,
|
|
4073
|
+
prTitle: cleanString(parsed.body.prTitle, "prTitle", { max: 240 }),
|
|
4074
|
+
prBody: cleanString(parsed.body.prBody, "prBody", { max: 8000 }),
|
|
4075
|
+
metadata: { ...metadata, ...(detail ? { detail } : {}), ...(agentId ? { updatedByAgentId: agentId } : {}) },
|
|
4076
|
+
});
|
|
4077
|
+
if (!result.ok) return error(result.error, result.status);
|
|
4078
|
+
emitCommand(result.command);
|
|
4079
|
+
auditEvent({
|
|
4080
|
+
clientId: `workspace-merge-${workspace.id}-${Date.now()}`,
|
|
4081
|
+
kind: "state",
|
|
4082
|
+
title: "Workspace merge",
|
|
4083
|
+
body: detail ?? workspace.worktreePath,
|
|
4084
|
+
meta: workspace.branch ?? workspace.id,
|
|
4085
|
+
icon: "ti-git-merge",
|
|
4086
|
+
view: "orchestrators",
|
|
4087
|
+
agentId,
|
|
4088
|
+
metadata: { action: "merge", workspaceId: workspace.id, repoRoot: workspace.repoRoot, worktreePath: workspace.worktreePath, status: result.workspace.status, commandId: result.command.id, ...authAuditMetadata(req) },
|
|
4089
|
+
});
|
|
4090
|
+
return json({ workspace: result.workspace, command: result.command }, 202);
|
|
3973
4091
|
}
|
|
3974
4092
|
const statusByAction: Record<string, WorkspaceStatus | undefined> = {
|
|
3975
4093
|
status: undefined,
|
|
@@ -3977,7 +4095,6 @@ const postWorkspaceAction: Handler = async (req, params) => {
|
|
|
3977
4095
|
"conflict-found": "conflict",
|
|
3978
4096
|
"request-review": "review_requested",
|
|
3979
4097
|
"merge-plan": "merge_planned",
|
|
3980
|
-
merge: "merge_planned",
|
|
3981
4098
|
abandon: "abandoned",
|
|
3982
4099
|
cleanup: "cleanup_requested",
|
|
3983
4100
|
};
|
|
@@ -3991,58 +4108,31 @@ const postWorkspaceAction: Handler = async (req, params) => {
|
|
|
3991
4108
|
if (!updated) return error("workspace not found", 404);
|
|
3992
4109
|
let command: Command | undefined;
|
|
3993
4110
|
if (requiresCommand) {
|
|
3994
|
-
//
|
|
4111
|
+
// Only `cleanup` reaches here — `merge` returned early via the shared helper.
|
|
4112
|
+
// Cleanup may queue: if the owning orchestrator is offline, the command (no
|
|
4113
|
+
// TTL) waits and reconciles when it reconnects. Only hard-fail when no
|
|
4114
|
+
// orchestrator owns the path at all — then DELETE is the escape.
|
|
3995
4115
|
const owners = listOrchestrators().filter((candidate) => pathWithinBase(workspace.sourceCwd, candidate.baseDir));
|
|
3996
4116
|
const onlineOwner = owners.find((candidate) => candidate.status === "online");
|
|
3997
|
-
const
|
|
3998
|
-
|
|
3999
|
-
|
|
4000
|
-
|
|
4001
|
-
|
|
4002
|
-
|
|
4003
|
-
|
|
4004
|
-
|
|
4005
|
-
|
|
4006
|
-
|
|
4007
|
-
|
|
4008
|
-
|
|
4009
|
-
|
|
4010
|
-
|
|
4011
|
-
|
|
4012
|
-
|
|
4013
|
-
|
|
4014
|
-
|
|
4015
|
-
|
|
4016
|
-
target: onlineOwner.agentId,
|
|
4017
|
-
correlationId: workspace.id,
|
|
4018
|
-
params: {
|
|
4019
|
-
action: "merge",
|
|
4020
|
-
...baseParams,
|
|
4021
|
-
baseRef: workspace.baseRef,
|
|
4022
|
-
baseSha: workspace.baseSha,
|
|
4023
|
-
strategy,
|
|
4024
|
-
deleteBranch: parsed.body.deleteBranch !== false,
|
|
4025
|
-
prTitle: cleanString(parsed.body.prTitle, "prTitle", { max: 240 }),
|
|
4026
|
-
prBody: cleanString(parsed.body.prBody, "prBody", { max: 8000 }),
|
|
4027
|
-
},
|
|
4028
|
-
});
|
|
4029
|
-
} else {
|
|
4030
|
-
// Cleanup may queue: if the owning orchestrator is offline, the command
|
|
4031
|
-
// (no TTL) waits and reconciles when it reconnects. Only hard-fail when
|
|
4032
|
-
// no orchestrator owns the path at all — then DELETE is the escape.
|
|
4033
|
-
const owner = onlineOwner ?? owners[0];
|
|
4034
|
-
if (!owner) return error("no orchestrator owns this workspace path; use DELETE /api/workspaces/:id to purge the record", 409);
|
|
4035
|
-
command = createCommand({
|
|
4036
|
-
type: "workspace.cleanup",
|
|
4037
|
-
source: "system",
|
|
4038
|
-
target: owner.agentId,
|
|
4039
|
-
correlationId: workspace.id,
|
|
4040
|
-
params: { action: "cleanup", ...baseParams, deleteBranch: true, queued: owner.status !== "online" },
|
|
4041
|
-
});
|
|
4042
|
-
}
|
|
4043
|
-
// Bind the lease to the dispatched merge command so it's released by id
|
|
4044
|
-
// when the command settles (postCommandResult).
|
|
4045
|
-
if (action === "merge" && mergeLeaseRepo) setMergeLeaseCommand(mergeLeaseRepo, command.id);
|
|
4117
|
+
const owner = onlineOwner ?? owners[0];
|
|
4118
|
+
if (!owner) return error("no orchestrator owns this workspace path; use DELETE /api/workspaces/:id to purge the record", 409);
|
|
4119
|
+
command = createCommand({
|
|
4120
|
+
type: "workspace.cleanup",
|
|
4121
|
+
source: "system",
|
|
4122
|
+
target: owner.agentId,
|
|
4123
|
+
correlationId: workspace.id,
|
|
4124
|
+
params: {
|
|
4125
|
+
action: "cleanup",
|
|
4126
|
+
workspaceId: workspace.id,
|
|
4127
|
+
repoRoot: workspace.repoRoot,
|
|
4128
|
+
worktreePath: workspace.worktreePath,
|
|
4129
|
+
branch: workspace.branch,
|
|
4130
|
+
requestedBy: agentId ?? "dashboard",
|
|
4131
|
+
requestedAt: Date.now(),
|
|
4132
|
+
deleteBranch: true,
|
|
4133
|
+
queued: owner.status !== "online",
|
|
4134
|
+
},
|
|
4135
|
+
});
|
|
4046
4136
|
emitCommand(command);
|
|
4047
4137
|
}
|
|
4048
4138
|
auditEvent({
|
|
@@ -4051,16 +4141,13 @@ const postWorkspaceAction: Handler = async (req, params) => {
|
|
|
4051
4141
|
title: `Workspace ${action}`,
|
|
4052
4142
|
body: detail ?? workspace.worktreePath,
|
|
4053
4143
|
meta: workspace.branch ?? workspace.id,
|
|
4054
|
-
icon: action === "cleanup" ? "ti-trash" : action === "
|
|
4144
|
+
icon: action === "cleanup" ? "ti-trash" : action === "conflict-found" ? "ti-alert-triangle" : "ti-git-branch",
|
|
4055
4145
|
view: "orchestrators",
|
|
4056
4146
|
agentId,
|
|
4057
4147
|
metadata: { action, workspaceId: workspace.id, repoRoot: workspace.repoRoot, worktreePath: workspace.worktreePath, status: updated.status, commandId: command?.id, ...authAuditMetadata(req) },
|
|
4058
4148
|
});
|
|
4059
4149
|
return json({ workspace: updated, command }, requiresCommand ? 202 : 200);
|
|
4060
4150
|
} catch (e) {
|
|
4061
|
-
// A merge that acquired the lease but failed before dispatch must release it,
|
|
4062
|
-
// or the repo stays blocked until the TTL expires.
|
|
4063
|
-
if (mergeLeaseRepo) releaseMergeLease({ repoRoot: mergeLeaseRepo });
|
|
4064
4151
|
if (e instanceof ValidationError) return error(e.message, 400);
|
|
4065
4152
|
throw e;
|
|
4066
4153
|
}
|
|
@@ -4397,16 +4484,35 @@ const patchCommand: Handler = async (req, params) => {
|
|
|
4397
4484
|
if (command.type === "workspace.reconcile" && command.status === "succeeded" && isRecord(command.result)) {
|
|
4398
4485
|
const workspaceId = cleanString(command.result.workspaceId, "result.workspaceId", { max: 160 });
|
|
4399
4486
|
const resultStatus = cleanEnum(command.result.status, "result.status", VALID_WORKSPACE_STATUSES) as WorkspaceStatus | undefined;
|
|
4487
|
+
const removed = command.result.removed === true;
|
|
4400
4488
|
if (workspaceId && resultStatus) {
|
|
4401
4489
|
// Only act on workspaces the agent left in a live state; never overwrite
|
|
4402
4490
|
// a status a human/agent has since moved on (merge_planned, abandoned, …).
|
|
4403
4491
|
const current = getWorkspace(workspaceId);
|
|
4404
4492
|
if (current && (current.status === "active" || current.status === "ready")) {
|
|
4405
|
-
|
|
4406
|
-
|
|
4407
|
-
|
|
4408
|
-
|
|
4409
|
-
|
|
4493
|
+
if (removed) {
|
|
4494
|
+
// The owner exited and the worktree had no work — the orchestrator
|
|
4495
|
+
// already deleted it on disk. Drop the DB row immediately instead of
|
|
4496
|
+
// parking it at `cleaned` for 24h: a no-change session is pure junk
|
|
4497
|
+
// from the user's view, so it should leave the Workspaces panel now.
|
|
4498
|
+
deleteWorkspace(workspaceId);
|
|
4499
|
+
auditEvent({
|
|
4500
|
+
clientId: `workspace-reconcile-removed-${workspaceId}-${Date.now()}`,
|
|
4501
|
+
kind: "state",
|
|
4502
|
+
title: "Workspace removed (no changes)",
|
|
4503
|
+
body: current.worktreePath,
|
|
4504
|
+
meta: current.branch ?? workspaceId,
|
|
4505
|
+
icon: "ti-trash",
|
|
4506
|
+
view: "orchestrators",
|
|
4507
|
+
metadata: { reconcileCommandId: command.id, workspaceId, repoRoot: current.repoRoot },
|
|
4508
|
+
});
|
|
4509
|
+
} else {
|
|
4510
|
+
updateWorkspaceStatus(workspaceId, resultStatus, {
|
|
4511
|
+
reconcileResult: command.result,
|
|
4512
|
+
reconcileCommandId: command.id,
|
|
4513
|
+
reconciledAt: Date.now(),
|
|
4514
|
+
});
|
|
4515
|
+
}
|
|
4410
4516
|
}
|
|
4411
4517
|
}
|
|
4412
4518
|
}
|
|
@@ -4469,6 +4575,27 @@ const deleteAgentProfileRoute: Handler = (req, params) => {
|
|
|
4469
4575
|
}
|
|
4470
4576
|
};
|
|
4471
4577
|
|
|
4578
|
+
// --- Steward config (global, provider-independent — issue #167) ---
|
|
4579
|
+
|
|
4580
|
+
const getStewardConfigRoute: Handler = () => json(getStewardConfigEntry());
|
|
4581
|
+
|
|
4582
|
+
const putStewardConfigRoute: Handler = async (req) => {
|
|
4583
|
+
const parsed = await parseBody<unknown>(req);
|
|
4584
|
+
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
4585
|
+
try {
|
|
4586
|
+
const value = isRecord(parsed.body) && Object.prototype.hasOwnProperty.call(parsed.body, "value")
|
|
4587
|
+
? parsed.body.value
|
|
4588
|
+
: parsed.body;
|
|
4589
|
+
const updatedBy = isRecord(parsed.body) ? cleanString(parsed.body.updatedBy, "updatedBy", { max: 200 }) : undefined;
|
|
4590
|
+
const entry = setStewardConfig(value, updatedBy);
|
|
4591
|
+
emitConfigChanged(entry.namespace, entry.key, entry.version);
|
|
4592
|
+
return json(entry, entry.version === 1 ? 201 : 200);
|
|
4593
|
+
} catch (e) {
|
|
4594
|
+
if (e instanceof ValidationError) return error(e.message, 400);
|
|
4595
|
+
throw e;
|
|
4596
|
+
}
|
|
4597
|
+
};
|
|
4598
|
+
|
|
4472
4599
|
// --- Config routes ---
|
|
4473
4600
|
|
|
4474
4601
|
function normalizeConfigPathParam(raw: string | undefined, field: string): string {
|
|
@@ -6276,6 +6403,7 @@ const routes: Route[] = [
|
|
|
6276
6403
|
route("POST", "/api/orchestrators/:id/heartbeat", postOrchestratorHeartbeat),
|
|
6277
6404
|
route("PATCH", "/api/orchestrators/:id/agents", patchOrchestratorAgents),
|
|
6278
6405
|
route("POST", "/api/orchestrators/:id/spawn", postOrchestratorSpawn),
|
|
6406
|
+
route("POST", "/api/orchestrators/:id/runner-token", postOrchestratorRunnerToken),
|
|
6279
6407
|
route("POST", "/api/orchestrators/:id/actions", postOrchestratorAction),
|
|
6280
6408
|
route("GET", "/api/orchestrators/:id/directories", getOrchestratorDirectories),
|
|
6281
6409
|
route("POST", "/api/orchestrators/:id/directories", postOrchestratorCreateDirectory),
|
|
@@ -6334,6 +6462,8 @@ const routes: Route[] = [
|
|
|
6334
6462
|
route("GET", "/api/agent-profiles/:name", getAgentProfileRoute),
|
|
6335
6463
|
route("PUT", "/api/agent-profiles/:name", putAgentProfileRoute),
|
|
6336
6464
|
route("DELETE", "/api/agent-profiles/:name", deleteAgentProfileRoute),
|
|
6465
|
+
route("GET", "/api/steward-config", getStewardConfigRoute),
|
|
6466
|
+
route("PUT", "/api/steward-config", putStewardConfigRoute),
|
|
6337
6467
|
route("GET", "/api/config/:namespace", getConfigNamespace),
|
|
6338
6468
|
route("GET", "/api/config/:namespace/:key/history", getConfigKeyHistory),
|
|
6339
6469
|
route("GET", "/api/config/:namespace/:key", getConfigKey),
|
package/src/runtime-tokens.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { createToken } from "./token-db";
|
|
1
|
+
import { createToken, revokeToken } from "./token-db";
|
|
2
|
+
import { verifyComponentTokenAllowExpired } from "./security";
|
|
2
3
|
import type { TokenRecord } from "./types";
|
|
3
4
|
|
|
4
5
|
interface RuntimeTokenResult {
|
|
@@ -6,6 +7,48 @@ interface RuntimeTokenResult {
|
|
|
6
7
|
record: TokenRecord;
|
|
7
8
|
}
|
|
8
9
|
|
|
10
|
+
// How long after expiry a runner token may still be re-minted. Bounds replay of
|
|
11
|
+
// a long-dead token to a sane window — a live session re-mints well within this.
|
|
12
|
+
const REMINT_MAX_EXPIRED_AGE_SECONDS = 30 * 24 * 60 * 60; // 30 days
|
|
13
|
+
|
|
14
|
+
// Orchestrator-mediated re-mint: given a runner's current (possibly expired) token
|
|
15
|
+
// and the calling orchestrator's id, verify the token belongs to a runner of THIS
|
|
16
|
+
// orchestrator and issue a fresh provider-agent token cloning its scope. The old
|
|
17
|
+
// token is revoked. The caller MUST already be authenticated/authorized as the
|
|
18
|
+
// orchestrator — this only establishes that the presented token is a genuine,
|
|
19
|
+
// non-revoked runner token owned by that orchestrator. See approach #1 in the
|
|
20
|
+
// runner self-heal design: the relay stays strict, the orchestrator's standing
|
|
21
|
+
// privilege is the authorization, the signed token is the identity.
|
|
22
|
+
export function reissueRunnerRuntimeToken(input: {
|
|
23
|
+
expiredToken: string;
|
|
24
|
+
orchestratorId: string;
|
|
25
|
+
createdBy?: string;
|
|
26
|
+
}): RuntimeTokenResult | { error: string } {
|
|
27
|
+
const payload = verifyComponentTokenAllowExpired(input.expiredToken);
|
|
28
|
+
if (!payload) return { error: "invalid or revoked runner token" };
|
|
29
|
+
if (payload.role !== "provider" || !payload.sub.startsWith("runner:")) {
|
|
30
|
+
return { error: "not a runner token" };
|
|
31
|
+
}
|
|
32
|
+
const orchestrators = payload.constraints?.orchestrators;
|
|
33
|
+
if (!Array.isArray(orchestrators) || !orchestrators.includes(input.orchestratorId)) {
|
|
34
|
+
return { error: "runner token not owned by this orchestrator" };
|
|
35
|
+
}
|
|
36
|
+
if (payload.exp !== undefined) {
|
|
37
|
+
const ageSeconds = Math.floor(Date.now() / 1000) - payload.exp;
|
|
38
|
+
if (ageSeconds > REMINT_MAX_EXPIRED_AGE_SECONDS) return { error: "runner token expired too long ago" };
|
|
39
|
+
}
|
|
40
|
+
const reissued = createToken({
|
|
41
|
+
profileId: "provider-agent",
|
|
42
|
+
sub: payload.sub,
|
|
43
|
+
role: "provider",
|
|
44
|
+
scope: payload.scope,
|
|
45
|
+
constraints: payload.constraints,
|
|
46
|
+
createdBy: input.createdBy ?? `remint:${payload.jti ?? "unknown"}`,
|
|
47
|
+
});
|
|
48
|
+
if (payload.jti) revokeToken(payload.jti);
|
|
49
|
+
return reissued;
|
|
50
|
+
}
|
|
51
|
+
|
|
9
52
|
export function issueOrchestratorRuntimeToken(input: {
|
|
10
53
|
orchestratorId: string;
|
|
11
54
|
baseDir: string;
|
package/src/security.ts
CHANGED
|
@@ -294,6 +294,23 @@ export function verifyComponentToken(token: string, nowSeconds = Math.floor(Date
|
|
|
294
294
|
return payload;
|
|
295
295
|
}
|
|
296
296
|
|
|
297
|
+
// Verify a component token's signature and revocation WITHOUT enforcing expiry.
|
|
298
|
+
// Used only for orchestrator-mediated runner-token re-minting: an expired runner
|
|
299
|
+
// token is unforgeable proof of identity (it is HMAC-signed by this relay), even
|
|
300
|
+
// though it can no longer authenticate a request. The caller (an authenticated
|
|
301
|
+
// orchestrator) supplies the authorization; this just establishes which runner.
|
|
302
|
+
// Revoked tokens are still rejected — revocation is a hard kill.
|
|
303
|
+
export function verifyComponentTokenAllowExpired(token: string): ComponentToken | null {
|
|
304
|
+
const parts = token.split(".");
|
|
305
|
+
if (parts.length !== 3) return null;
|
|
306
|
+
const [headerRaw, payloadRaw, signature] = parts as [string, string, string];
|
|
307
|
+
if (!safeEqual(signature, hmac(`${headerRaw}.${payloadRaw}`))) return null;
|
|
308
|
+
const payload = parseBase64urlJson(payloadRaw);
|
|
309
|
+
if (!isComponentToken(payload)) return null;
|
|
310
|
+
if (payload.jti && isTokenRevoked(payload.jti)) return null;
|
|
311
|
+
return payload;
|
|
312
|
+
}
|
|
313
|
+
|
|
297
314
|
export function forbidden(req: Request): Response {
|
|
298
315
|
return applyCors(req, Response.json({ error: "forbidden" }, { status: 403 }));
|
|
299
316
|
}
|