agent-relay-server 0.11.8 → 0.11.9
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 +6012 -1098
- package/runner/src/adapter.ts +4 -0
- package/src/bus.ts +42 -0
- package/src/config-store.ts +58 -0
- package/src/maintenance.ts +281 -6
- package/src/routes.ts +208 -86
- 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"] 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";
|
|
@@ -2143,7 +2148,7 @@ const deleteAgentById: Handler = (_req, params) => {
|
|
|
2143
2148
|
type AgentControlAction = (typeof VALID_AGENT_ACTIONS)[number];
|
|
2144
2149
|
|
|
2145
2150
|
function agentControlActionCommandType(action: AgentControlAction): "agent.restart" | "agent.shutdown" | "agent.reconnect" | "agent.compact" | "agent.clearContext" {
|
|
2146
|
-
if (action === "restart") return "agent.restart";
|
|
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";
|
|
@@ -2161,6 +2166,7 @@ function agentControlActionFromCommandType(type: string): AgentControlAction | n
|
|
|
2161
2166
|
|
|
2162
2167
|
function agentControlActionRequestedTitle(action: AgentControlAction): string {
|
|
2163
2168
|
if (action === "restart") return "Agent restart requested";
|
|
2169
|
+
if (action === "resume") return "Agent resume requested";
|
|
2164
2170
|
if (action === "shutdown") return "Agent shutdown requested";
|
|
2165
2171
|
if (action === "compact") return "Agent compaction requested";
|
|
2166
2172
|
if (action === "clearContext") return "Agent context clear requested";
|
|
@@ -2169,6 +2175,7 @@ function agentControlActionRequestedTitle(action: AgentControlAction): string {
|
|
|
2169
2175
|
|
|
2170
2176
|
function agentControlActionCompletedTitle(action: AgentControlAction): string {
|
|
2171
2177
|
if (action === "restart") return "Agent restarted";
|
|
2178
|
+
if (action === "resume") return "Agent resumed";
|
|
2172
2179
|
if (action === "shutdown") return "Agent shut down";
|
|
2173
2180
|
if (action === "compact") return "Agent compacted";
|
|
2174
2181
|
if (action === "clearContext") return "Agent context cleared";
|
|
@@ -2179,6 +2186,7 @@ function agentControlActionIcon(action: AgentControlAction): string {
|
|
|
2179
2186
|
if (action === "shutdown") return "ti-power";
|
|
2180
2187
|
if (action === "compact") return "ti-compress";
|
|
2181
2188
|
if (action === "clearContext") return "ti-eraser";
|
|
2189
|
+
if (action === "resume") return "ti-player-play";
|
|
2182
2190
|
return "ti-refresh";
|
|
2183
2191
|
}
|
|
2184
2192
|
|
|
@@ -2191,6 +2199,7 @@ function agentIsControlEligible(agent: AgentCard): boolean {
|
|
|
2191
2199
|
|
|
2192
2200
|
function agentCanReceiveControlAction(agent: AgentCard, action: AgentControlAction): boolean {
|
|
2193
2201
|
if (!agentIsControlEligible(agent)) return false;
|
|
2202
|
+
if (action === "resume") return agentRuntimeProvider(agent) === "claude" && (agent.status === "offline" || agent.status === "stale");
|
|
2194
2203
|
const lifecycle = agent.providerCapabilities?.lifecycle;
|
|
2195
2204
|
if (lifecycle) {
|
|
2196
2205
|
if (action === "restart") return lifecycle.restartHard === true;
|
|
@@ -2202,6 +2211,10 @@ function agentCanReceiveControlAction(agent: AgentCard, action: AgentControlActi
|
|
|
2202
2211
|
return agent.meta?.runnerManaged === true && (action === "restart" || action === "shutdown");
|
|
2203
2212
|
}
|
|
2204
2213
|
|
|
2214
|
+
function agentRuntimeProvider(agent: AgentCard): string | undefined {
|
|
2215
|
+
return metaString(agent.meta, "provider") ?? agent.providerCapabilities?.model?.provider;
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2205
2218
|
function managedControlOrchestrator(agent: AgentCard): NonNullable<ReturnType<typeof getOrchestrator>> | null {
|
|
2206
2219
|
if (agent.meta?.runnerManaged !== true) return null;
|
|
2207
2220
|
const metaSessionName = typeof agent.meta.sessionName === "string" ? agent.meta.sessionName : "";
|
|
@@ -2226,11 +2239,16 @@ function restartSpawnParamsForAgent(
|
|
|
2226
2239
|
orchestrator: NonNullable<ReturnType<typeof getOrchestrator>> | null,
|
|
2227
2240
|
policyName?: string,
|
|
2228
2241
|
spawnRequestId?: string,
|
|
2242
|
+
opts: { resumeId?: string } = {},
|
|
2229
2243
|
): Record<string, unknown> | undefined {
|
|
2230
2244
|
if (!orchestrator) return undefined;
|
|
2231
2245
|
const requestId = spawnRequestId ?? spawnRequestIdForRestart();
|
|
2232
2246
|
const policy = policyName ? getSpawnPolicy(policyName) : null;
|
|
2233
|
-
|
|
2247
|
+
const requestedBy = opts.resumeId ? "dashboard-resume" : "dashboard-restart";
|
|
2248
|
+
if (policy) {
|
|
2249
|
+
const params = { ...managedSpawnParams(policy.value, requestId), agentId: agent.id, requestedBy };
|
|
2250
|
+
return opts.resumeId ? withClaudeResumeParams(params, opts.resumeId, agent.id) : params;
|
|
2251
|
+
}
|
|
2234
2252
|
|
|
2235
2253
|
const provider = metaString(agent.meta, "provider");
|
|
2236
2254
|
if (provider !== "claude" && provider !== "codex") return undefined;
|
|
@@ -2259,7 +2277,7 @@ function restartSpawnParamsForAgent(
|
|
|
2259
2277
|
};
|
|
2260
2278
|
}
|
|
2261
2279
|
}
|
|
2262
|
-
|
|
2280
|
+
const params = {
|
|
2263
2281
|
action: "spawn",
|
|
2264
2282
|
provider,
|
|
2265
2283
|
...resolvedModel,
|
|
@@ -2284,17 +2302,57 @@ function restartSpawnParamsForAgent(
|
|
|
2284
2302
|
label,
|
|
2285
2303
|
policyName,
|
|
2286
2304
|
spawnRequestId: requestId,
|
|
2287
|
-
createdBy:
|
|
2305
|
+
createdBy: requestedBy,
|
|
2288
2306
|
}),
|
|
2289
|
-
requestedBy
|
|
2307
|
+
requestedBy,
|
|
2290
2308
|
requestedAt: Date.now(),
|
|
2291
2309
|
};
|
|
2310
|
+
return opts.resumeId ? withClaudeResumeParams(params, opts.resumeId, agent.id) : params;
|
|
2292
2311
|
}
|
|
2293
2312
|
|
|
2294
2313
|
function spawnRequestIdForRestart(): string {
|
|
2295
2314
|
return `sp_${crypto.randomUUID()}`;
|
|
2296
2315
|
}
|
|
2297
2316
|
|
|
2317
|
+
function withClaudeResumeParams(params: Record<string, unknown>, resumeId: string, agentId: string): Record<string, unknown> {
|
|
2318
|
+
return {
|
|
2319
|
+
...params,
|
|
2320
|
+
providerArgs: providerArgsWithClaudeResume(recordStringArray(params.providerArgs), resumeId),
|
|
2321
|
+
resumeOfAgentId: agentId,
|
|
2322
|
+
claudeResumeId: resumeId,
|
|
2323
|
+
};
|
|
2324
|
+
}
|
|
2325
|
+
|
|
2326
|
+
function providerArgsWithClaudeResume(args: string[], resumeId: string): string[] {
|
|
2327
|
+
const cleaned: string[] = [];
|
|
2328
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
2329
|
+
const arg = args[i];
|
|
2330
|
+
if (!arg) continue;
|
|
2331
|
+
if (arg === "--resume") {
|
|
2332
|
+
i += 1;
|
|
2333
|
+
continue;
|
|
2334
|
+
}
|
|
2335
|
+
if (arg.startsWith("--resume=")) continue;
|
|
2336
|
+
cleaned.push(arg);
|
|
2337
|
+
}
|
|
2338
|
+
return [...cleaned, "--resume", resumeId];
|
|
2339
|
+
}
|
|
2340
|
+
|
|
2341
|
+
function recordStringArray(value: unknown): string[] {
|
|
2342
|
+
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string" && item.trim().length > 0) : [];
|
|
2343
|
+
}
|
|
2344
|
+
|
|
2345
|
+
function latestClaudeResumeIdForAgent(agent: AgentCard): string | undefined {
|
|
2346
|
+
for (const entry of getAgentTimeline(agent.id, { limit: 50 })) {
|
|
2347
|
+
const metadata = entry.metadata;
|
|
2348
|
+
if (!metadata) continue;
|
|
2349
|
+
const provider = metaString(metadata, "provider");
|
|
2350
|
+
const resumeId = metaString(metadata, "claudeResumeId");
|
|
2351
|
+
if (provider === "claude" && resumeId && CLAUDE_RESUME_ID_RE.test(resumeId)) return resumeId;
|
|
2352
|
+
}
|
|
2353
|
+
return undefined;
|
|
2354
|
+
}
|
|
2355
|
+
|
|
2298
2356
|
function compactMemoryParams(agent: AgentCard): Record<string, unknown> {
|
|
2299
2357
|
const alwaysReload = agent.tags
|
|
2300
2358
|
.filter((tag) => tag.startsWith("memory-reload:"))
|
|
@@ -2315,11 +2373,14 @@ const postAgentAction: Handler = async (req, params) => {
|
|
|
2315
2373
|
if (!agent) return error("agent not found", 404);
|
|
2316
2374
|
if (!agentCanReceiveControlAction(agent, action)) return error(`agent does not support ${action}`, 400);
|
|
2317
2375
|
|
|
2318
|
-
const orchestrator = (action === "restart" || action === "shutdown") ? managedControlOrchestrator(agent) : null;
|
|
2376
|
+
const orchestrator = (action === "restart" || action === "shutdown" || action === "resume") ? managedControlOrchestrator(agent) : null;
|
|
2319
2377
|
const metaSessionName = typeof agent.meta?.sessionName === "string" ? agent.meta.sessionName : undefined;
|
|
2320
2378
|
const metaTmuxSession = typeof agent.meta?.tmuxSession === "string" ? agent.meta.tmuxSession : undefined;
|
|
2321
2379
|
const metaPolicyName = typeof agent.meta?.policyName === "string" ? agent.meta.policyName : undefined;
|
|
2322
2380
|
const metaSpawnRequestId = typeof agent.meta?.spawnRequestId === "string" ? agent.meta.spawnRequestId : undefined;
|
|
2381
|
+
const resumeId = action === "resume" ? latestClaudeResumeIdForAgent(agent) : undefined;
|
|
2382
|
+
if (action === "resume" && !orchestrator) return error("no online orchestrator available to resume agent", 409);
|
|
2383
|
+
if (action === "resume" && !resumeId) return error("no Claude resume id recorded for agent", 422);
|
|
2323
2384
|
const denied = authorizeRoute(req, {
|
|
2324
2385
|
scope: "agent:write",
|
|
2325
2386
|
resource: { agentId: agent.id, orchestratorId: orchestrator?.id, policyName: metaPolicyName, spawnRequestId: metaSpawnRequestId },
|
|
@@ -2338,14 +2399,15 @@ const postAgentAction: Handler = async (req, params) => {
|
|
|
2338
2399
|
...(metaTmuxSession ? { tmuxSession: metaTmuxSession } : {}),
|
|
2339
2400
|
...(metaPolicyName ? { policyName: metaPolicyName } : {}),
|
|
2340
2401
|
...(metaSpawnRequestId ? { spawnRequestId: metaSpawnRequestId } : {}),
|
|
2341
|
-
...(action === "restart" ? { restartSpawn: restartSpawnParamsForAgent(agent, orchestrator, metaPolicyName, metaSpawnRequestId) } : {}),
|
|
2402
|
+
...(action === "restart" || action === "resume" ? { restartSpawn: restartSpawnParamsForAgent(agent, orchestrator, metaPolicyName, metaSpawnRequestId, { resumeId }) } : {}),
|
|
2342
2403
|
...(action === "compact" ? compactMemoryParams(agent) : {}),
|
|
2404
|
+
...(resumeId ? { claudeResumeId: resumeId } : {}),
|
|
2343
2405
|
requestedBy: "dashboard",
|
|
2344
2406
|
requestedAt: Date.now(),
|
|
2345
2407
|
},
|
|
2346
2408
|
});
|
|
2347
|
-
if (action === "shutdown" || action === "restart") {
|
|
2348
|
-
const lifecycleAction = action === "shutdown" ? "shutting-down" : "restarting";
|
|
2409
|
+
if (action === "shutdown" || action === "restart" || action === "resume") {
|
|
2410
|
+
const lifecycleAction = action === "shutdown" ? "shutting-down" : action === "resume" ? "resuming" : "restarting";
|
|
2349
2411
|
markReady(agent.id, false);
|
|
2350
2412
|
mergeAgentMeta(agent.id, { lifecycleAction, lifecycleActionAt: Date.now(), lifecycleCommandId: command.id });
|
|
2351
2413
|
emitAgentStatus(agent.id);
|
|
@@ -2826,6 +2888,40 @@ const postRuntimeTokenRenew: Handler = async (req) => {
|
|
|
2826
2888
|
}, 201);
|
|
2827
2889
|
};
|
|
2828
2890
|
|
|
2891
|
+
// Orchestrator-mediated runner-token re-mint. A live runner whose runtime token
|
|
2892
|
+
// has expired (e.g. the relay was unreachable across the renewal window) cannot
|
|
2893
|
+
// self-renew — its dead token can't authenticate. Its orchestrator, which holds
|
|
2894
|
+
// a long-lived credential, proxies the runner's expired token here. We verify the
|
|
2895
|
+
// token is a genuine, non-revoked runner token owned by THIS orchestrator and
|
|
2896
|
+
// mint a fresh one cloning its scope, so the session self-heals without a restart.
|
|
2897
|
+
const postOrchestratorRunnerToken: Handler = async (req, params) => {
|
|
2898
|
+
const orch = getOrchestrator(params.id!);
|
|
2899
|
+
if (!orch) return error("orchestrator not found", 404);
|
|
2900
|
+
const denied = authorizeRoute(req, { scope: "command:write", resource: { orchestratorId: orch.id } });
|
|
2901
|
+
if (denied) return denied;
|
|
2902
|
+
const parsed = await parseBody<unknown>(req);
|
|
2903
|
+
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
2904
|
+
if (!isRecord(parsed.body)) return error("body required");
|
|
2905
|
+
const token = cleanString(parsed.body.token, "token", { required: true, max: 8192 })!;
|
|
2906
|
+
const result = reissueRunnerRuntimeToken({
|
|
2907
|
+
expiredToken: token,
|
|
2908
|
+
orchestratorId: orch.id,
|
|
2909
|
+
createdBy: `orchestrator-remint:${orch.id}`,
|
|
2910
|
+
});
|
|
2911
|
+
if ("error" in result) return error(result.error, 403);
|
|
2912
|
+
auditEvent({
|
|
2913
|
+
clientId: "server-runner-token-remint-" + result.record.jti + "-" + Date.now(),
|
|
2914
|
+
kind: "state",
|
|
2915
|
+
title: "Runner token re-minted",
|
|
2916
|
+
body: result.record.sub,
|
|
2917
|
+
meta: result.record.jti,
|
|
2918
|
+
icon: "ti-key",
|
|
2919
|
+
view: "security",
|
|
2920
|
+
metadata: { orchestratorId: orch.id, jti: result.record.jti, sub: result.record.sub, ...authAuditMetadata(req) },
|
|
2921
|
+
});
|
|
2922
|
+
return json(result, 201);
|
|
2923
|
+
};
|
|
2924
|
+
|
|
2829
2925
|
const postInteractiveRunnerRuntimeToken: Handler = async (req) => {
|
|
2830
2926
|
if (!isRootCredentialRequest(req)) return error("root credential required for runtime token exchange", 403);
|
|
2831
2927
|
const parsed = await parseBody<unknown>(req);
|
|
@@ -3940,7 +4036,6 @@ const postWorkspaceOrphanReclaim: Handler = async (req) => {
|
|
|
3940
4036
|
const postWorkspaceAction: Handler = async (req, params) => {
|
|
3941
4037
|
const parsed = await parseBody<unknown>(req);
|
|
3942
4038
|
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
3943
|
-
let mergeLeaseRepo: string | undefined;
|
|
3944
4039
|
try {
|
|
3945
4040
|
if (!isRecord(parsed.body)) return error("body required");
|
|
3946
4041
|
const workspace = getWorkspace(params.id!);
|
|
@@ -3959,17 +4054,32 @@ const postWorkspaceAction: Handler = async (req, params) => {
|
|
|
3959
4054
|
const denied = authorizeRoute(req, { scope: requiresCommand ? "command:write" : "agent:write", resource: { agentId, cwd: workspace.worktreePath } });
|
|
3960
4055
|
if (denied) return denied;
|
|
3961
4056
|
if (action === "status") return json(workspace);
|
|
3962
|
-
//
|
|
3963
|
-
//
|
|
4057
|
+
// Base merges go through the shared helper (lease + command + bind), the same
|
|
4058
|
+
// path the auto-merge job uses, so both serialize per repo (issue #157).
|
|
3964
4059
|
if (action === "merge") {
|
|
3965
|
-
const
|
|
3966
|
-
|
|
3967
|
-
|
|
3968
|
-
|
|
3969
|
-
|
|
3970
|
-
)
|
|
3971
|
-
|
|
3972
|
-
|
|
4060
|
+
const strategy = cleanEnum(parsed.body.strategy, "strategy", ["pr", "rebase-ff", "auto"] as const, "auto") as WorkspaceMergeStrategy;
|
|
4061
|
+
const result = requestWorkspaceMerge(workspace, {
|
|
4062
|
+
requestedBy: agentId ?? "dashboard",
|
|
4063
|
+
strategy,
|
|
4064
|
+
deleteBranch: parsed.body.deleteBranch !== false,
|
|
4065
|
+
prTitle: cleanString(parsed.body.prTitle, "prTitle", { max: 240 }),
|
|
4066
|
+
prBody: cleanString(parsed.body.prBody, "prBody", { max: 8000 }),
|
|
4067
|
+
metadata: { ...metadata, ...(detail ? { detail } : {}), ...(agentId ? { updatedByAgentId: agentId } : {}) },
|
|
4068
|
+
});
|
|
4069
|
+
if (!result.ok) return error(result.error, result.status);
|
|
4070
|
+
emitCommand(result.command);
|
|
4071
|
+
auditEvent({
|
|
4072
|
+
clientId: `workspace-merge-${workspace.id}-${Date.now()}`,
|
|
4073
|
+
kind: "state",
|
|
4074
|
+
title: "Workspace merge",
|
|
4075
|
+
body: detail ?? workspace.worktreePath,
|
|
4076
|
+
meta: workspace.branch ?? workspace.id,
|
|
4077
|
+
icon: "ti-git-merge",
|
|
4078
|
+
view: "orchestrators",
|
|
4079
|
+
agentId,
|
|
4080
|
+
metadata: { action: "merge", workspaceId: workspace.id, repoRoot: workspace.repoRoot, worktreePath: workspace.worktreePath, status: result.workspace.status, commandId: result.command.id, ...authAuditMetadata(req) },
|
|
4081
|
+
});
|
|
4082
|
+
return json({ workspace: result.workspace, command: result.command }, 202);
|
|
3973
4083
|
}
|
|
3974
4084
|
const statusByAction: Record<string, WorkspaceStatus | undefined> = {
|
|
3975
4085
|
status: undefined,
|
|
@@ -3977,7 +4087,6 @@ const postWorkspaceAction: Handler = async (req, params) => {
|
|
|
3977
4087
|
"conflict-found": "conflict",
|
|
3978
4088
|
"request-review": "review_requested",
|
|
3979
4089
|
"merge-plan": "merge_planned",
|
|
3980
|
-
merge: "merge_planned",
|
|
3981
4090
|
abandon: "abandoned",
|
|
3982
4091
|
cleanup: "cleanup_requested",
|
|
3983
4092
|
};
|
|
@@ -3991,58 +4100,31 @@ const postWorkspaceAction: Handler = async (req, params) => {
|
|
|
3991
4100
|
if (!updated) return error("workspace not found", 404);
|
|
3992
4101
|
let command: Command | undefined;
|
|
3993
4102
|
if (requiresCommand) {
|
|
3994
|
-
//
|
|
4103
|
+
// Only `cleanup` reaches here — `merge` returned early via the shared helper.
|
|
4104
|
+
// Cleanup may queue: if the owning orchestrator is offline, the command (no
|
|
4105
|
+
// TTL) waits and reconciles when it reconnects. Only hard-fail when no
|
|
4106
|
+
// orchestrator owns the path at all — then DELETE is the escape.
|
|
3995
4107
|
const owners = listOrchestrators().filter((candidate) => pathWithinBase(workspace.sourceCwd, candidate.baseDir));
|
|
3996
4108
|
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);
|
|
4109
|
+
const owner = onlineOwner ?? owners[0];
|
|
4110
|
+
if (!owner) return error("no orchestrator owns this workspace path; use DELETE /api/workspaces/:id to purge the record", 409);
|
|
4111
|
+
command = createCommand({
|
|
4112
|
+
type: "workspace.cleanup",
|
|
4113
|
+
source: "system",
|
|
4114
|
+
target: owner.agentId,
|
|
4115
|
+
correlationId: workspace.id,
|
|
4116
|
+
params: {
|
|
4117
|
+
action: "cleanup",
|
|
4118
|
+
workspaceId: workspace.id,
|
|
4119
|
+
repoRoot: workspace.repoRoot,
|
|
4120
|
+
worktreePath: workspace.worktreePath,
|
|
4121
|
+
branch: workspace.branch,
|
|
4122
|
+
requestedBy: agentId ?? "dashboard",
|
|
4123
|
+
requestedAt: Date.now(),
|
|
4124
|
+
deleteBranch: true,
|
|
4125
|
+
queued: owner.status !== "online",
|
|
4126
|
+
},
|
|
4127
|
+
});
|
|
4046
4128
|
emitCommand(command);
|
|
4047
4129
|
}
|
|
4048
4130
|
auditEvent({
|
|
@@ -4051,16 +4133,13 @@ const postWorkspaceAction: Handler = async (req, params) => {
|
|
|
4051
4133
|
title: `Workspace ${action}`,
|
|
4052
4134
|
body: detail ?? workspace.worktreePath,
|
|
4053
4135
|
meta: workspace.branch ?? workspace.id,
|
|
4054
|
-
icon: action === "cleanup" ? "ti-trash" : action === "
|
|
4136
|
+
icon: action === "cleanup" ? "ti-trash" : action === "conflict-found" ? "ti-alert-triangle" : "ti-git-branch",
|
|
4055
4137
|
view: "orchestrators",
|
|
4056
4138
|
agentId,
|
|
4057
4139
|
metadata: { action, workspaceId: workspace.id, repoRoot: workspace.repoRoot, worktreePath: workspace.worktreePath, status: updated.status, commandId: command?.id, ...authAuditMetadata(req) },
|
|
4058
4140
|
});
|
|
4059
4141
|
return json({ workspace: updated, command }, requiresCommand ? 202 : 200);
|
|
4060
4142
|
} 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
4143
|
if (e instanceof ValidationError) return error(e.message, 400);
|
|
4065
4144
|
throw e;
|
|
4066
4145
|
}
|
|
@@ -4397,16 +4476,35 @@ const patchCommand: Handler = async (req, params) => {
|
|
|
4397
4476
|
if (command.type === "workspace.reconcile" && command.status === "succeeded" && isRecord(command.result)) {
|
|
4398
4477
|
const workspaceId = cleanString(command.result.workspaceId, "result.workspaceId", { max: 160 });
|
|
4399
4478
|
const resultStatus = cleanEnum(command.result.status, "result.status", VALID_WORKSPACE_STATUSES) as WorkspaceStatus | undefined;
|
|
4479
|
+
const removed = command.result.removed === true;
|
|
4400
4480
|
if (workspaceId && resultStatus) {
|
|
4401
4481
|
// Only act on workspaces the agent left in a live state; never overwrite
|
|
4402
4482
|
// a status a human/agent has since moved on (merge_planned, abandoned, …).
|
|
4403
4483
|
const current = getWorkspace(workspaceId);
|
|
4404
4484
|
if (current && (current.status === "active" || current.status === "ready")) {
|
|
4405
|
-
|
|
4406
|
-
|
|
4407
|
-
|
|
4408
|
-
|
|
4409
|
-
|
|
4485
|
+
if (removed) {
|
|
4486
|
+
// The owner exited and the worktree had no work — the orchestrator
|
|
4487
|
+
// already deleted it on disk. Drop the DB row immediately instead of
|
|
4488
|
+
// parking it at `cleaned` for 24h: a no-change session is pure junk
|
|
4489
|
+
// from the user's view, so it should leave the Workspaces panel now.
|
|
4490
|
+
deleteWorkspace(workspaceId);
|
|
4491
|
+
auditEvent({
|
|
4492
|
+
clientId: `workspace-reconcile-removed-${workspaceId}-${Date.now()}`,
|
|
4493
|
+
kind: "state",
|
|
4494
|
+
title: "Workspace removed (no changes)",
|
|
4495
|
+
body: current.worktreePath,
|
|
4496
|
+
meta: current.branch ?? workspaceId,
|
|
4497
|
+
icon: "ti-trash",
|
|
4498
|
+
view: "orchestrators",
|
|
4499
|
+
metadata: { reconcileCommandId: command.id, workspaceId, repoRoot: current.repoRoot },
|
|
4500
|
+
});
|
|
4501
|
+
} else {
|
|
4502
|
+
updateWorkspaceStatus(workspaceId, resultStatus, {
|
|
4503
|
+
reconcileResult: command.result,
|
|
4504
|
+
reconcileCommandId: command.id,
|
|
4505
|
+
reconciledAt: Date.now(),
|
|
4506
|
+
});
|
|
4507
|
+
}
|
|
4410
4508
|
}
|
|
4411
4509
|
}
|
|
4412
4510
|
}
|
|
@@ -4469,6 +4567,27 @@ const deleteAgentProfileRoute: Handler = (req, params) => {
|
|
|
4469
4567
|
}
|
|
4470
4568
|
};
|
|
4471
4569
|
|
|
4570
|
+
// --- Steward config (global, provider-independent — issue #167) ---
|
|
4571
|
+
|
|
4572
|
+
const getStewardConfigRoute: Handler = () => json(getStewardConfigEntry());
|
|
4573
|
+
|
|
4574
|
+
const putStewardConfigRoute: Handler = async (req) => {
|
|
4575
|
+
const parsed = await parseBody<unknown>(req);
|
|
4576
|
+
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
4577
|
+
try {
|
|
4578
|
+
const value = isRecord(parsed.body) && Object.prototype.hasOwnProperty.call(parsed.body, "value")
|
|
4579
|
+
? parsed.body.value
|
|
4580
|
+
: parsed.body;
|
|
4581
|
+
const updatedBy = isRecord(parsed.body) ? cleanString(parsed.body.updatedBy, "updatedBy", { max: 200 }) : undefined;
|
|
4582
|
+
const entry = setStewardConfig(value, updatedBy);
|
|
4583
|
+
emitConfigChanged(entry.namespace, entry.key, entry.version);
|
|
4584
|
+
return json(entry, entry.version === 1 ? 201 : 200);
|
|
4585
|
+
} catch (e) {
|
|
4586
|
+
if (e instanceof ValidationError) return error(e.message, 400);
|
|
4587
|
+
throw e;
|
|
4588
|
+
}
|
|
4589
|
+
};
|
|
4590
|
+
|
|
4472
4591
|
// --- Config routes ---
|
|
4473
4592
|
|
|
4474
4593
|
function normalizeConfigPathParam(raw: string | undefined, field: string): string {
|
|
@@ -6276,6 +6395,7 @@ const routes: Route[] = [
|
|
|
6276
6395
|
route("POST", "/api/orchestrators/:id/heartbeat", postOrchestratorHeartbeat),
|
|
6277
6396
|
route("PATCH", "/api/orchestrators/:id/agents", patchOrchestratorAgents),
|
|
6278
6397
|
route("POST", "/api/orchestrators/:id/spawn", postOrchestratorSpawn),
|
|
6398
|
+
route("POST", "/api/orchestrators/:id/runner-token", postOrchestratorRunnerToken),
|
|
6279
6399
|
route("POST", "/api/orchestrators/:id/actions", postOrchestratorAction),
|
|
6280
6400
|
route("GET", "/api/orchestrators/:id/directories", getOrchestratorDirectories),
|
|
6281
6401
|
route("POST", "/api/orchestrators/:id/directories", postOrchestratorCreateDirectory),
|
|
@@ -6334,6 +6454,8 @@ const routes: Route[] = [
|
|
|
6334
6454
|
route("GET", "/api/agent-profiles/:name", getAgentProfileRoute),
|
|
6335
6455
|
route("PUT", "/api/agent-profiles/:name", putAgentProfileRoute),
|
|
6336
6456
|
route("DELETE", "/api/agent-profiles/:name", deleteAgentProfileRoute),
|
|
6457
|
+
route("GET", "/api/steward-config", getStewardConfigRoute),
|
|
6458
|
+
route("PUT", "/api/steward-config", putStewardConfigRoute),
|
|
6337
6459
|
route("GET", "/api/config/:namespace", getConfigNamespace),
|
|
6338
6460
|
route("GET", "/api/config/:namespace/:key/history", getConfigKeyHistory),
|
|
6339
6461
|
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
|
}
|