agent-relay-server 0.119.10 → 0.120.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 +57 -1
- package/package.json +3 -3
- package/public/assets/{MessageBubble-U5sX3SNZ.js → MessageBubble-BJXjk3GB.js} +2 -2
- package/public/assets/{MessageBubble-U5sX3SNZ.js.map → MessageBubble-BJXjk3GB.js.map} +1 -1
- package/public/assets/{PlanGraphPanel-Bi2gvXeE.js → PlanGraphPanel-DI5JOnm1.js} +2 -2
- package/public/assets/{PlanGraphPanel-Bi2gvXeE.js.map → PlanGraphPanel-DI5JOnm1.js.map} +1 -1
- package/public/assets/{activity-Cwkz51pG.js → activity-BUt4FiFQ.js} +2 -2
- package/public/assets/{activity-Cwkz51pG.js.map → activity-BUt4FiFQ.js.map} +1 -1
- package/public/assets/{agent-profiles-BgddVqRp.js → agent-profiles-C1dvEaGS.js} +2 -2
- package/public/assets/{agent-profiles-BgddVqRp.js.map → agent-profiles-C1dvEaGS.js.map} +1 -1
- package/public/assets/{agents-CiGYBw0h.js → agents-DvJZ0Tcz.js} +2 -2
- package/public/assets/{agents-CiGYBw0h.js.map → agents-DvJZ0Tcz.js.map} +1 -1
- package/public/assets/{analytics-CmSHiDAI.js → analytics-DMLoMwta.js} +2 -2
- package/public/assets/{analytics-CmSHiDAI.js.map → analytics-DMLoMwta.js.map} +1 -1
- package/public/assets/{automation-BBJB4RaE.js → automation-DICpRUGK.js} +2 -2
- package/public/assets/{automation-BBJB4RaE.js.map → automation-DICpRUGK.js.map} +1 -1
- package/public/assets/{branch-state-badge-Db6uU7yT.js → branch-state-badge-BYE2U_y4.js} +2 -2
- package/public/assets/{branch-state-badge-Db6uU7yT.js.map → branch-state-badge-BYE2U_y4.js.map} +1 -1
- package/public/assets/{channels-trEI29j0.js → channels-3SlDQL3j.js} +2 -2
- package/public/assets/{channels-trEI29j0.js.map → channels-3SlDQL3j.js.map} +1 -1
- package/public/assets/{chat-DS578gZy.js → chat-_I9nvxj2.js} +2 -2
- package/public/assets/{chat-DS578gZy.js.map → chat-_I9nvxj2.js.map} +1 -1
- package/public/assets/{connectors-DNV138gK.js → connectors-CrEW591Q.js} +2 -2
- package/public/assets/{connectors-DNV138gK.js.map → connectors-CrEW591Q.js.map} +1 -1
- package/public/assets/{formatted-body-DYUVqWym.js → formatted-body-ChERiS0y.js} +3 -3
- package/public/assets/{formatted-body-DYUVqWym.js.map → formatted-body-ChERiS0y.js.map} +1 -1
- package/public/assets/{formatted-body-impl-DdcyxVO0.js → formatted-body-impl-CiaUIrP3.js} +2 -2
- package/public/assets/{formatted-body-impl-DdcyxVO0.js.map → formatted-body-impl-CiaUIrP3.js.map} +1 -1
- package/public/assets/{index-CqcAqQKW.js → index-CtBqO8vm.js} +7 -7
- package/public/assets/index-CtBqO8vm.js.map +1 -0
- package/public/assets/{insights-BunHbsTA.js → insights-DBjaf3N5.js} +2 -2
- package/public/assets/{insights-BunHbsTA.js.map → insights-DBjaf3N5.js.map} +1 -1
- package/public/assets/{integrations-COnkRfWW.js → integrations-B-bdliwU.js} +2 -2
- package/public/assets/{integrations-COnkRfWW.js.map → integrations-B-bdliwU.js.map} +1 -1
- package/public/assets/{maintenance-cICBVcpJ.js → maintenance-EWlKbynM.js} +2 -2
- package/public/assets/{maintenance-cICBVcpJ.js.map → maintenance-EWlKbynM.js.map} +1 -1
- package/public/assets/{managed-agents-BK9_Gi-M.js → managed-agents-DzjKwBh4.js} +2 -2
- package/public/assets/{managed-agents-BK9_Gi-M.js.map → managed-agents-DzjKwBh4.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-EZfcTHG9.js → markdown-preview-impl-bwLB6Gc-.js} +2 -2
- package/public/assets/{markdown-preview-impl-EZfcTHG9.js.map → markdown-preview-impl-bwLB6Gc-.js.map} +1 -1
- package/public/assets/{memory-Vi-8HvK3.js → memory-DEwJsFNK.js} +2 -2
- package/public/assets/{memory-Vi-8HvK3.js.map → memory-DEwJsFNK.js.map} +1 -1
- package/public/assets/{messages-DScIiBMI.js → messages-BSMkT9X2.js} +2 -2
- package/public/assets/{messages-DScIiBMI.js.map → messages-BSMkT9X2.js.map} +1 -1
- package/public/assets/{orchestrators-DLogldXx.js → orchestrators-Bl8k3KxQ.js} +2 -2
- package/public/assets/{orchestrators-DLogldXx.js.map → orchestrators-Bl8k3KxQ.js.map} +1 -1
- package/public/assets/{overview-B_RQAQR8.js → overview-CdFe7Dmc.js} +2 -2
- package/public/assets/{overview-B_RQAQR8.js.map → overview-CdFe7Dmc.js.map} +1 -1
- package/public/assets/{pairs-CSiPQ7E2.js → pairs-0ewxtvFZ.js} +2 -2
- package/public/assets/{pairs-CSiPQ7E2.js.map → pairs-0ewxtvFZ.js.map} +1 -1
- package/public/assets/{projects-Dd8gYzcn.js → projects-DEn85LQb.js} +2 -2
- package/public/assets/{projects-Dd8gYzcn.js.map → projects-DEn85LQb.js.map} +1 -1
- package/public/assets/{prompt-settings-DsDi_Xpd.js → prompt-settings-CMQ2tHe0.js} +2 -2
- package/public/assets/{prompt-settings-DsDi_Xpd.js.map → prompt-settings-CMQ2tHe0.js.map} +1 -1
- package/public/assets/{registry-DHFUWIdb.js → registry-DntwLc8a.js} +2 -2
- package/public/assets/{registry-DHFUWIdb.js.map → registry-DntwLc8a.js.map} +1 -1
- package/public/assets/{security-D2nHkJH6.js → security-CYwC-26O.js} +2 -2
- package/public/assets/{security-D2nHkJH6.js.map → security-CYwC-26O.js.map} +1 -1
- package/public/assets/{select-BjNcommA.js → select-B19D5ToH.js} +2 -2
- package/public/assets/{select-BjNcommA.js.map → select-B19D5ToH.js.map} +1 -1
- package/public/assets/{settings-DiUEq54n.js → settings-BzYvlKIL.js} +2 -2
- package/public/assets/{settings-DiUEq54n.js.map → settings-BzYvlKIL.js.map} +1 -1
- package/public/assets/store-BuuJUn70.js +9 -0
- package/public/assets/store-BuuJUn70.js.map +1 -0
- package/public/assets/{tasks-Dx8GVQiR.js → tasks-vZ5VRoO8.js} +2 -2
- package/public/assets/{tasks-Dx8GVQiR.js.map → tasks-vZ5VRoO8.js.map} +1 -1
- package/public/assets/{teams-DQtf7bha.js → teams-E3rI5gkA.js} +2 -2
- package/public/assets/{teams-DQtf7bha.js.map → teams-E3rI5gkA.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-D5rhXV4u.js → terminal-viewer-impl-24nB6STe.js} +2 -2
- package/public/assets/{terminal-viewer-impl-D5rhXV4u.js.map → terminal-viewer-impl-24nB6STe.js.map} +1 -1
- package/public/assets/types-uVOXgvMT.js.map +1 -1
- package/public/assets/{work-queue-BfWNDp7U.js → work-queue-Bql5E-C3.js} +2 -2
- package/public/assets/{work-queue-BfWNDp7U.js.map → work-queue-Bql5E-C3.js.map} +1 -1
- package/public/assets/{workspaces-M3xEMNXu.js → workspaces-DjQ6EdrZ.js} +2 -2
- package/public/assets/{workspaces-M3xEMNXu.js.map → workspaces-DjQ6EdrZ.js.map} +1 -1
- package/public/index.html +3 -3
- package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/src/config-store.ts +1 -1
- package/src/db/migrations.ts +5 -1
- package/src/db/scheduled-timers.ts +48 -4
- package/src/db/schema.ts +2 -1
- package/src/mcp/index.ts +2 -1
- package/src/mcp/tools-scheduler.ts +22 -3
- package/src/prompt-resolver.ts +6 -4
- package/src/routes/agent-profiles.ts +9 -0
- package/src/routes/commands.ts +8 -1
- package/src/routes/index.ts +4 -2
- package/src/routes/tasks.ts +19 -1
- package/src/services/dispatch-command.ts +136 -5
- package/src/services/scheduler-command.ts +360 -0
- package/src/services/scheduler.ts +25 -5
- package/src/services/task-done-when.ts +1 -0
- package/src/services/task-entity.ts +148 -3
- package/src/services/task-lifecycle.ts +12 -2
- package/src/sse.ts +58 -10
- package/public/assets/index-CqcAqQKW.js.map +0 -1
- package/public/assets/store-OLe8gFh0.js +0 -9
- package/public/assets/store-OLe8gFh0.js.map +0 -1
|
@@ -41,6 +41,23 @@ export interface ScheduledTimer {
|
|
|
41
41
|
lastMessageId?: number;
|
|
42
42
|
claimedBy?: string;
|
|
43
43
|
claimExpiresAt?: number;
|
|
44
|
+
command?: ScheduledTimerCommand;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ScheduledTimerCommand {
|
|
48
|
+
commandId?: string;
|
|
49
|
+
orchestratorId: string;
|
|
50
|
+
cwd: string;
|
|
51
|
+
command: string;
|
|
52
|
+
env?: Record<string, string>;
|
|
53
|
+
status: "pending" | "running" | "succeeded" | "failed" | "timed_out" | "canceled";
|
|
54
|
+
startedAt?: number;
|
|
55
|
+
finishedAt?: number;
|
|
56
|
+
exitCode?: number | null;
|
|
57
|
+
durationMs?: number;
|
|
58
|
+
stdoutTail?: string;
|
|
59
|
+
stderrTail?: string;
|
|
60
|
+
logArtifactId?: string;
|
|
44
61
|
}
|
|
45
62
|
|
|
46
63
|
interface ScheduledTimerRow {
|
|
@@ -74,6 +91,7 @@ interface ScheduledTimerRow {
|
|
|
74
91
|
last_message_id: number | null;
|
|
75
92
|
claimed_by: string | null;
|
|
76
93
|
claim_expires_at: number | null;
|
|
94
|
+
command: string | null;
|
|
77
95
|
}
|
|
78
96
|
|
|
79
97
|
export interface CreateScheduledTimerRowInput {
|
|
@@ -96,6 +114,7 @@ export interface CreateScheduledTimerRowInput {
|
|
|
96
114
|
maxRuns?: number;
|
|
97
115
|
expiresAt?: number;
|
|
98
116
|
idempotencyKey?: string;
|
|
117
|
+
command?: ScheduledTimerCommand;
|
|
99
118
|
}
|
|
100
119
|
|
|
101
120
|
export function insertScheduledTimer(input: CreateScheduledTimerRowInput, now = Date.now()): ScheduledTimer {
|
|
@@ -105,9 +124,9 @@ export function insertScheduledTimer(input: CreateScheduledTimerRowInput, now =
|
|
|
105
124
|
INSERT INTO scheduled_timers (
|
|
106
125
|
id, target, created_by, created_by_kind, created_by_scopes, created_by_constraints, delivery_from,
|
|
107
126
|
created_at, next_run_at, schedule_kind, schedule_def, message_kind, channel, subject, body, claimable, payload, meta, enabled,
|
|
108
|
-
run_count, max_runs, expires_at, idempotency_key, correlation_id
|
|
127
|
+
run_count, max_runs, expires_at, idempotency_key, correlation_id, command
|
|
109
128
|
)
|
|
110
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 0, ?, ?, ?, ?)
|
|
129
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 0, ?, ?, ?, ?, ?)
|
|
111
130
|
`).run(
|
|
112
131
|
id,
|
|
113
132
|
input.target,
|
|
@@ -131,6 +150,7 @@ export function insertScheduledTimer(input: CreateScheduledTimerRowInput, now =
|
|
|
131
150
|
input.expiresAt ?? null,
|
|
132
151
|
input.idempotencyKey ?? null,
|
|
133
152
|
correlationId,
|
|
153
|
+
input.command ? JSON.stringify(input.command) : null,
|
|
134
154
|
);
|
|
135
155
|
return getScheduledTimer(id)!;
|
|
136
156
|
}
|
|
@@ -215,12 +235,35 @@ export function completeScheduledTimerRun(input: {
|
|
|
215
235
|
claimed_by = NULL,
|
|
216
236
|
claim_expires_at = NULL
|
|
217
237
|
WHERE id = ?
|
|
218
|
-
AND claimed_by = ?
|
|
238
|
+
AND (claimed_by = ? OR (claimed_by IS NULL AND ? = ''))
|
|
219
239
|
AND enabled = 1
|
|
220
|
-
`).run(runCount, input.now, input.messageId, candidateNextRunAt, enabled ? 1 : 0, input.timer.id, input.timer.claimedBy ?? "");
|
|
240
|
+
`).run(runCount, input.now, input.messageId, candidateNextRunAt, enabled ? 1 : 0, input.timer.id, input.timer.claimedBy ?? "", input.timer.claimedBy ?? "");
|
|
221
241
|
return getScheduledTimer(input.timer.id)!;
|
|
222
242
|
}
|
|
223
243
|
|
|
244
|
+
export function setScheduledTimerCommandId(timerId: string, commandId: string): ScheduledTimer | null {
|
|
245
|
+
const timer = getScheduledTimer(timerId);
|
|
246
|
+
if (!timer?.command) return timer;
|
|
247
|
+
getDb().query("UPDATE scheduled_timers SET command = ? WHERE id = ?")
|
|
248
|
+
.run(JSON.stringify({ ...timer.command, commandId }), timerId);
|
|
249
|
+
return getScheduledTimer(timerId);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function findScheduledTimerByCommandId(commandId: string): ScheduledTimer | null {
|
|
253
|
+
const row = getDb()
|
|
254
|
+
.query("SELECT * FROM scheduled_timers WHERE json_extract(command, '$.commandId') = ? ORDER BY created_at DESC LIMIT 1")
|
|
255
|
+
.get(commandId) as ScheduledTimerRow | undefined;
|
|
256
|
+
return row ? rowToTimer(row) : null;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function updateScheduledTimerCommand(timerId: string, patch: Partial<ScheduledTimerCommand>): ScheduledTimer | null {
|
|
260
|
+
const timer = getScheduledTimer(timerId);
|
|
261
|
+
if (!timer?.command) return timer;
|
|
262
|
+
getDb().query("UPDATE scheduled_timers SET command = ? WHERE id = ?")
|
|
263
|
+
.run(JSON.stringify({ ...timer.command, ...patch }), timerId);
|
|
264
|
+
return getScheduledTimer(timerId);
|
|
265
|
+
}
|
|
266
|
+
|
|
224
267
|
export function recordScheduledTimerFailure(input: {
|
|
225
268
|
timer: ScheduledTimer;
|
|
226
269
|
error: string;
|
|
@@ -295,6 +338,7 @@ function rowToTimer(row: ScheduledTimerRow): ScheduledTimer {
|
|
|
295
338
|
lastMessageId: row.last_message_id ?? undefined,
|
|
296
339
|
claimedBy: row.claimed_by ?? undefined,
|
|
297
340
|
claimExpiresAt: row.claim_expires_at ?? undefined,
|
|
341
|
+
command: parseRecord(row.command) as ScheduledTimerCommand | undefined,
|
|
298
342
|
};
|
|
299
343
|
}
|
|
300
344
|
|
package/src/db/schema.ts
CHANGED
|
@@ -197,7 +197,8 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
197
197
|
correlation_id TEXT NOT NULL,
|
|
198
198
|
last_message_id INTEGER REFERENCES messages(id),
|
|
199
199
|
claimed_by TEXT,
|
|
200
|
-
claim_expires_at INTEGER
|
|
200
|
+
claim_expires_at INTEGER,
|
|
201
|
+
command TEXT
|
|
201
202
|
);
|
|
202
203
|
CREATE INDEX IF NOT EXISTS idx_scheduled_timers_due ON scheduled_timers(enabled, next_run_at, claim_expires_at);
|
|
203
204
|
CREATE INDEX IF NOT EXISTS idx_scheduled_timers_creator ON scheduled_timers(created_by, enabled, next_run_at);
|
package/src/mcp/index.ts
CHANGED
|
@@ -53,7 +53,8 @@ export const RELAY_TOOL_GROUPS: Record<string, readonly string[]> = {
|
|
|
53
53
|
memory: ["relay_recall", "relay_compact_and_resume", "relay_memory_write", "relay_memory_promote", "relay_memory_retract", "relay_memory_read", "relay_memory_search"],
|
|
54
54
|
artifacts: ["relay_upload_artifact", "relay_attach_artifact"],
|
|
55
55
|
workspace: ["relay_workspace_status", "relay_workspace_ready", "relay_workspace_deps", "relay_workspace_list", "relay_workspace_claim", "relay_workspace_release", "relay_workspace_land", "relay_merge_pause", "relay_merge_pauses", "relay_merge_renew", "relay_merge_resume"],
|
|
56
|
-
"spawn-lifecycle": ["relay_spawn_agent", "relay_spawn_targets", "relay_my_agents", "relay_shutdown_agent", "relay_agent_action", "relay_agent_status", "relay_agent_crash_report"
|
|
56
|
+
"spawn-lifecycle": ["relay_spawn_agent", "relay_spawn_targets", "relay_my_agents", "relay_shutdown_agent", "relay_agent_action", "relay_agent_status", "relay_agent_crash_report"],
|
|
57
|
+
schedule: ["relay_schedule_timer", "relay_list_timers", "relay_cancel_timer"],
|
|
57
58
|
plans: ["relay_plan_create", "relay_plan_add_node", "relay_plan_update_node", "relay_plan_add_edge", "relay_plan_remove_node", "relay_plan_remove_edge", "relay_plan_archive", "relay_plan_delete", "relay_plan_get"],
|
|
58
59
|
tasks: ["relay_task_create", "relay_task_get", "relay_task_list", "relay_task_update", "relay_task_set_status", "relay_task_set_stage", "relay_task_record_gate", "relay_task_add_dependency", "relay_task_remove_dependency", "relay_task_add_ref", "relay_task_remove_ref", "relay_task_assign_agent", "relay_task_history", "relay_task_memory_read", "relay_task_memory_replace_working_state", "relay_task_memory_append_ruled_out", "relay_task_memory_append_decision", "relay_task_dispatch", "relay_task_comment", "relay_task_close_bridge", "relay_release_readiness"],
|
|
59
60
|
teams: ["relay_team_get", "relay_team_set_brief", "relay_team_claim", "relay_team_dissolve"],
|
|
@@ -9,7 +9,7 @@ import { optionalBoolean, optionalPositiveInt, optionalRecord, optionalString, s
|
|
|
9
9
|
export const SCHEDULER_TOOLS: ToolDefinition[] = [
|
|
10
10
|
{
|
|
11
11
|
name: "relay_schedule_timer",
|
|
12
|
-
description: "Create a durable Relay timer that wakes a target by enqueueing a normal Relay message when due.
|
|
12
|
+
description: "Create a durable Relay timer that wakes a target by enqueueing a normal Relay message when due. With command, runs a background orchestrator command and wakes on exit or afterMs timeout.",
|
|
13
13
|
requiredScopes: ["schedule:write"],
|
|
14
14
|
inputSchema: {
|
|
15
15
|
type: "object",
|
|
@@ -21,11 +21,13 @@ export const SCHEDULER_TOOLS: ToolDefinition[] = [
|
|
|
21
21
|
subject: { type: "string" },
|
|
22
22
|
body: { type: "string" },
|
|
23
23
|
payload: { type: "object" },
|
|
24
|
+
command: { type: "string", description: "Optional background shell command. Requires command:write. When set, afterMs is the max timeout and everyMs/at are rejected." },
|
|
25
|
+
cwd: { type: "string", description: "Command working directory. Must resolve inside an online orchestrator baseDir. Defaults to the caller agent cwd when available." },
|
|
26
|
+
env: { type: "object", additionalProperties: { type: "string" }, description: "Optional string environment overrides for the command." },
|
|
24
27
|
maxRuns: { type: "integer", minimum: 1 },
|
|
25
28
|
expiresAt: { oneOf: [{ type: "string" }, { type: "integer", minimum: 1 }] },
|
|
26
29
|
idempotencyKey: { type: "string" },
|
|
27
30
|
},
|
|
28
|
-
required: ["body"],
|
|
29
31
|
additionalProperties: false,
|
|
30
32
|
},
|
|
31
33
|
},
|
|
@@ -94,14 +96,19 @@ function cleanScheduleInput(args: Record<string, unknown>): ScheduleTimerInput {
|
|
|
94
96
|
at: optionalString(args.at, "at", 80),
|
|
95
97
|
everyMs: optionalPositiveInt(args.everyMs, "everyMs"),
|
|
96
98
|
subject: optionalString(args.subject, "subject", 200),
|
|
97
|
-
body:
|
|
99
|
+
body: optionalString(args.body, "body", MAX_SAFE_BODY_CHARS),
|
|
98
100
|
payload: optionalRecord(args.payload, "payload"),
|
|
101
|
+
command: optionalString(args.command, "command", 8_000),
|
|
102
|
+
cwd: optionalString(args.cwd, "cwd", 500),
|
|
103
|
+
env: optionalStringRecord(args.env, "env"),
|
|
99
104
|
maxRuns: optionalPositiveInt(args.maxRuns, "maxRuns"),
|
|
100
105
|
expiresAt: typeof args.expiresAt === "number" ? args.expiresAt : optionalString(args.expiresAt, "expiresAt", 80),
|
|
101
106
|
idempotencyKey: optionalString(args.idempotencyKey, "idempotencyKey", 240),
|
|
102
107
|
};
|
|
103
108
|
}
|
|
104
109
|
|
|
110
|
+
const MAX_SAFE_BODY_CHARS = 64_000;
|
|
111
|
+
|
|
105
112
|
function authContextPayload(auth: McpAuthContext): Parameters<typeof authContextFromMcp>[0] {
|
|
106
113
|
return { kind: auth.kind, actor: auth.actor, scopes: auth.scopes, component: auth.component };
|
|
107
114
|
}
|
|
@@ -128,9 +135,21 @@ function serializeTimer(timer: ScheduledTimer): Record<string, unknown> {
|
|
|
128
135
|
idempotencyKey: timer.idempotencyKey,
|
|
129
136
|
correlationId: timer.correlationId,
|
|
130
137
|
lastMessageId: timer.lastMessageId,
|
|
138
|
+
command: timer.command,
|
|
131
139
|
};
|
|
132
140
|
}
|
|
133
141
|
|
|
142
|
+
function optionalStringRecord(value: unknown, field: string): Record<string, string> | undefined {
|
|
143
|
+
const record = optionalRecord(value, field);
|
|
144
|
+
if (!record) return undefined;
|
|
145
|
+
const out: Record<string, string> = {};
|
|
146
|
+
for (const [key, item] of Object.entries(record)) {
|
|
147
|
+
if (typeof item !== "string") throw new ValidationError(`${field}.${key} must be a string`);
|
|
148
|
+
out[key] = item;
|
|
149
|
+
}
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
|
|
134
153
|
function withAuthErrors<T>(fn: () => T): T {
|
|
135
154
|
try {
|
|
136
155
|
return fn();
|
package/src/prompt-resolver.ts
CHANGED
|
@@ -30,15 +30,17 @@ const DEFAULT_PROMPT_TEMPLATES = [
|
|
|
30
30
|
description: "Built-in ops-assistant append instruction.",
|
|
31
31
|
template: lines(
|
|
32
32
|
"Ops-assistant standing brief: execute mechanical operations on your coordinator's behalf: test suites, release commands such as `bun run release`, smoke and verification probes, gates, and host/version/status checks.",
|
|
33
|
-
"
|
|
33
|
+
"For any long command, start it detached in the background with stdout/stderr redirected to a log so it returns immediately and survives your turn; then call `relay_schedule_timer` with `target: \"me\"`, `afterMs` set near the expected duration, and a body telling your future self to check the log and report the exit code plus tail. End the turn after scheduling. Do not poll, tail, `ps`, or babysit the command in a loop.",
|
|
34
|
+
"On the timer wake, check the log and process result. If the command is done, report the outcome; if it is still running, reschedule once, or investigate if it is well past the expected duration.",
|
|
34
35
|
"Hold and apply session-local ops conventions the coordinator gives you. Report crisp outcomes only: command/gate, pass/fail, commit/version/host evidence, and the next blocker when one exists. Do not dump logs unless asked.",
|
|
35
|
-
"Do not make strategy, design, scope, or release-timing calls. Escalate those decisions to your spawn-parent.
|
|
36
|
+
"Do not make strategy, design, scope, or release-timing calls. Escalate those decisions to your spawn-parent.",
|
|
36
37
|
),
|
|
37
38
|
defaultTemplate: lines(
|
|
38
39
|
"Ops-assistant standing brief: execute mechanical operations on your coordinator's behalf: test suites, release commands such as `bun run release`, smoke and verification probes, gates, and host/version/status checks.",
|
|
39
|
-
"
|
|
40
|
+
"For any long command, start it detached in the background with stdout/stderr redirected to a log so it returns immediately and survives your turn; then call `relay_schedule_timer` with `target: \"me\"`, `afterMs` set near the expected duration, and a body telling your future self to check the log and report the exit code plus tail. End the turn after scheduling. Do not poll, tail, `ps`, or babysit the command in a loop.",
|
|
41
|
+
"On the timer wake, check the log and process result. If the command is done, report the outcome; if it is still running, reschedule once, or investigate if it is well past the expected duration.",
|
|
40
42
|
"Hold and apply session-local ops conventions the coordinator gives you. Report crisp outcomes only: command/gate, pass/fail, commit/version/host evidence, and the next blocker when one exists. Do not dump logs unless asked.",
|
|
41
|
-
"Do not make strategy, design, scope, or release-timing calls. Escalate those decisions to your spawn-parent.
|
|
43
|
+
"Do not make strategy, design, scope, or release-timing calls. Escalate those decisions to your spawn-parent.",
|
|
42
44
|
),
|
|
43
45
|
variables: [],
|
|
44
46
|
},
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { ValidationError, withResolvedProvisioning } from "../db";
|
|
3
3
|
import { cleanString, optionalEnum } from "../validation";
|
|
4
4
|
import { deleteAgentProfile, getAgentProfile, listAgentProfiles, setAgentProfile } from "../config-store";
|
|
5
|
+
import { RELAY_TOOL_GROUPS } from "../mcp";
|
|
5
6
|
import { emitConfigChanged } from "../sse";
|
|
6
7
|
import { error, isRootCredentialRequest, json, normalizeConfigPathParam, parseBody, type Handler } from "./_shared";
|
|
7
8
|
import { getComponentAuth, hasComponentScope } from "../security";
|
|
@@ -10,6 +11,14 @@ import { SPAWN_PROVIDERS, type AgentProfile, type SpawnProvider } from "../types
|
|
|
10
11
|
|
|
11
12
|
export const getAgentProfilesRoute: Handler = () => json(listAgentProfiles());
|
|
12
13
|
|
|
14
|
+
/**
|
|
15
|
+
* The canonical tool-group catalog (#970): name + member tool names, read straight off
|
|
16
|
+
* `RELAY_TOOL_GROUPS` so the dashboard multiselect can never drift from the code registry
|
|
17
|
+
* that `visibleTools` (src/mcp/index.ts) actually enforces at tools/list time.
|
|
18
|
+
*/
|
|
19
|
+
export const getAgentProfileToolGroupsRoute: Handler = () =>
|
|
20
|
+
json(Object.entries(RELAY_TOOL_GROUPS).map(([name, tools]) => ({ name, tools: [...tools] })));
|
|
21
|
+
|
|
13
22
|
export const getAgentProfileRoute: Handler = (_req, params) => {
|
|
14
23
|
const name = normalizeConfigPathParam(params.name, "name");
|
|
15
24
|
const profile = getAgentProfile(name);
|
package/src/routes/commands.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { isRecord, sanitizeFsName, type LandGateRunResult } from "agent-relay-sd
|
|
|
12
12
|
import { storeTextArtifact } from "../artifact-text";
|
|
13
13
|
import { authContextFromRequest } from "../services/auth-context";
|
|
14
14
|
import { commandAuthorizationResource as dispatchCommandAuthorizationResource, dispatchCommand } from "../services/dispatch-command";
|
|
15
|
+
import { prepareScheduledCommandResultForStorage, settleScheduledCommandTimer } from "../services/scheduler-command";
|
|
15
16
|
import { emitWorkspaceTaskSemanticNotifications } from "../services/task-lifecycle";
|
|
16
17
|
import { ServiceAuthError } from "../services/errors";
|
|
17
18
|
import { notifyBaseWorktreeStrand, notifyBranchLandFailed, notifyBranchLanded, notifyLandGateFailed, notifyLandGateWarnings } from "../branch-landed";
|
|
@@ -204,7 +205,10 @@ export const patchCommand: Handler = async (req, params) => {
|
|
|
204
205
|
const denied = authorizeRoute(req, { scope: "command:write", resource: dispatchCommandAuthorizationResource(current) });
|
|
205
206
|
if (denied) return denied;
|
|
206
207
|
const status = optionalEnum(parsed.body.status, "status", VALID_COMMAND_STATUSES);
|
|
207
|
-
|
|
208
|
+
let result = cleanParams(parsed.body.result, "result");
|
|
209
|
+
if (result && current.type === "scheduler.command") {
|
|
210
|
+
result = await prepareScheduledCommandResultForStorage(current, result);
|
|
211
|
+
}
|
|
208
212
|
const err = cleanString(parsed.body.error, "error", { max: 4000 });
|
|
209
213
|
const command = updateCommand(params.id!, { status: status as CommandStatus | undefined, result, error: err });
|
|
210
214
|
if (!command) return error("command not found", 404);
|
|
@@ -225,6 +229,9 @@ export const patchCommand: Handler = async (req, params) => {
|
|
|
225
229
|
getCompactionWatch().noteCommandSucceeded(command.type, command.id, command.target);
|
|
226
230
|
}
|
|
227
231
|
enqueueContinuationInjectionAfterSelfResume(command);
|
|
232
|
+
if (command.type === "scheduler.command") {
|
|
233
|
+
await settleScheduledCommandTimer(command);
|
|
234
|
+
}
|
|
228
235
|
if (command.type === "workspace.cleanup" && command.status === "succeeded" && isRecord(command.result)) {
|
|
229
236
|
const workspaceId = cleanString(command.result.workspaceId, "result.workspaceId", { max: 160 });
|
|
230
237
|
if (workspaceId) {
|
package/src/routes/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Auto-split from routes.ts (#299). Router: route table + matchRoute dispatch.
|
|
2
2
|
import { deleteAgentActiveMemories, deleteAgentById, findAgents, getAgentActiveMemories, getAgentById, getAgentChatHistory, getAgentContextSnapshots, getAgentCrashReportRoute, getAgentQuotaSnapshots, getAgentReplyObligations, getAgentTimelineRoute, getAgents, getMessageArtifactsRoute, getTaskArtifactsRoute, patchAgentLabel, patchAgentReady, patchAgentStatus, patchAgentTags, postHeartbeat } from "./agents";
|
|
3
|
-
import { deleteAgentProfileRoute, getAgentProfileRoute, getAgentProfilesRoute, getResolvedAgentProfileRoute, putAgentProfileRoute } from "./agent-profiles";
|
|
3
|
+
import { deleteAgentProfileRoute, getAgentProfileRoute, getAgentProfilesRoute, getAgentProfileToolGroupsRoute, getResolvedAgentProfileRoute, putAgentProfileRoute } from "./agent-profiles";
|
|
4
4
|
import { deleteAgentTerminalSession, getAgentDrive, getAgentPresence, postAgentAction, postAgentDrive, postAgentPermissionDecision, postAgentPresence, postAgentPrompt, postAgentTerminalSession } from "./agent-sessions";
|
|
5
5
|
import { deleteMyAvatar, deleteUserAvatarById, getMe, getUserAvatar, getUsers, headUserAvatar, patchMe, patchUserById, postMyAvatar, postUserAvatarById } from "./users";
|
|
6
6
|
import { deleteArtifactById, getArtifactById, getArtifactContent, getArtifacts, headArtifactContent, postArtifact } from "./artifacts";
|
|
@@ -45,7 +45,7 @@ import { getRecipeByName, getRecipeInstanceArtifacts, getRecipeInstanceById, get
|
|
|
45
45
|
import { getSettingRoute, getSettingsNamespaceRoute, getSettingsRoute, putSettingRoute, putSettingsDefaultRoute } from "./settings";
|
|
46
46
|
import { postNotificationPreviewRoute } from "./notifications";
|
|
47
47
|
import { getStewardConfigRoute, getWorkspaceConfigRoute, putStewardConfigRoute, putWorkspaceConfigRoute } from "./steward";
|
|
48
|
-
import { getTaskById, getTaskEntities, getTaskEvents, getTasks, patchTaskStatus, postClaimTask, postRenewTaskClaim, postTaskCreate, getTaskDetailRoute, getTaskMemoryRoute, getTaskHistoryRoute, patchTaskEntity, postTaskEntityStatus, postTaskEntityStage, postTaskEntityGate, postTaskDependency, deleteTaskDependency, postTaskRef, deleteTaskRef, postTaskAssign, postTaskComment, getTaskCloseBridge, postTaskDispatch } from "./tasks";
|
|
48
|
+
import { getTaskById, getTaskEntities, getTaskEvents, getTasks, patchTaskStatus, postClaimTask, postRenewTaskClaim, postTaskCreate, postTaskReconcileAutoScaffolded, getTaskDetailRoute, getTaskMemoryRoute, getTaskHistoryRoute, patchTaskEntity, postTaskEntityStatus, postTaskEntityStage, postTaskEntityGate, postTaskDependency, deleteTaskDependency, postTaskRef, deleteTaskRef, postTaskAssign, postTaskComment, getTaskCloseBridge, postTaskDispatch } from "./tasks";
|
|
49
49
|
import { getMyTeamRoute, getTeamOutcomesRoute, getTeamsRoute, postMyTeamDissolveRoute, putMyTeamBriefRoute } from "./teams";
|
|
50
50
|
import { postTeamDissolveByIdRoute } from "./teams";
|
|
51
51
|
import { deleteTeamTemplateRoute, getTeamTemplateRoute, getTeamTemplatesRoute, postTeamTemplateRoute, putTeamTemplateRoute } from "./team-templates";
|
|
@@ -235,6 +235,7 @@ const routes: Route[] = [
|
|
|
235
235
|
route("PATCH", "/api/commands/:id", patchCommand),
|
|
236
236
|
route("DELETE", "/api/commands/:id", deleteCommandById),
|
|
237
237
|
route("GET", "/api/agent-profiles", getAgentProfilesRoute),
|
|
238
|
+
route("GET", "/api/agent-profiles/tool-groups", getAgentProfileToolGroupsRoute),
|
|
238
239
|
route("GET", "/api/agent-profiles/:name/resolved", getResolvedAgentProfileRoute),
|
|
239
240
|
route("GET", "/api/agent-profiles/:name", getAgentProfileRoute),
|
|
240
241
|
route("PUT", "/api/agent-profiles/:name", putAgentProfileRoute),
|
|
@@ -353,6 +354,7 @@ const routes: Route[] = [
|
|
|
353
354
|
route("GET", "/api/task-entities", getTaskEntities),
|
|
354
355
|
route("GET", "/api/tasks", getTasks),
|
|
355
356
|
route("POST", "/api/tasks", postTaskCreate),
|
|
357
|
+
route("POST", "/api/tasks/reconcile-auto-scaffolded", postTaskReconcileAutoScaffolded),
|
|
356
358
|
route("GET", "/api/tasks/:id", getTaskById),
|
|
357
359
|
route("GET", "/api/tasks/:id/detail", getTaskDetailRoute),
|
|
358
360
|
route("GET", "/api/tasks/:id/memory", getTaskMemoryRoute),
|
package/src/routes/tasks.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { ValidationError, claimTask, getMessage, getTask, listTaskEvents, listTa
|
|
|
9
9
|
import { readTaskMemoryEnvelope } from "../db/task-memory";
|
|
10
10
|
import {
|
|
11
11
|
addTaskEntityDependency, addTaskEntityRef, assignTaskEntityAgent, createTaskEntity, getTaskEntity,
|
|
12
|
-
listTaskEntitySummaries, recordTaskEntityGate, removeTaskEntityDependency, removeTaskEntityRef, setTaskEntityStage, setTaskEntityStatus, taskEntityHistory, updateTaskEntity,
|
|
12
|
+
listTaskEntitySummaries, reconcileAutoScaffoldedTaskReaps, recordTaskEntityGate, removeTaskEntityDependency, removeTaskEntityRef, setTaskEntityStage, setTaskEntityStatus, taskEntityHistory, updateTaskEntity,
|
|
13
13
|
} from "../services/task-entity";
|
|
14
14
|
import { CommentThroughError, commentOnTaskThrough, taskCloseBridge } from "../services/task-issue-weave";
|
|
15
15
|
import { captureTaskResultMemory, injectMemoryForTaskClaim } from "../memory-service";
|
|
@@ -86,6 +86,24 @@ export const getTaskEntities: Handler = (req) => {
|
|
|
86
86
|
}));
|
|
87
87
|
};
|
|
88
88
|
|
|
89
|
+
export const postTaskReconcileAutoScaffolded: Handler = async (req) => {
|
|
90
|
+
const parsed = await parseBody<Record<string, unknown>>(req);
|
|
91
|
+
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
92
|
+
const body = parsed.body ?? {};
|
|
93
|
+
if (!isRecord(body)) return error("JSON object body required");
|
|
94
|
+
const limit = body.limit === undefined ? undefined : Number(body.limit);
|
|
95
|
+
if (limit !== undefined && (!Number.isInteger(limit) || limit < 1 || limit > 500)) return error("limit must be an integer between 1 and 500");
|
|
96
|
+
try {
|
|
97
|
+
return json(reconcileAutoScaffoldedTaskReaps({
|
|
98
|
+
dryRun: body.dryRun !== false,
|
|
99
|
+
limit,
|
|
100
|
+
actor: actorOf(req),
|
|
101
|
+
}));
|
|
102
|
+
} catch (e) {
|
|
103
|
+
return entityError(e);
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
89
107
|
// --- #834 durable Task entity (kind='deliverable') HTTP surface ---
|
|
90
108
|
|
|
91
109
|
export const postTaskCreate: Handler = async (req) => {
|
|
@@ -14,9 +14,10 @@ import { isRecord, stringValue } from "agent-relay-sdk";
|
|
|
14
14
|
import { createCommand } from "../commands-db";
|
|
15
15
|
import { emitCommandEvent } from "../command-events";
|
|
16
16
|
import { isComponentAuthorizedFor } from "../security";
|
|
17
|
+
import { listOrchestrators } from "../db";
|
|
17
18
|
import { ServiceAuthError } from "./errors";
|
|
18
19
|
import type { AuthContext } from "./auth-context";
|
|
19
|
-
import type { Command, CreateCommandInput } from "../types";
|
|
20
|
+
import type { Command, ComponentToken, CreateCommandInput, TokenConstraints } from "../types";
|
|
20
21
|
|
|
21
22
|
/** THE single home for the command authorization resource. Was duplicated in
|
|
22
23
|
* routes/commands.ts and bus.ts (diverging on `cleanString` vs `stringValue`) plus their
|
|
@@ -34,6 +35,130 @@ export function commandAuthorizationResource(input: Pick<CreateCommandInput, "ta
|
|
|
34
35
|
};
|
|
35
36
|
}
|
|
36
37
|
|
|
38
|
+
/** Does the token pin the cwd(s) a command may run in? An absent path resource field must NOT
|
|
39
|
+
* silently bypass such a constraint (the orchestrator would default it to baseDir — a full
|
|
40
|
+
* cwd-constraint escape, #968 hole 1), so we force-resolve the effective paths before the check. */
|
|
41
|
+
function hasCwdConstraint(constraints: TokenConstraints | undefined): boolean {
|
|
42
|
+
return Boolean(constraints?.cwd || constraints?.cwdPrefixes?.length);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** THE universe of param fields that can carry a host filesystem path a command executes at.
|
|
46
|
+
* A constrained token's `cwd`/`cwdPrefixes` must cover EVERY such field a command runs at — the
|
|
47
|
+
* round-1/2 fixes only ever looked at `cwd`, so commands that execute at `worktreePath`/`repoRoot`
|
|
48
|
+
* (workspace.* → execProcess/git in orchestrator/src/workspace-probe/*) escaped the constraint
|
|
49
|
+
* entirely (#968 round-3). Adding a NEW path-bearing param field means adding it here — otherwise
|
|
50
|
+
* the fail-closed check below cannot see it. */
|
|
51
|
+
const PATH_AXIS_FIELDS = ["cwd", "worktreePath", "repoRoot"] as const;
|
|
52
|
+
type PathAxisField = (typeof PATH_AXIS_FIELDS)[number];
|
|
53
|
+
|
|
54
|
+
/** Declarative map: command type → the path-bearing param field(s) it executes host work at.
|
|
55
|
+
* The orchestrator runs a shell/git at these paths (scheduler.command → control.ts; workspace.* →
|
|
56
|
+
* control.ts → workspace-probe/*). A constrained token must have EACH resolved axis inside its
|
|
57
|
+
* cwdPrefixes/cwd.
|
|
58
|
+
*
|
|
59
|
+
* A command type absent from BOTH this map and NON_PATH_COMMAND_TYPES is UNKNOWN: under a
|
|
60
|
+
* constrained token it FAILS CLOSED if it carries any PATH_AXIS_FIELD (a new host-executing type
|
|
61
|
+
* that forgot to declare its axes cannot silently bypass the constraint). This is deliberately
|
|
62
|
+
* NOT an allowlist of "types that run at cwd" — that's exactly what was too narrow twice. */
|
|
63
|
+
const COMMAND_PATH_AXES: Record<string, readonly PathAxisField[]> = {
|
|
64
|
+
"scheduler.command": ["cwd"],
|
|
65
|
+
"workspace.merge": ["worktreePath", "repoRoot"],
|
|
66
|
+
"workspace.cleanup": ["worktreePath", "repoRoot"],
|
|
67
|
+
"workspace.reconcile": ["worktreePath", "repoRoot"],
|
|
68
|
+
"workspace.pr-arm-auto-merge": ["worktreePath", "repoRoot"],
|
|
69
|
+
"workspace.pr-merge": ["worktreePath", "repoRoot"],
|
|
70
|
+
"workspace.pr-refresh": ["worktreePath", "repoRoot"],
|
|
71
|
+
"workspace.deps-refresh": ["worktreePath", "repoRoot"],
|
|
72
|
+
"workspace.idle-refresh": ["worktreePath", "repoRoot"],
|
|
73
|
+
"workspace.recovery-branch-discard": ["repoRoot"],
|
|
74
|
+
"workspace.prune": ["repoRoot"],
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** Command types that do NOT execute host filesystem work at a param-supplied path — a cwd
|
|
78
|
+
* constraint must not gate them, or we regress unrelated dispatch. agent.* operate on tmux
|
|
79
|
+
* sessions / relay state; orchestrator.migrate-provider-config only reads local config. Any OTHER
|
|
80
|
+
* unmapped type is trusted only if it carries no path field at all (see assertPathAxesWithinConstraint). */
|
|
81
|
+
const NON_PATH_COMMAND_TYPES = new Set<string>([
|
|
82
|
+
"agent.restart",
|
|
83
|
+
"agent.shutdown",
|
|
84
|
+
"orchestrator.migrate-provider-config",
|
|
85
|
+
]);
|
|
86
|
+
|
|
87
|
+
/** The cwd a dispatched command will ACTUALLY run in — the same default the orchestrator applies.
|
|
88
|
+
* If `params.cwd` is set, that's it. Otherwise the orchestrator (`orchestrator-<id>` target, or
|
|
89
|
+
* `params.orchestratorId`) defaults a missing cwd to its own `baseDir` (control.ts). We resolve
|
|
90
|
+
* that baseDir here so the auth gate evaluates the REAL cwd, not an absent (constraint-skipping)
|
|
91
|
+
* one. Returns undefined only when the effective cwd genuinely can't be determined. */
|
|
92
|
+
function resolveEffectiveCommandCwd(input: Pick<CreateCommandInput, "target" | "params">): string | undefined {
|
|
93
|
+
const params = isRecord(input.params) ? input.params : {};
|
|
94
|
+
const explicit = stringValue(params.cwd);
|
|
95
|
+
if (explicit) return explicit;
|
|
96
|
+
const orchestratorId = stringValue(params.orchestratorId)
|
|
97
|
+
?? (typeof input.target === "string" && input.target.startsWith("orchestrator-")
|
|
98
|
+
? input.target.slice("orchestrator-".length)
|
|
99
|
+
: undefined);
|
|
100
|
+
if (!orchestratorId) return undefined;
|
|
101
|
+
return listOrchestrators().find((item) => item.id === orchestratorId)?.baseDir;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Effective value of one path axis, applying the SAME defaulting the orchestrator would.
|
|
105
|
+
* Only `cwd` has a baseDir default (scheduler.command); `worktreePath`/`repoRoot` have none —
|
|
106
|
+
* an absent value stays absent and drives the fail-closed "no axis resolved" deny below. */
|
|
107
|
+
function resolveAxisValue(field: PathAxisField, input: CreateCommandInput, params: Record<string, unknown>): string | undefined {
|
|
108
|
+
const explicit = stringValue(params[field]);
|
|
109
|
+
if (explicit) return explicit;
|
|
110
|
+
if (field === "cwd") return resolveEffectiveCommandCwd(input);
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Is a single resolved path inside the token's cwd/cwdPrefixes? Reuses the exact production
|
|
115
|
+
* matcher (`constraintsAllow` → `cwdAllows`) via a cwd-only authorization resource. */
|
|
116
|
+
function pathAxisAllowed(component: ComponentToken, value: string): boolean {
|
|
117
|
+
return isComponentAuthorizedFor(component, { scope: "command:write", resource: { cwd: value } });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** #968 round-3 — the COMPREHENSIVE cwd-constraint gate. For a cwd-constrained component token,
|
|
121
|
+
* every host path the command executes at must be inside the constraint. Single choke point for
|
|
122
|
+
* BOTH transports (HTTP `POST /api/commands`, WS bus command frame). Throws to DENY. */
|
|
123
|
+
function assertPathAxesWithinConstraint(input: CreateCommandInput, component: ComponentToken): void {
|
|
124
|
+
const params = isRecord(input.params) ? input.params : {};
|
|
125
|
+
const declared = COMMAND_PATH_AXES[input.type];
|
|
126
|
+
|
|
127
|
+
if (!declared) {
|
|
128
|
+
// Unmapped type. If it carries ANY known path field, it's a host-executing command that failed
|
|
129
|
+
// to declare its axes → FAIL CLOSED. Otherwise it's a non-path command (agent.* etc.) and a cwd
|
|
130
|
+
// constraint must not gate it — but only trust that for types explicitly known non-path, or that
|
|
131
|
+
// simply carry no path field at all (so a stray unmapped type can't smuggle a path through).
|
|
132
|
+
const strayPath = PATH_AXIS_FIELDS.some((field) => stringValue(params[field]));
|
|
133
|
+
if (strayPath && !NON_PATH_COMMAND_TYPES.has(input.type)) {
|
|
134
|
+
throw new ServiceAuthError("component token lacks command scope");
|
|
135
|
+
}
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Mapped host-executing type: resolve + constrain-check each declared axis (and, defensively, any
|
|
140
|
+
// other present path field), DENY if any is out of bounds. If NONE of the axes resolve to a value,
|
|
141
|
+
// the command's run location can't be proven inside the constraint — the orchestrator would default
|
|
142
|
+
// it out of bounds — so fail closed.
|
|
143
|
+
let anyResolved = false;
|
|
144
|
+
const checked = new Set<PathAxisField>();
|
|
145
|
+
for (const field of declared) {
|
|
146
|
+
const value = resolveAxisValue(field, input, params);
|
|
147
|
+
if (!value) continue;
|
|
148
|
+
anyResolved = true;
|
|
149
|
+
checked.add(field);
|
|
150
|
+
if (!pathAxisAllowed(component, value)) throw new ServiceAuthError("component token lacks command scope");
|
|
151
|
+
}
|
|
152
|
+
for (const field of PATH_AXIS_FIELDS) {
|
|
153
|
+
if (checked.has(field)) continue;
|
|
154
|
+
const value = stringValue(params[field]);
|
|
155
|
+
if (!value) continue;
|
|
156
|
+
anyResolved = true;
|
|
157
|
+
if (!pathAxisAllowed(component, value)) throw new ServiceAuthError("component token lacks command scope");
|
|
158
|
+
}
|
|
159
|
+
if (!anyResolved) throw new ServiceAuthError("component token lacks command scope");
|
|
160
|
+
}
|
|
161
|
+
|
|
37
162
|
/** Authorize a command dispatch from the unified auth context. Mirrors BOTH old paths
|
|
38
163
|
* exactly: only a COMPONENT token carries per-resource constraints, so the gate is a no-op
|
|
39
164
|
* for admin/system/integration principals (which already cleared the coarse `command:write`
|
|
@@ -42,10 +167,16 @@ export function commandAuthorizationResource(input: Pick<CreateCommandInput, "ta
|
|
|
42
167
|
* "no component token ⇒ allow; else `isComponentAuthorizedFor(command:write, resource)`". */
|
|
43
168
|
function assertCommandAuthorized(ctx: AuthContext, input: CreateCommandInput): void {
|
|
44
169
|
if (!ctx.component) return;
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
170
|
+
const resource = commandAuthorizationResource(input);
|
|
171
|
+
// #968 round-3: a cwd-constrained token must have EVERY host path the command executes at
|
|
172
|
+
// (not just `params.cwd` — also `worktreePath`/`repoRoot`, and any future path axis) inside its
|
|
173
|
+
// constraint. Force-resolve each declared axis (applying the orchestrator's baseDir defaulting)
|
|
174
|
+
// and constrain-check it; an unmapped-but-path-bearing command fails closed. Covers both the
|
|
175
|
+
// round-1 (absent cwd) and round-2 (scheduler-only) narrowness in one declarative choke point.
|
|
176
|
+
if (hasCwdConstraint(ctx.component.constraints)) {
|
|
177
|
+
assertPathAxesWithinConstraint(input, ctx.component);
|
|
178
|
+
}
|
|
179
|
+
const ok = isComponentAuthorizedFor(ctx.component, { scope: "command:write", resource });
|
|
49
180
|
if (!ok) throw new ServiceAuthError("component token lacks command scope");
|
|
50
181
|
}
|
|
51
182
|
|