agent-relay-server 0.120.1 → 0.120.3

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.
Files changed (181) hide show
  1. package/docs/openapi.json +1 -1
  2. package/package.json +3 -3
  3. package/public/assets/MessageBubble-Cqi3XsZ7.js +2 -0
  4. package/public/assets/{MessageBubble-jpshgh3K.js.map → MessageBubble-Cqi3XsZ7.js.map} +1 -1
  5. package/public/assets/{PlanGraphPanel-CjVG-Jsq.js → PlanGraphPanel-D6Um05oW.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-CjVG-Jsq.js.map → PlanGraphPanel-D6Um05oW.js.map} +1 -1
  7. package/public/assets/{activity-DLTFF8Dz.js → activity-B0LsQHEz.js} +2 -2
  8. package/public/assets/{activity-DLTFF8Dz.js.map → activity-B0LsQHEz.js.map} +1 -1
  9. package/public/assets/{agent-profiles-BcCLIrON.js → agent-profiles-O--pe5Ic.js} +2 -2
  10. package/public/assets/{agent-profiles-BcCLIrON.js.map → agent-profiles-O--pe5Ic.js.map} +1 -1
  11. package/public/assets/{agent-type-icon-BEz393ra.js → agent-type-icon-CeEoMouY.js} +2 -2
  12. package/public/assets/{agent-type-icon-BEz393ra.js.map → agent-type-icon-CeEoMouY.js.map} +1 -1
  13. package/public/assets/agents-Ii6XlVwR.js +2 -0
  14. package/public/assets/{agents-QemvuxKO.js.map → agents-Ii6XlVwR.js.map} +1 -1
  15. package/public/assets/{analytics-DMLoMwta.js → analytics-BCzdYWRT.js} +2 -2
  16. package/public/assets/{analytics-DMLoMwta.js.map → analytics-BCzdYWRT.js.map} +1 -1
  17. package/public/assets/{automation-DACSOffp.js → automation-Crr3Xzui.js} +2 -2
  18. package/public/assets/{automation-DACSOffp.js.map → automation-Crr3Xzui.js.map} +1 -1
  19. package/public/assets/{badge-BhUZ1cAb.js → badge-DCIT3Irj.js} +2 -2
  20. package/public/assets/{badge-BhUZ1cAb.js.map → badge-DCIT3Irj.js.map} +1 -1
  21. package/public/assets/{branch-state-badge-BYE2U_y4.js → branch-state-badge-Bhw2rloy.js} +2 -2
  22. package/public/assets/{branch-state-badge-BYE2U_y4.js.map → branch-state-badge-Bhw2rloy.js.map} +1 -1
  23. package/public/assets/{button-C-V4oij1.js → button-CzdTkRIf.js} +2 -2
  24. package/public/assets/{button-C-V4oij1.js.map → button-CzdTkRIf.js.map} +1 -1
  25. package/public/assets/{card-Br1JLjTH.js → card-C3BGuVC6.js} +2 -2
  26. package/public/assets/{card-Br1JLjTH.js.map → card-C3BGuVC6.js.map} +1 -1
  27. package/public/assets/{channels-3SlDQL3j.js → channels-CKs9rvsn.js} +2 -2
  28. package/public/assets/{channels-3SlDQL3j.js.map → channels-CKs9rvsn.js.map} +1 -1
  29. package/public/assets/chat-BCwEUj5R.js +2 -0
  30. package/public/assets/chat-BCwEUj5R.js.map +1 -0
  31. package/public/assets/{connectors-CrEW591Q.js → connectors-D_kQR_vw.js} +2 -2
  32. package/public/assets/{connectors-CrEW591Q.js.map → connectors-D_kQR_vw.js.map} +1 -1
  33. package/public/assets/{copy-button-BStzXgtd.js → copy-button-B3o1jpLu.js} +2 -2
  34. package/public/assets/{copy-button-BStzXgtd.js.map → copy-button-B3o1jpLu.js.map} +1 -1
  35. package/public/assets/{dist-ChDX_t12.js → dist-B-T7lg0a.js} +2 -2
  36. package/public/assets/{dist-ChDX_t12.js.map → dist-B-T7lg0a.js.map} +1 -1
  37. package/public/assets/{dist-DV83j9tm.js → dist-B3e4B9pg.js} +2 -2
  38. package/public/assets/{dist-DV83j9tm.js.map → dist-B3e4B9pg.js.map} +1 -1
  39. package/public/assets/{dist-y03IwOrd.js → dist-B6woQ9Tb.js} +2 -2
  40. package/public/assets/{dist-y03IwOrd.js.map → dist-B6woQ9Tb.js.map} +1 -1
  41. package/public/assets/{dist-CQ0Z-TDi.js → dist-B9G83DXv.js} +2 -2
  42. package/public/assets/{dist-CQ0Z-TDi.js.map → dist-B9G83DXv.js.map} +1 -1
  43. package/public/assets/{dist-Cs8X0P1d.js → dist-BIjlN-xB.js} +2 -2
  44. package/public/assets/{dist-Cs8X0P1d.js.map → dist-BIjlN-xB.js.map} +1 -1
  45. package/public/assets/{dist-DyNJNwpi.js → dist-BInckSJC.js} +2 -2
  46. package/public/assets/{dist-DyNJNwpi.js.map → dist-BInckSJC.js.map} +1 -1
  47. package/public/assets/{dist-CmFeEXMe.js → dist-BlzVGRhh.js} +2 -2
  48. package/public/assets/{dist-CmFeEXMe.js.map → dist-BlzVGRhh.js.map} +1 -1
  49. package/public/assets/{dist-CDZZxxJq.js → dist-C7aTlosb.js} +2 -2
  50. package/public/assets/{dist-CDZZxxJq.js.map → dist-C7aTlosb.js.map} +1 -1
  51. package/public/assets/{dist-D2jvo5oA.js → dist-DGfp5dLz.js} +2 -2
  52. package/public/assets/{dist-D2jvo5oA.js.map → dist-DGfp5dLz.js.map} +1 -1
  53. package/public/assets/{dist-mm7c7nBt.js → dist-DT-OV6sQ.js} +2 -2
  54. package/public/assets/{dist-mm7c7nBt.js.map → dist-DT-OV6sQ.js.map} +1 -1
  55. package/public/assets/{dist-CBMWGhWc.js → dist-OUPTEP6q.js} +2 -2
  56. package/public/assets/{dist-CBMWGhWc.js.map → dist-OUPTEP6q.js.map} +1 -1
  57. package/public/assets/{es2015-VnZ2kVIz.js → es2015-CnKXYKpf.js} +2 -2
  58. package/public/assets/{es2015-VnZ2kVIz.js.map → es2015-CnKXYKpf.js.map} +1 -1
  59. package/public/assets/formatted-body-impl-CSRGuMcM.js +3 -0
  60. package/public/assets/formatted-body-impl-CSRGuMcM.js.map +1 -0
  61. package/public/assets/formatted-body-q_qzt1L-.js +3 -0
  62. package/public/assets/{formatted-body-Dg9R3RD7.js.map → formatted-body-q_qzt1L-.js.map} +1 -1
  63. package/public/assets/{index-D_fNJnsI.js → index-Ceiwd02X.js} +10 -10
  64. package/public/assets/index-Ceiwd02X.js.map +1 -0
  65. package/public/assets/{input-CtLT7WXi.js → input-B6Q0NWCS.js} +2 -2
  66. package/public/assets/{input-CtLT7WXi.js.map → input-B6Q0NWCS.js.map} +1 -1
  67. package/public/assets/{insights-D48n2ojF.js → insights-DUhXqZTZ.js} +2 -2
  68. package/public/assets/{insights-D48n2ojF.js.map → insights-DUhXqZTZ.js.map} +1 -1
  69. package/public/assets/{integrations-B-bdliwU.js → integrations-lbg5CjtE.js} +2 -2
  70. package/public/assets/{integrations-B-bdliwU.js.map → integrations-lbg5CjtE.js.map} +1 -1
  71. package/public/assets/{lib-ByLQqcD1.js → lib-BHsWPMKB.js} +2 -2
  72. package/public/assets/{lib-ByLQqcD1.js.map → lib-BHsWPMKB.js.map} +1 -1
  73. package/public/assets/lucide-react-DuhlF2Sj.js +10 -0
  74. package/public/assets/lucide-react-DuhlF2Sj.js.map +1 -0
  75. package/public/assets/{maintenance-LQ_U3Nqy.js → maintenance-CQgneSVf.js} +2 -2
  76. package/public/assets/{maintenance-LQ_U3Nqy.js.map → maintenance-CQgneSVf.js.map} +1 -1
  77. package/public/assets/{managed-agents-Cfh8FvY8.js → managed-agents-DsnYf6fm.js} +4 -4
  78. package/public/assets/{managed-agents-Cfh8FvY8.js.map → managed-agents-DsnYf6fm.js.map} +1 -1
  79. package/public/assets/{markdown-preview-impl-BSa7zYx1.js → markdown-preview-impl-C1ZtGJp6.js} +2 -2
  80. package/public/assets/{markdown-preview-impl-BSa7zYx1.js.map → markdown-preview-impl-C1ZtGJp6.js.map} +1 -1
  81. package/public/assets/{memory-Dr1iYKkC.js → memory-SSLUTOI2.js} +2 -2
  82. package/public/assets/{memory-Dr1iYKkC.js.map → memory-SSLUTOI2.js.map} +1 -1
  83. package/public/assets/messages-BY26OP4K.js +2 -0
  84. package/public/assets/{messages-DRKulS-2.js.map → messages-BY26OP4K.js.map} +1 -1
  85. package/public/assets/{orchestrators-BguyeViG.js → orchestrators-OY7yPzVI.js} +2 -2
  86. package/public/assets/{orchestrators-BguyeViG.js.map → orchestrators-OY7yPzVI.js.map} +1 -1
  87. package/public/assets/overview-Dj0u1tPG.js +2 -0
  88. package/public/assets/{overview-Dbq2lr7Y.js.map → overview-Dj0u1tPG.js.map} +1 -1
  89. package/public/assets/{pairs-DD7i2scF.js → pairs-CrUR50ou.js} +2 -2
  90. package/public/assets/{pairs-DD7i2scF.js.map → pairs-CrUR50ou.js.map} +1 -1
  91. package/public/assets/{projects-DEn85LQb.js → projects-B_AtxHtH.js} +2 -2
  92. package/public/assets/{projects-DEn85LQb.js.map → projects-B_AtxHtH.js.map} +1 -1
  93. package/public/assets/{prompt-settings-DwgrPKY3.js → prompt-settings-CPtYSban.js} +2 -2
  94. package/public/assets/{prompt-settings-DwgrPKY3.js.map → prompt-settings-CPtYSban.js.map} +1 -1
  95. package/public/assets/{react-dom-cLkVFBsS.js → react-dom-DWBmcawP.js} +2 -2
  96. package/public/assets/{react-dom-cLkVFBsS.js.map → react-dom-DWBmcawP.js.map} +1 -1
  97. package/public/assets/{registry-DCR3xFpe.js → registry-BgUALWn5.js} +2 -2
  98. package/public/assets/{registry-DCR3xFpe.js.map → registry-BgUALWn5.js.map} +1 -1
  99. package/public/assets/security-5lHfGk7g.js +3 -0
  100. package/public/assets/security-5lHfGk7g.js.map +1 -0
  101. package/public/assets/{select-CpyCRBK3.js → select-Cgx4QSLj.js} +2 -2
  102. package/public/assets/{select-CpyCRBK3.js.map → select-Cgx4QSLj.js.map} +1 -1
  103. package/public/assets/settings-DFGgxyun.js +6 -0
  104. package/public/assets/{settings-DYNFGC2-.js.map → settings-DFGgxyun.js.map} +1 -1
  105. package/public/assets/store-BM4zM89B.js +9 -0
  106. package/public/assets/store-BM4zM89B.js.map +1 -0
  107. package/public/assets/{task-chip-kxk4eNm1.js → task-chip-DXfvXBDx.js} +2 -2
  108. package/public/assets/{task-chip-kxk4eNm1.js.map → task-chip-DXfvXBDx.js.map} +1 -1
  109. package/public/assets/{tasks-rdy9Nsbf.js → tasks-BvH5rluu.js} +2 -2
  110. package/public/assets/{tasks-rdy9Nsbf.js.map → tasks-BvH5rluu.js.map} +1 -1
  111. package/public/assets/{teams-DHloBagJ.js → teams-VZOIOdoE.js} +2 -2
  112. package/public/assets/{teams-DHloBagJ.js.map → teams-VZOIOdoE.js.map} +1 -1
  113. package/public/assets/{terminal-viewer-impl-DWTuNejR.js → terminal-viewer-impl-DYY3Wmoe.js} +2 -2
  114. package/public/assets/{terminal-viewer-impl-DWTuNejR.js.map → terminal-viewer-impl-DYY3Wmoe.js.map} +1 -1
  115. package/public/assets/types-uVOXgvMT.js.map +1 -1
  116. package/public/assets/{use-keyboard-viewport-B06XtRUZ.js → use-keyboard-viewport-CP109w9y.js} +2 -2
  117. package/public/assets/{use-keyboard-viewport-B06XtRUZ.js.map → use-keyboard-viewport-CP109w9y.js.map} +1 -1
  118. package/public/assets/{user-avatar-DuwCFrER.js → user-avatar-Dw6mzWhv.js} +2 -2
  119. package/public/assets/{user-avatar-DuwCFrER.js.map → user-avatar-Dw6mzWhv.js.map} +1 -1
  120. package/public/assets/{work-queue-CShT7nTC.js → work-queue-B6Xk7QNe.js} +2 -2
  121. package/public/assets/{work-queue-CShT7nTC.js.map → work-queue-B6Xk7QNe.js.map} +1 -1
  122. package/public/assets/{workspaces-DjQ6EdrZ.js → workspaces-CPIzAHAe.js} +2 -2
  123. package/public/assets/{workspaces-DjQ6EdrZ.js.map → workspaces-CPIzAHAe.js.map} +1 -1
  124. package/public/index.html +23 -23
  125. package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
  126. package/src/automations/reconcile.ts +69 -4
  127. package/src/branch-landed.ts +18 -11
  128. package/src/bus.ts +25 -18
  129. package/src/db/connection.ts +53 -1
  130. package/src/db/maintenance-worker.ts +37 -0
  131. package/src/db/message-reads.ts +86 -45
  132. package/src/db/migrations.ts +1 -0
  133. package/src/db/provider-quotas.ts +6 -7
  134. package/src/db/schema.ts +2 -1
  135. package/src/maintenance/jobs.ts +19 -13
  136. package/src/maintenance/scheduler.ts +8 -3
  137. package/src/maintenance/types.ts +5 -1
  138. package/src/mcp/index.ts +5 -5
  139. package/src/mcp/tools-workspace.ts +8 -3
  140. package/src/routes/orchestrator-proxy.ts +7 -6
  141. package/src/routes/sse.ts +2 -1
  142. package/src/routes/tasks.ts +29 -0
  143. package/src/routes/workspaces.ts +9 -2
  144. package/src/runtime-tokens.ts +4 -0
  145. package/src/security.ts +31 -7
  146. package/src/server.ts +40 -18
  147. package/src/services/git-process.ts +61 -0
  148. package/src/services/git-remote.ts +87 -7
  149. package/src/services/issue-lifecycle.ts +6 -6
  150. package/src/services/project.ts +47 -1
  151. package/src/services/send-message.ts +2 -4
  152. package/src/services/spawn-agent.ts +2 -2
  153. package/src/services/task-lifecycle.ts +6 -9
  154. package/src/settings/registry.ts +6 -4
  155. package/src/spawn-targets.ts +2 -2
  156. package/src/sse.ts +131 -46
  157. package/src/steward.ts +10 -6
  158. package/src/terminal-authz.ts +50 -0
  159. package/src/token-db.ts +3 -3
  160. package/src/validation.ts +1 -1
  161. package/src/workspace-actions.ts +46 -4
  162. package/src/workspace-auto-merge.ts +120 -13
  163. package/src/workspace-orphans.ts +1 -1
  164. package/src/workspace-probe.ts +19 -10
  165. package/public/assets/MessageBubble-jpshgh3K.js +0 -2
  166. package/public/assets/agents-QemvuxKO.js +0 -2
  167. package/public/assets/chat-xKXCafN9.js +0 -2
  168. package/public/assets/chat-xKXCafN9.js.map +0 -1
  169. package/public/assets/formatted-body-Dg9R3RD7.js +0 -3
  170. package/public/assets/formatted-body-impl-DlasTD8X.js +0 -3
  171. package/public/assets/formatted-body-impl-DlasTD8X.js.map +0 -1
  172. package/public/assets/index-D_fNJnsI.js.map +0 -1
  173. package/public/assets/lucide-react-Dc3ZwCBi.js +0 -9
  174. package/public/assets/lucide-react-Dc3ZwCBi.js.map +0 -1
  175. package/public/assets/messages-DRKulS-2.js +0 -2
  176. package/public/assets/overview-Dbq2lr7Y.js +0 -2
  177. package/public/assets/security-BH8kTdA-.js +0 -3
  178. package/public/assets/security-BH8kTdA-.js.map +0 -1
  179. package/public/assets/settings-DYNFGC2-.js +0 -6
  180. package/public/assets/store-BuuJUn70.js +0 -9
  181. package/public/assets/store-BuuJUn70.js.map +0 -1
@@ -125,10 +125,15 @@ function supersededAccountKeys(provider: string, accountKey: string, rawAccountK
125
125
  return [...candidates];
126
126
  }
127
127
 
128
+ // Source priority for quota precedence: "ingest" (WHAM/API) beats "probe" (agent self-report).
129
+ const QUOTA_SOURCE_PRIORITY: Record<string, number> = { ingest: 3, header: 2, probe: 1, statusline: 0, emulated: -1 };
130
+ function quotaSourcePriority(source: string): number { return QUOTA_SOURCE_PRIORITY[source] ?? 1; }
131
+
128
132
  function newestQuotaState(rows: ProviderQuotaRow[]): string | null {
133
+ const priority = (candidate: ProviderQuotaRow) => { const source = parseJson<Partial<QuotaState>>(candidate.quota_state, { source: "probe" }).source; return quotaSourcePriority(typeof source === "string" ? source : "probe"); };
129
134
  const row = rows
130
135
  .filter((candidate) => candidate.quota_state)
131
- .sort((a, b) => (b.last_updated_at ?? 0) - (a.last_updated_at ?? 0))[0];
136
+ .sort((a, b) => priority(b) - priority(a) || (b.last_updated_at ?? 0) - (a.last_updated_at ?? 0))[0];
132
137
  return row?.quota_state ?? null;
133
138
  }
134
139
 
@@ -285,12 +290,6 @@ function storedQuotaWindowSampleTimes(quota: StoredQuotaState): Map<string, numb
285
290
  return times;
286
291
  }
287
292
 
288
- // Source priority for per-window precedence: "ingest" (WHAM/API) beats "probe" (agent self-report).
289
- const QUOTA_SOURCE_PRIORITY: Record<string, number> = { ingest: 3, header: 2, probe: 1, statusline: 0, emulated: -1 };
290
- function quotaSourcePriority(source: string): number {
291
- return QUOTA_SOURCE_PRIORITY[source] ?? 1;
292
- }
293
-
294
293
  function storedQuotaWindowSources(quota: StoredQuotaState): Map<string, string> {
295
294
  const sources = new Map<string, string>();
296
295
  const rawSources = quota._windowSource && typeof quota._windowSource === "object" ? quota._windowSource : {};
package/src/db/schema.ts CHANGED
@@ -91,7 +91,7 @@ import type {
91
91
  export function initDb(path: string = "agent-relay.db"): Database {
92
92
  const conn = new Database(path, { create: true });
93
93
  applyConnectionPragmas(conn);
94
- setDb(conn);
94
+ setDb(conn, path);
95
95
 
96
96
  getDb().run(`
97
97
  CREATE TABLE IF NOT EXISTS agents (
@@ -165,6 +165,7 @@ export function initDb(path: string = "agent-relay.db"): Database {
165
165
  -- resolved_to_agent ALTER — never inline here (same hazard as #483/#559).
166
166
  -- idx_msg_reply_to is also migration-owned because reply_to was added by ALTER.
167
167
  CREATE INDEX IF NOT EXISTS idx_msg_to_id ON messages(to_target, id);
168
+ CREATE INDEX IF NOT EXISTS idx_msg_from_id ON messages(from_agent, id);
168
169
  CREATE INDEX IF NOT EXISTS idx_msg_created ON messages(created_at);
169
170
  CREATE INDEX IF NOT EXISTS idx_msg_channel ON messages(channel);
170
171
  CREATE TABLE IF NOT EXISTS scheduled_timers (
@@ -13,14 +13,14 @@ import {
13
13
  listStaleQueuedDirectTargets,
14
14
  pruneOrphanedSharedWorkspaces,
15
15
  pruneOfflineAgents,
16
- pruneOldMessages,
16
+ pruneOldMessagesAsync,
17
17
  pruneProviderQuotaHistory,
18
18
  reapStaleAgents,
19
19
  reapStaleOrchestrators,
20
20
  releaseExpiredClaims,
21
21
  releaseExpiredDriveLeases,
22
22
  releaseOrphanedTasks,
23
- runDbMaintenance,
23
+ runDbMaintenanceAsync,
24
24
  sweepArtifacts,
25
25
  } from "../db";
26
26
  import { isAgentUnavailable } from "../db/agent-predicates";
@@ -72,6 +72,10 @@ import type { MaintenanceJobDefinition } from "./types";
72
72
 
73
73
  let dbMaintenanceRuns = 0;
74
74
 
75
+ function isFleetIdleForVacuum(): boolean {
76
+ return !listAgents({ status: "busy" }).some((agent) => agent.kind === "provider" && agent.id !== "user" && agent.id !== "system");
77
+ }
78
+
75
79
  function isManagedBusyWatchCandidate(agent: ReturnType<typeof listAgents>[number]): boolean {
76
80
  if (agent.kind !== "provider") return false;
77
81
  if (agent.status !== "busy") return false;
@@ -315,17 +319,17 @@ export const definitions: MaintenanceJobDefinition[] = [
315
319
  description: "Stop spawned sessions whose relay agent is offline/gone but whose process the orchestrator still reports running, after a grace period for self-heal.",
316
320
  intervalMs: ORPHAN_REAPER_INTERVAL_MS,
317
321
  runOnStart: false,
318
- handler: reapOrphanedSessions,
322
+ handler: () => reapOrphanedSessions(),
319
323
  },
320
- { id: "transient-agent-reaper", title: "Transient agent reaper", description: "Cleanly shut down transient spawned agents once they are idle and any isolated branch work has landed.", intervalMs: REAP_INTERVAL_MS, runOnStart: false, handler: reapTransientAgents },
321
- { id: "rate-limit-resume", title: "Rate-limit resume", description: "Auto-resume agents held on a provider usage/rate-limit stall once their reset window has passed (#286).", intervalMs: REAP_INTERVAL_MS, runOnStart: false, handler: resumeRateLimitedAgents },
324
+ { id: "transient-agent-reaper", title: "Transient agent reaper", description: "Cleanly shut down transient spawned agents once they are idle and any isolated branch work has landed.", intervalMs: REAP_INTERVAL_MS, runOnStart: false, handler: () => reapTransientAgents() },
325
+ { id: "rate-limit-resume", title: "Rate-limit resume", description: "Auto-resume agents held on a provider usage/rate-limit stall once their reset window has passed (#286).", intervalMs: REAP_INTERVAL_MS, runOnStart: false, handler: () => resumeRateLimitedAgents() },
322
326
  {
323
327
  id: "stuck-busy-watchdog",
324
328
  title: "Stuck busy watchdog",
325
329
  description: "Warn when a managed provider stays busy past the threshold without provider progress (#53).",
326
330
  intervalMs: REAP_INTERVAL_MS,
327
331
  runOnStart: false,
328
- handler: runStuckBusyWatchdog,
332
+ handler: () => runStuckBusyWatchdog(),
329
333
  },
330
334
  {
331
335
  id: "team-reap",
@@ -341,7 +345,7 @@ export const definitions: MaintenanceJobDefinition[] = [
341
345
  description: "Gently ping a team's current coordinator once when the team has had no busy members for the configured threshold.",
342
346
  intervalMs: REAP_INTERVAL_MS,
343
347
  runOnStart: false,
344
- handler: runTeamIdleWatchdog,
348
+ handler: () => runTeamIdleWatchdog(),
345
349
  },
346
350
  {
347
351
  id: "orchestrator-reaper",
@@ -373,9 +377,9 @@ export const definitions: MaintenanceJobDefinition[] = [
373
377
  description: "Delete messages outside the configured retention window.",
374
378
  intervalMs: DAY_MS,
375
379
  runOnStart: false,
376
- handler() {
380
+ handler(context) {
377
381
  const retentionDays = Number(process.env.RETENTION_DAYS) || 30;
378
- return { prunedMessages: pruneOldMessages(retentionDays * DAY_MS) };
382
+ return pruneOldMessagesAsync(retentionDays * DAY_MS, { signal: context?.signal });
379
383
  },
380
384
  },
381
385
  {
@@ -385,10 +389,12 @@ export const definitions: MaintenanceJobDefinition[] = [
385
389
  intervalMs: DB_MAINTENANCE_INTERVAL_MS,
386
390
  runOnStart: false,
387
391
  timeoutMs: 5 * 60 * 1000,
388
- handler() {
392
+ async handler(context) {
389
393
  dbMaintenanceRuns += 1;
390
- const vacuum = DB_VACUUM_EVERY > 0 && dbMaintenanceRuns % DB_VACUUM_EVERY === 0;
391
- return runDbMaintenance({ vacuum });
394
+ const vacuumDue = DB_VACUUM_EVERY > 0 && dbMaintenanceRuns % DB_VACUUM_EVERY === 0;
395
+ const vacuum = vacuumDue && isFleetIdleForVacuum();
396
+ const result = await runDbMaintenanceAsync({ vacuum, signal: context?.signal });
397
+ return vacuumDue && !vacuum ? { ...result, vacuumSkipped: "fleet-busy" } : result;
392
398
  },
393
399
  },
394
400
  {
@@ -492,7 +498,7 @@ export const definitions: MaintenanceJobDefinition[] = [
492
498
  intervalMs: 60 * 1000,
493
499
  runOnStart: false,
494
500
  timeoutMs: 10 * 1000,
495
- handler: runAutoMergeWatchdog,
501
+ handler: () => runAutoMergeWatchdog(),
496
502
  },
497
503
  {
498
504
  id: "workspace-idle-refresh",
@@ -118,8 +118,9 @@ async function runJob(definition: MaintenanceJobDefinition, options: { force?: b
118
118
  return { id: definition.id, status: "skipped", startedAt, finishedAt: Date.now(), durationMs: 0, result: { reason: "not due or already running", state } };
119
119
  }
120
120
 
121
+ const abortController = new AbortController();
121
122
  try {
122
- const result = await withTimeout(Promise.resolve(definition.handler()), timeoutMs);
123
+ const result = await withTimeout(Promise.resolve(definition.handler({ signal: abortController.signal })), timeoutMs, abortController);
123
124
  const finishedAt = Date.now();
124
125
  const durationMs = finishedAt - startedAt;
125
126
  db.query(`
@@ -145,10 +146,14 @@ async function runJob(definition: MaintenanceJobDefinition, options: { force?: b
145
146
  }
146
147
  }
147
148
 
148
- async function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
149
+ async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, abortController?: AbortController): Promise<T> {
149
150
  let timeout: Timer | undefined;
150
151
  const timeoutPromise = new Promise<never>((_, reject) => {
151
- timeout = setTimeout(() => reject(new Error(`maintenance job timed out after ${timeoutMs}ms`)), timeoutMs);
152
+ timeout = setTimeout(() => {
153
+ const error = new Error(`maintenance job timed out after ${timeoutMs}ms`);
154
+ abortController?.abort(error);
155
+ reject(error);
156
+ }, timeoutMs);
152
157
  });
153
158
  try {
154
159
  return await Promise.race([promise, timeoutPromise]);
@@ -1,5 +1,9 @@
1
1
  import type { MaintenanceJob } from "../types";
2
2
 
3
+ export interface MaintenanceJobContext {
4
+ signal: AbortSignal;
5
+ }
6
+
3
7
  export interface MaintenanceJobDefinition {
4
8
  id: string;
5
9
  title: string;
@@ -7,7 +11,7 @@ export interface MaintenanceJobDefinition {
7
11
  intervalMs: number;
8
12
  timeoutMs?: number;
9
13
  runOnStart?: boolean;
10
- handler: () => Promise<Record<string, unknown>> | Record<string, unknown>;
14
+ handler: (context?: MaintenanceJobContext) => Promise<Record<string, unknown>> | Record<string, unknown>;
11
15
  }
12
16
 
13
17
  export interface MaintenanceJobRow {
package/src/mcp/index.ts CHANGED
@@ -167,11 +167,11 @@ async function callTool(auth: McpAuthContext, params: unknown): Promise<Record<s
167
167
  else if (name === "relay_agent_action") result = relayAgentAction(auth, args);
168
168
  else if (name === "relay_workspace_status") result = await relayWorkspaceStatus(auth, args);
169
169
  else if (name === "relay_workspace_list") result = relayWorkspaceList(auth, args);
170
- else if (name === "relay_workspace_ready") result = relayWorkspaceMutation(auth, "ready", args);
171
- else if (name === "relay_workspace_deps") result = relayWorkspaceMutation(auth, "deps-refresh", args);
172
- else if (name === "relay_workspace_claim") result = relayWorkspaceMutation(auth, "claim", args);
173
- else if (name === "relay_workspace_release") result = relayWorkspaceMutation(auth, "release-claim", args);
174
- else if (name === "relay_workspace_land") result = relayWorkspaceMutation(auth, "merge", args);
170
+ else if (name === "relay_workspace_ready") result = await relayWorkspaceMutation(auth, "ready", args);
171
+ else if (name === "relay_workspace_deps") result = await relayWorkspaceMutation(auth, "deps-refresh", args);
172
+ else if (name === "relay_workspace_claim") result = await relayWorkspaceMutation(auth, "claim", args);
173
+ else if (name === "relay_workspace_release") result = await relayWorkspaceMutation(auth, "release-claim", args);
174
+ else if (name === "relay_workspace_land") result = await relayWorkspaceMutation(auth, "merge", args);
175
175
  else if (name.startsWith("relay_merge_")) result = relayMergeTool(auth, name, args); else if (name.startsWith("relay_team_")) result = await relayTeamTool(auth, name, args);
176
176
  else if (name.startsWith("relay_plan_")) result = relayPlanTool(auth, name, args);
177
177
  else if (name.startsWith("relay_task_memory_")) result = relayTaskMemoryTool(auth, name, args);
@@ -4,6 +4,7 @@ import { McpAuthError, McpNotFoundError } from "../mcp-errors";
4
4
  import { AUTO_MERGE_POLICIES } from "../workspace-merge";
5
5
  import { describeWorkspacePhase, landReceipt, readyContract } from "../workspace-phase";
6
6
  import { LAND_STRATEGIES, applyWorkspaceAction, waitForWorkspaceStatus, type WorkspaceAction } from "../workspace-actions";
7
+ import { refreshWorkspaceGitState } from "../workspace-probe";
7
8
  import { optionalEnum } from "../validation";
8
9
  import type { Command, WorkspaceAutoMergePolicy, WorkspaceMergeStrategy, WorkspaceRecord } from "../types";
9
10
  import type { McpAuthContext, ToolDefinition } from "./types";
@@ -185,9 +186,13 @@ export function relayWorkspaceList(auth: McpAuthContext, args: Record<string, un
185
186
  return { workspaces, count: workspaces.length };
186
187
  }
187
188
 
188
- export function relayWorkspaceMutation(auth: McpAuthContext, action: WorkspaceAction, args: Record<string, unknown>): Record<string, unknown> {
189
- const ws = resolveWorkspaceForCaller(auth, args);
190
- const result = applyWorkspaceAction(ws, {
189
+ export async function relayWorkspaceMutation(auth: McpAuthContext, action: WorkspaceAction, args: Record<string, unknown>): Promise<Record<string, unknown>> {
190
+ let ws = resolveWorkspaceForCaller(auth, args);
191
+ if (action === "ready" || action === "request-review") {
192
+ await refreshWorkspaceGitState(ws);
193
+ ws = getWorkspace(ws.id) ?? ws;
194
+ }
195
+ const result = await applyWorkspaceAction(ws, {
191
196
  action,
192
197
  agentId: callerAgentId(auth) ?? auth.actor,
193
198
  detail: optionalString(args.detail, "detail", 4000),
@@ -6,6 +6,7 @@ import { cleanStringArray } from "../validation";
6
6
  import { emitOrchestratorStatus } from "../sse";
7
7
  import { type OrchestratorRuntimeInput, type SpawnProvider } from "../types";
8
8
  import { relayToken } from "../config";
9
+ import { isTerminalSessionRequestAuthorized } from "../terminal-authz";
9
10
 
10
11
  export const getOrchestratorDirectories: Handler = async (req, params) => {
11
12
  const orch = getOrchestrator(params.id!);
@@ -146,16 +147,16 @@ export const getOrchestratorLogs: Handler = (req, params) => {
146
147
  };
147
148
 
148
149
  export const getOrchestratorTerminal: Handler = (req, params) => {
149
- const denied = authorizeRoute(req, { scope: "terminal:attach", resource: { orchestratorId: params.id!, terminalAttach: true } });
150
- return denied ?? proxyOrchestratorGet(req, params.id!, `/api/terminal/${encodeURIComponent(params.session!)}`);
150
+ if (!isTerminalSessionRequestAuthorized(req, params.id!, params.session!, "read")) return error("forbidden", 403);
151
+ return proxyOrchestratorGet(req, params.id!, `/api/terminal/${encodeURIComponent(params.session!)}`);
151
152
  };
152
153
 
153
154
  export const postOrchestratorTerminalInput: Handler = (req, params) => {
154
- const denied = authorizeRoute(req, { scope: "terminal:attach", resource: { orchestratorId: params.id!, terminalAttach: true } });
155
- return denied ?? proxyOrchestratorPost(req, params.id!, `/api/terminal/${encodeURIComponent(params.session!)}/input`);
155
+ if (!isTerminalSessionRequestAuthorized(req, params.id!, params.session!, "write")) return error("forbidden", 403);
156
+ return proxyOrchestratorPost(req, params.id!, `/api/terminal/${encodeURIComponent(params.session!)}/input`);
156
157
  };
157
158
 
158
159
  export const postOrchestratorTerminalResize: Handler = (req, params) => {
159
- const denied = authorizeRoute(req, { scope: "terminal:attach", resource: { orchestratorId: params.id!, terminalAttach: true } });
160
- return denied ?? proxyOrchestratorPost(req, params.id!, `/api/terminal/${encodeURIComponent(params.session!)}/resize`);
160
+ if (!isTerminalSessionRequestAuthorized(req, params.id!, params.session!, "read")) return error("forbidden", 403);
161
+ return proxyOrchestratorPost(req, params.id!, `/api/terminal/${encodeURIComponent(params.session!)}/resize`);
161
162
  };
package/src/routes/sse.ts CHANGED
@@ -5,5 +5,6 @@ import { type Handler } from "./_shared";
5
5
  export const getEvents: Handler = (req) => {
6
6
  const url = new URL(req.url);
7
7
  const agentId = url.searchParams.get("for") || null;
8
- return createSSEStream(agentId);
8
+ const lastEventId = Number(req.headers.get("last-event-id") ?? "");
9
+ return createSSEStream(agentId, Number.isSafeInteger(lastEventId) && lastEventId > 0 ? lastEventId : 0);
9
10
  };
@@ -29,6 +29,15 @@ function taskDetailOr404(id: number) {
29
29
  return detail ? json(detail) : error("deliverable task not found", 404);
30
30
  }
31
31
 
32
+ function requireTaskMutationAuth(req: Request, id: number): Response | null {
33
+ if (!getTaskEntity(id)) return error("deliverable task not found", 404);
34
+ if (isFullReachRequest(req)) return null;
35
+ const component = getComponentAuth(req);
36
+ const agentId = component ? agentIdFromComponent(component) : undefined;
37
+ if (!isTaskReadAuthorized(id, agentId, component?.scope ?? [])) return error("forbidden", 403);
38
+ return null;
39
+ }
40
+
32
41
  function normalizeTaskStatusInput(body: unknown): TaskStatusInput {
33
42
  if (!isRecord(body)) throw new ValidationError("JSON object body required");
34
43
  const status = optionalEnum(body.status, "status", VALID_TASK_STATUSES);
@@ -173,6 +182,8 @@ export const getTaskHistoryRoute: Handler = (req, params) => {
173
182
  export const patchTaskEntity: Handler = async (req, params) => {
174
183
  const id = parseId(params.id);
175
184
  if (id === null) return error("invalid task id");
185
+ const forbidden = requireTaskMutationAuth(req, id);
186
+ if (forbidden) return forbidden;
176
187
  const parsed = await parseBody<Record<string, unknown>>(req);
177
188
  if (!parsed.ok) return error(parsed.error, parsed.status);
178
189
  const body = parsed.body;
@@ -200,6 +211,8 @@ export const patchTaskEntity: Handler = async (req, params) => {
200
211
  export const postTaskEntityStatus: Handler = async (req, params) => {
201
212
  const id = parseId(params.id);
202
213
  if (id === null) return error("invalid task id");
214
+ const forbidden = requireTaskMutationAuth(req, id);
215
+ if (forbidden) return forbidden;
203
216
  const parsed = await parseBody<Record<string, unknown>>(req);
204
217
  if (!parsed.ok) return error(parsed.error, parsed.status);
205
218
  const body = parsed.body;
@@ -220,6 +233,8 @@ export const postTaskEntityStatus: Handler = async (req, params) => {
220
233
  export const postTaskEntityStage: Handler = async (req, params) => {
221
234
  const id = parseId(params.id);
222
235
  if (id === null) return error("invalid task id");
236
+ const forbidden = requireTaskMutationAuth(req, id);
237
+ if (forbidden) return forbidden;
223
238
  const parsed = await parseBody<Record<string, unknown>>(req);
224
239
  if (!parsed.ok) return error(parsed.error, parsed.status);
225
240
  const body = parsed.body;
@@ -240,6 +255,8 @@ export const postTaskEntityStage: Handler = async (req, params) => {
240
255
  export const postTaskEntityGate: Handler = async (req, params) => {
241
256
  const id = parseId(params.id);
242
257
  if (id === null) return error("invalid task id");
258
+ const forbidden = requireTaskMutationAuth(req, id);
259
+ if (forbidden) return forbidden;
243
260
  const parsed = await parseBody<Record<string, unknown>>(req);
244
261
  if (!parsed.ok) return error(parsed.error, parsed.status);
245
262
  const body = parsed.body;
@@ -261,6 +278,8 @@ export const postTaskEntityGate: Handler = async (req, params) => {
261
278
  export const postTaskDependency: Handler = async (req, params) => {
262
279
  const id = parseId(params.id);
263
280
  if (id === null) return error("invalid task id");
281
+ const forbidden = requireTaskMutationAuth(req, id);
282
+ if (forbidden) return forbidden;
264
283
  const parsed = await parseBody<{ dependsOnTaskId?: number }>(req);
265
284
  if (!parsed.ok) return error(parsed.error, parsed.status);
266
285
  const dep = parseId(String(parsed.body?.dependsOnTaskId));
@@ -276,6 +295,8 @@ export const postTaskDependency: Handler = async (req, params) => {
276
295
  export const deleteTaskDependency: Handler = async (req, params) => {
277
296
  const id = parseId(params.id);
278
297
  if (id === null) return error("invalid task id");
298
+ const forbidden = requireTaskMutationAuth(req, id);
299
+ if (forbidden) return forbidden;
279
300
  const parsed = await parseBody<{ dependsOnTaskId?: number }>(req);
280
301
  if (!parsed.ok) return error(parsed.error, parsed.status);
281
302
  const dep = parseId(String(parsed.body?.dependsOnTaskId));
@@ -287,6 +308,8 @@ export const deleteTaskDependency: Handler = async (req, params) => {
287
308
  export const postTaskRef: Handler = async (req, params) => {
288
309
  const id = parseId(params.id);
289
310
  if (id === null) return error("invalid task id");
311
+ const forbidden = requireTaskMutationAuth(req, id);
312
+ if (forbidden) return forbidden;
290
313
  const parsed = await parseBody<Record<string, unknown>>(req);
291
314
  if (!parsed.ok) return error(parsed.error, parsed.status);
292
315
  const body = parsed.body;
@@ -308,6 +331,8 @@ export const postTaskRef: Handler = async (req, params) => {
308
331
  export const deleteTaskRef: Handler = async (req, params) => {
309
332
  const id = parseId(params.id);
310
333
  if (id === null) return error("invalid task id");
334
+ const forbidden = requireTaskMutationAuth(req, id);
335
+ if (forbidden) return forbidden;
311
336
  const parsed = await parseBody<Record<string, unknown>>(req);
312
337
  if (!parsed.ok) return error(parsed.error, parsed.status);
313
338
  const body = parsed.body;
@@ -328,6 +353,8 @@ export const deleteTaskRef: Handler = async (req, params) => {
328
353
  export const postTaskAssign: Handler = async (req, params) => {
329
354
  const id = parseId(params.id);
330
355
  if (id === null) return error("invalid task id");
356
+ const forbidden = requireTaskMutationAuth(req, id);
357
+ if (forbidden) return forbidden;
331
358
  const parsed = await parseBody<Record<string, unknown>>(req);
332
359
  if (!parsed.ok) return error(parsed.error, parsed.status);
333
360
  const body = parsed.body;
@@ -345,6 +372,8 @@ export const postTaskAssign: Handler = async (req, params) => {
345
372
  export const postTaskComment: Handler = async (req, params) => {
346
373
  const id = parseId(params.id);
347
374
  if (id === null) return error("invalid task id");
375
+ const forbidden = requireTaskMutationAuth(req, id);
376
+ if (forbidden) return forbidden;
348
377
  const parsed = await parseBody<Record<string, unknown>>(req);
349
378
  if (!parsed.ok) return error(parsed.error, parsed.status);
350
379
  const body = parsed.body;
@@ -16,6 +16,7 @@ import { workspaceActiveClaim } from "../workspace-claim";
16
16
  import { resolveCoordinatorAgentId } from "../steward-identity";
17
17
  import { relayToken } from "../config";
18
18
  import { workspaceOwnerOrchestrator } from "../workspace-host";
19
+ import { refreshWorkspaceGitState } from "../workspace-probe";
19
20
 
20
21
  export const getWorkspaces: Handler = (req) => {
21
22
  try {
@@ -423,7 +424,7 @@ export const postWorkspaceAction: Handler = async (req, params) => {
423
424
  if (!parsed.ok) return error(parsed.error, parsed.status);
424
425
  try {
425
426
  if (!isRecord(parsed.body)) return error("body required");
426
- const workspace = getWorkspace(params.id!);
427
+ let workspace = getWorkspace(params.id!);
427
428
  if (!workspace) return error("workspace not found", 404);
428
429
  const action = optionalEnum(parsed.body.action, "action", WORKSPACE_ACTIONS);
429
430
  if (!action) return error("action required", 400);
@@ -464,7 +465,13 @@ export const postWorkspaceAction: Handler = async (req, params) => {
464
465
  // Everything else delegates to the shared core (one home, shared with the
465
466
  // relay_workspace_* MCP tools); the core self-audits and returns any command to
466
467
  // emit, mirroring requestWorkspaceMerge's "caller emits" contract.
467
- const result = applyWorkspaceAction(workspace, {
468
+ if (action === "ready" || action === "request-review") {
469
+ await refreshWorkspaceGitState(workspace);
470
+ const refreshed = getWorkspace(workspace.id);
471
+ if (refreshed) workspace = refreshed;
472
+ }
473
+
474
+ const result = await applyWorkspaceAction(workspace, {
468
475
  action,
469
476
  agentId,
470
477
  detail: cleanString(parsed.body.detail, "detail", { max: 4000 }),
@@ -82,6 +82,8 @@ export function issueOrchestratorRuntimeToken(input: {
82
82
  cwdPrefixes: [input.baseDir],
83
83
  logsRead: true,
84
84
  terminalAttach: true,
85
+ terminalRead: true,
86
+ terminalWrite: true,
85
87
  },
86
88
  createdBy: input.createdBy ?? "runtime",
87
89
  });
@@ -190,6 +192,8 @@ export function issueInteractiveRunnerRuntimeToken(input: {
190
192
  agents: [agentId],
191
193
  cwdPrefixes: [input.cwd],
192
194
  terminalAttach: false,
195
+ terminalRead: false,
196
+ terminalWrite: false,
193
197
  logsRead: false,
194
198
  canDelegate: false,
195
199
  },
package/src/security.ts CHANGED
@@ -104,6 +104,9 @@ type AuthorizationResource = {
104
104
  spawnRequestId?: string;
105
105
  integrationName?: string;
106
106
  terminalAttach?: boolean;
107
+ terminalRead?: boolean;
108
+ terminalWrite?: boolean;
109
+ terminalOwner?: boolean;
107
110
  logsRead?: boolean;
108
111
  };
109
112
 
@@ -147,7 +150,7 @@ export function isRequestAuthorizedFor(req: Request, check: AuthorizationCheck):
147
150
  }
148
151
 
149
152
  export function isComponentAuthorizedFor(auth: ComponentToken, check: AuthorizationCheck): boolean {
150
- return hasComponentScope(auth, check.scope) && constraintsAllow(auth.constraints, check.resource);
153
+ return hasComponentScope(auth, check.scope) && constraintsAllow(auth, check.resource);
151
154
  }
152
155
 
153
156
  export function requiredScopeFor(method: string, pathname: string): string | null {
@@ -160,7 +163,8 @@ export function requiredScopeFor(method: string, pathname: string): string | nul
160
163
  if (pathname === "/api/orchestrators/bootstrap/exchange") return "token:write";
161
164
  if (pathname === "/api/orchestrators/bootstrap") return "token:write";
162
165
  if (pathname.match(/^\/api\/orchestrators\/[^/]+\/logs\//)) return "logs:read";
163
- if (pathname.match(/^\/api\/orchestrators\/[^/]+\/terminal\//)) return "terminal:attach";
166
+ if (pathname.match(/^\/api\/orchestrators\/[^/]+\/terminal\/[^/]+\/input$/)) return "terminal:write";
167
+ if (pathname.match(/^\/api\/orchestrators\/[^/]+\/terminal\//)) return "terminal:read";
164
168
  const settingScope = requiredSettingsScopeFor(method, pathname);
165
169
  if (settingScope !== undefined) return settingScope;
166
170
  if (pathname.startsWith("/api/artifacts")) {
@@ -259,7 +263,8 @@ export function requiredComponentScopeFor(method: string, pathname: string): str
259
263
  if (pathname === "/api/events") return "message:read";
260
264
  if (pathname === "/api/mcp") return "mcp:use";
261
265
  if (pathname.match(/^\/api\/orchestrators\/[^/]+\/logs\//)) return "logs:read";
262
- if (pathname.match(/^\/api\/orchestrators\/[^/]+\/terminal\//)) return "terminal:attach";
266
+ if (pathname.match(/^\/api\/orchestrators\/[^/]+\/terminal\/[^/]+\/input$/)) return "terminal:write";
267
+ if (pathname.match(/^\/api\/orchestrators\/[^/]+\/terminal\//)) return "terminal:read";
263
268
  const settingScope = requiredSettingsScopeFor(method, pathname);
264
269
  if (settingScope !== undefined) return settingScope ?? "system:admin";
265
270
  if (pathname.startsWith("/api/commands")) {
@@ -519,10 +524,12 @@ export function resolveTeamLineage(constraints: TokenConstraints | undefined): s
519
524
  return constraints?.teamId;
520
525
  }
521
526
 
522
- function constraintsAllow(constraints: TokenConstraints | undefined, resource: AuthorizationResource | undefined): boolean {
523
- if (!constraints || !resource) return true;
527
+ function constraintsAllow(auth: ComponentToken, resource: AuthorizationResource | undefined): boolean {
528
+ const constraints = auth.constraints;
529
+ if (!resource) return true;
530
+ if (!constraints) return resource.terminalOwner ? terminalOwnerAllowed(auth, undefined, resource) : true;
524
531
  const hasConstraints = Object.keys(constraints).length > 0;
525
- if (!hasConstraints) return true;
532
+ if (!hasConstraints && !resource.terminalOwner) return true;
526
533
  if (resource.agentId && !listAllows(constraints.agents, resource.agentId)) return false;
527
534
  if (resource.policyName && !listAllows(constraints.policies, resource.policyName)) return false;
528
535
  if (resource.channel && !listAllows(constraints.channels, resource.channel)) return false;
@@ -533,10 +540,27 @@ function constraintsAllow(constraints: TokenConstraints | undefined, resource: A
533
540
  if (resource.cwd && !cwdAllows(constraints, resource.cwd)) return false;
534
541
  if (resource.target && !targetAllows(constraints, resource.target)) return false;
535
542
  if (resource.terminalAttach && constraints.terminalAttach !== true) return false;
543
+ if (resource.terminalRead && constraints.terminalRead !== true) return false;
544
+ if (resource.terminalWrite && constraints.terminalWrite !== true) return false;
536
545
  if (resource.logsRead && constraints.logsRead !== true) return false;
546
+ if (resource.terminalOwner && !terminalOwnerAllowed(auth, constraints, resource)) return false;
537
547
  return true;
538
548
  }
539
549
 
550
+ function terminalOwnerAllowed(auth: ComponentToken, constraints: TokenConstraints | undefined, resource: AuthorizationResource): boolean {
551
+ if (hasScope(auth.scope, "system:admin")) return true;
552
+ if (auth.role === "orchestrator") {
553
+ return !resource.orchestratorId || listAllows(constraints?.orchestrators, resource.orchestratorId);
554
+ }
555
+ if (!constraints) return false;
556
+ if (resource.agentId && constraints.agents?.includes(resource.agentId)) return true;
557
+ if (resource.policyName && constraints.policies?.includes(resource.policyName)) return true;
558
+ if (resource.spawnRequestId && constraints.spawnRequestIds?.includes(resource.spawnRequestId)) return true;
559
+ if (resource.agentId && constraints.targets?.some((target) => target === resource.agentId || target === `agent:${resource.agentId}`)) return true;
560
+ if (resource.policyName && constraints.targets?.includes(`policy:${resource.policyName}`)) return true;
561
+ return false;
562
+ }
563
+
540
564
  function targetAllows(constraints: TokenConstraints, target: string): boolean {
541
565
  const hasTargetConstraints = Boolean(constraints.targets?.length || constraints.policies?.length || constraints.agents?.length);
542
566
  if (constraints.targets?.includes(target)) return true;
@@ -564,7 +588,7 @@ function isTokenConstraints(value: unknown): value is TokenConstraints {
564
588
  if (!value || typeof value !== "object" || Array.isArray(value)) return false;
565
589
  const record = value as Record<string, unknown>;
566
590
  for (const [key, item] of Object.entries(record)) {
567
- if (["terminalAttach", "logsRead", "canDelegate"].includes(key)) {
591
+ if (["terminalAttach", "terminalRead", "terminalWrite", "logsRead", "canDelegate"].includes(key)) {
568
592
  if (typeof item !== "boolean") return false;
569
593
  } else if (key === "cwd" || key === "spawnedBy" || key === "teamId" || key === "projectId" || key === "profile" || key === "tenantId") {
570
594
  if (typeof item !== "string") return false;
package/src/server.ts CHANGED
@@ -15,6 +15,7 @@ import { startPlanLiveBindings } from "./services/plan-live-bindings";
15
15
  import { startRelayScheduler } from "./services/scheduler";
16
16
  import { recordWorkspaceIssueActivity } from "./services/spawn-issue-activity";
17
17
  import { onWorkspaceCreate } from "./db";
18
+ import { isTerminalSessionRequestAuthorized } from "./terminal-authz";
18
19
  import { resolve, sep } from "path";
19
20
  import { gzipSync, brotliCompressSync, constants as zlibConstants } from "node:zlib";
20
21
  import {
@@ -35,7 +36,6 @@ import {
35
36
  isAuthorized,
36
37
  isScopedRequestAuthorized,
37
38
  isOriginAllowed,
38
- isRequestAuthorizedFor,
39
39
  unauthorized,
40
40
  } from "./security";
41
41
  import { startMaintenanceScheduler } from "./maintenance";
@@ -93,20 +93,7 @@ export function startServer(): void {
93
93
  hostname: HOST,
94
94
  idleTimeout: IDLE_TIMEOUT_SECONDS,
95
95
  fetch: createFetchHandler({ publicDir: process.env.PUBLIC_DIR, logRequests: LOG_REQUESTS }),
96
- websocket: {
97
- open(ws) {
98
- if (ws.data?.kind === "terminal-proxy") return openTerminalProxy(ws as TerminalProxySocket);
99
- return busHandleOpen(ws as any);
100
- },
101
- message(ws, data) {
102
- if (ws.data?.kind === "terminal-proxy") return sendTerminalProxyFrame(ws as TerminalProxySocket, data);
103
- return busHandleMessage(ws as any, data);
104
- },
105
- close(ws) {
106
- if (ws.data?.kind === "terminal-proxy") return closeTerminalProxy(ws as TerminalProxySocket);
107
- return busHandleClose(ws as any);
108
- },
109
- },
96
+ websocket: createWebSocketHandlers(),
110
97
  });
111
98
 
112
99
  console.log(`agent-relay ${VERSION} running on http://${HOST}:${PORT}`);
@@ -117,6 +104,7 @@ interface TerminalProxySocketData {
117
104
  upstreamUrl: string;
118
105
  upstreamToken?: string;
119
106
  upstream?: WebSocket;
107
+ writeAuthorized: boolean;
120
108
  pending: Array<string | Buffer>;
121
109
  }
122
110
 
@@ -128,6 +116,23 @@ type RelaySocketData = {
128
116
 
129
117
  type TerminalProxySocket = ServerWebSocket<TerminalProxySocketData>;
130
118
 
119
+ export function createWebSocketHandlers() {
120
+ return {
121
+ open(ws: ServerWebSocket<RelaySocketData>) {
122
+ if (ws.data?.kind === "terminal-proxy") return openTerminalProxy(ws as TerminalProxySocket);
123
+ return busHandleOpen(ws as any);
124
+ },
125
+ message(ws: ServerWebSocket<RelaySocketData>, data: string | Buffer) {
126
+ if (ws.data?.kind === "terminal-proxy") return sendTerminalProxyFrame(ws as TerminalProxySocket, data);
127
+ return busHandleMessage(ws as any, data);
128
+ },
129
+ close(ws: ServerWebSocket<RelaySocketData>) {
130
+ if (ws.data?.kind === "terminal-proxy") return closeTerminalProxy(ws as TerminalProxySocket);
131
+ return busHandleClose(ws as any);
132
+ },
133
+ };
134
+ }
135
+
131
136
  function emitAutomationDispatches(results: AutomationDispatchResult[]): void {
132
137
  for (const result of results) {
133
138
  if (result.command) emitAutomationCommand(result.command);
@@ -195,6 +200,10 @@ function openTerminalProxy(ws: TerminalProxySocket): void {
195
200
  }
196
201
 
197
202
  function sendTerminalProxyFrame(ws: TerminalProxySocket, data: string | Buffer): void {
203
+ if (!ws.data.writeAuthorized && isTerminalWriteFrame(data)) {
204
+ ws.send(JSON.stringify({ type: "error", error: "terminal write forbidden" }));
205
+ return;
206
+ }
198
207
  const upstream = ws.data.upstream;
199
208
  if (!upstream || upstream.readyState === WebSocket.CONNECTING) {
200
209
  ws.data.pending.push(data);
@@ -204,10 +213,22 @@ function sendTerminalProxyFrame(ws: TerminalProxySocket, data: string | Buffer):
204
213
  upstream.send(data);
205
214
  }
206
215
 
216
+ function isTerminalWriteFrame(data: string | Buffer): boolean {
217
+ let payload: unknown;
218
+ try {
219
+ payload = JSON.parse(typeof data === "string" ? data : data.toString("utf8"));
220
+ } catch {
221
+ return false;
222
+ }
223
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false;
224
+ const frame = payload as Record<string, unknown>;
225
+ return frame.type === "input" || (frame.type === "interactive" && frame.interactive === true);
226
+ }
227
+
207
228
  function closeTerminalProxy(ws: TerminalProxySocket): void {
208
229
  const upstream = ws.data.upstream;
209
230
  ws.data.pending = [];
210
- if (upstream && upstream.readyState === WebSocket.OPEN) upstream.close();
231
+ if (upstream && upstream.readyState !== WebSocket.CLOSED) upstream.close();
211
232
  }
212
233
 
213
234
  export function createFetchHandler(
@@ -235,9 +256,10 @@ export function createFetchHandler(
235
256
  if (!isAuthorized(req)) return unauthorized(req);
236
257
  const orchestratorId = decodeURIComponent(terminalStreamMatch[1]!);
237
258
  const session = decodeURIComponent(terminalStreamMatch[2]!);
238
- if (!isRequestAuthorizedFor(req, { scope: "terminal:attach", resource: { orchestratorId, terminalAttach: true } })) {
259
+ if (!isTerminalSessionRequestAuthorized(req, orchestratorId, session, "read")) {
239
260
  return forbidden(req);
240
261
  }
262
+ const writeAuthorized = isTerminalSessionRequestAuthorized(req, orchestratorId, session, "write");
241
263
  const orch = getOrchestrator(orchestratorId);
242
264
  if (!orch) return Response.json({ error: "orchestrator not found" }, { status: 404 });
243
265
  if (!orch.apiUrl) return Response.json({ error: "orchestrator does not expose an API" }, { status: 422 });
@@ -248,7 +270,7 @@ export function createFetchHandler(
248
270
  // access/proxy logs (#149). The orchestrator accepts both, but headers are safer.
249
271
  const token = relayToken();
250
272
  const upgraded = server.upgrade(req, {
251
- data: { kind: "terminal-proxy", upstreamUrl: upstream.toString(), upstreamToken: token, pending: [] },
273
+ data: { kind: "terminal-proxy", upstreamUrl: upstream.toString(), upstreamToken: token, writeAuthorized, pending: [] },
252
274
  });
253
275
  if (!upgraded) return new Response("WebSocket upgrade failed", { status: 400 });
254
276
  return undefined;