agent-relay-server 0.120.1 → 0.120.2
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 +1 -1
- package/package.json +3 -3
- package/public/assets/MessageBubble-DazQ9Rn-.js +2 -0
- package/public/assets/{MessageBubble-jpshgh3K.js.map → MessageBubble-DazQ9Rn-.js.map} +1 -1
- package/public/assets/{PlanGraphPanel-CjVG-Jsq.js → PlanGraphPanel-DpTiGCkz.js} +2 -2
- package/public/assets/{PlanGraphPanel-CjVG-Jsq.js.map → PlanGraphPanel-DpTiGCkz.js.map} +1 -1
- package/public/assets/{activity-DLTFF8Dz.js → activity-DReCE2mK.js} +2 -2
- package/public/assets/{activity-DLTFF8Dz.js.map → activity-DReCE2mK.js.map} +1 -1
- package/public/assets/{agent-profiles-BcCLIrON.js → agent-profiles-D3ba8Xpe.js} +2 -2
- package/public/assets/{agent-profiles-BcCLIrON.js.map → agent-profiles-D3ba8Xpe.js.map} +1 -1
- package/public/assets/agents-BcsIxadm.js +2 -0
- package/public/assets/{agents-QemvuxKO.js.map → agents-BcsIxadm.js.map} +1 -1
- package/public/assets/{analytics-DMLoMwta.js → analytics-0lOlkaib.js} +2 -2
- package/public/assets/{analytics-DMLoMwta.js.map → analytics-0lOlkaib.js.map} +1 -1
- package/public/assets/{automation-DACSOffp.js → automation-D66mthBn.js} +2 -2
- package/public/assets/{automation-DACSOffp.js.map → automation-D66mthBn.js.map} +1 -1
- package/public/assets/{branch-state-badge-BYE2U_y4.js → branch-state-badge-NH-l2Eh3.js} +2 -2
- package/public/assets/{branch-state-badge-BYE2U_y4.js.map → branch-state-badge-NH-l2Eh3.js.map} +1 -1
- package/public/assets/{channels-3SlDQL3j.js → channels-BlHLMQVk.js} +2 -2
- package/public/assets/{channels-3SlDQL3j.js.map → channels-BlHLMQVk.js.map} +1 -1
- package/public/assets/chat-rwffRsPX.js +2 -0
- package/public/assets/chat-rwffRsPX.js.map +1 -0
- package/public/assets/{connectors-CrEW591Q.js → connectors-CdsiaLD3.js} +2 -2
- package/public/assets/{connectors-CrEW591Q.js.map → connectors-CdsiaLD3.js.map} +1 -1
- package/public/assets/{formatted-body-Dg9R3RD7.js → formatted-body-C1AprxyU.js} +3 -3
- package/public/assets/{formatted-body-Dg9R3RD7.js.map → formatted-body-C1AprxyU.js.map} +1 -1
- package/public/assets/formatted-body-impl-D_ZJQVNk.js +3 -0
- package/public/assets/formatted-body-impl-D_ZJQVNk.js.map +1 -0
- package/public/assets/{index-D_fNJnsI.js → index-DfPouNiV.js} +10 -10
- package/public/assets/index-DfPouNiV.js.map +1 -0
- package/public/assets/{insights-D48n2ojF.js → insights-BJf1vwrf.js} +2 -2
- package/public/assets/{insights-D48n2ojF.js.map → insights-BJf1vwrf.js.map} +1 -1
- package/public/assets/{integrations-B-bdliwU.js → integrations-BZSHasnY.js} +2 -2
- package/public/assets/{integrations-B-bdliwU.js.map → integrations-BZSHasnY.js.map} +1 -1
- package/public/assets/{maintenance-LQ_U3Nqy.js → maintenance-uEI1lArh.js} +2 -2
- package/public/assets/{maintenance-LQ_U3Nqy.js.map → maintenance-uEI1lArh.js.map} +1 -1
- package/public/assets/{managed-agents-Cfh8FvY8.js → managed-agents-mX3hHpOP.js} +4 -4
- package/public/assets/{managed-agents-Cfh8FvY8.js.map → managed-agents-mX3hHpOP.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-BSa7zYx1.js → markdown-preview-impl-ClNwMC48.js} +2 -2
- package/public/assets/{markdown-preview-impl-BSa7zYx1.js.map → markdown-preview-impl-ClNwMC48.js.map} +1 -1
- package/public/assets/{memory-Dr1iYKkC.js → memory-OfusEzGh.js} +2 -2
- package/public/assets/{memory-Dr1iYKkC.js.map → memory-OfusEzGh.js.map} +1 -1
- package/public/assets/messages-DsQGj0gU.js +2 -0
- package/public/assets/{messages-DRKulS-2.js.map → messages-DsQGj0gU.js.map} +1 -1
- package/public/assets/{orchestrators-BguyeViG.js → orchestrators-BwWyl4mB.js} +2 -2
- package/public/assets/{orchestrators-BguyeViG.js.map → orchestrators-BwWyl4mB.js.map} +1 -1
- package/public/assets/overview-CED1SeTY.js +2 -0
- package/public/assets/{overview-Dbq2lr7Y.js.map → overview-CED1SeTY.js.map} +1 -1
- package/public/assets/{pairs-DD7i2scF.js → pairs-BNu6ZWg4.js} +2 -2
- package/public/assets/{pairs-DD7i2scF.js.map → pairs-BNu6ZWg4.js.map} +1 -1
- package/public/assets/{projects-DEn85LQb.js → projects-BmopRiUD.js} +2 -2
- package/public/assets/{projects-DEn85LQb.js.map → projects-BmopRiUD.js.map} +1 -1
- package/public/assets/{prompt-settings-DwgrPKY3.js → prompt-settings-Ci2n1cFt.js} +2 -2
- package/public/assets/{prompt-settings-DwgrPKY3.js.map → prompt-settings-Ci2n1cFt.js.map} +1 -1
- package/public/assets/{registry-DCR3xFpe.js → registry-DwXnDIql.js} +2 -2
- package/public/assets/{registry-DCR3xFpe.js.map → registry-DwXnDIql.js.map} +1 -1
- package/public/assets/security-AW5EbdOm.js +3 -0
- package/public/assets/security-AW5EbdOm.js.map +1 -0
- package/public/assets/{select-CpyCRBK3.js → select-CEeWX4hp.js} +2 -2
- package/public/assets/{select-CpyCRBK3.js.map → select-CEeWX4hp.js.map} +1 -1
- package/public/assets/settings-DYUdGz4G.js +6 -0
- package/public/assets/{settings-DYNFGC2-.js.map → settings-DYUdGz4G.js.map} +1 -1
- package/public/assets/store-BaslPLIP.js +9 -0
- package/public/assets/store-BaslPLIP.js.map +1 -0
- package/public/assets/{tasks-rdy9Nsbf.js → tasks-DxCsrY4g.js} +2 -2
- package/public/assets/{tasks-rdy9Nsbf.js.map → tasks-DxCsrY4g.js.map} +1 -1
- package/public/assets/{teams-DHloBagJ.js → teams-CF7vi-oM.js} +2 -2
- package/public/assets/{teams-DHloBagJ.js.map → teams-CF7vi-oM.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-DWTuNejR.js → terminal-viewer-impl-Dy3vqOnW.js} +2 -2
- package/public/assets/{terminal-viewer-impl-DWTuNejR.js.map → terminal-viewer-impl-Dy3vqOnW.js.map} +1 -1
- package/public/assets/types-uVOXgvMT.js.map +1 -1
- package/public/assets/{work-queue-CShT7nTC.js → work-queue-BPaGXrCE.js} +2 -2
- package/public/assets/{work-queue-CShT7nTC.js.map → work-queue-BPaGXrCE.js.map} +1 -1
- package/public/assets/{workspaces-DjQ6EdrZ.js → workspaces-BLEXgnlj.js} +2 -2
- package/public/assets/{workspaces-DjQ6EdrZ.js.map → workspaces-BLEXgnlj.js.map} +1 -1
- package/public/index.html +3 -3
- package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/src/automations/reconcile.ts +69 -4
- package/src/branch-landed.ts +18 -7
- package/src/bus.ts +23 -16
- package/src/db/connection.ts +29 -1
- package/src/db/maintenance-worker.ts +25 -0
- package/src/db/message-reads.ts +69 -44
- package/src/db/migrations.ts +1 -0
- package/src/db/provider-quotas.ts +6 -7
- package/src/db/schema.ts +2 -1
- package/src/maintenance/jobs.ts +3 -3
- package/src/mcp/index.ts +5 -5
- package/src/mcp/tools-workspace.ts +8 -3
- package/src/routes/orchestrator-proxy.ts +7 -6
- package/src/routes/tasks.ts +29 -0
- package/src/routes/workspaces.ts +9 -2
- package/src/runtime-tokens.ts +4 -0
- package/src/security.ts +31 -7
- package/src/server.ts +40 -18
- package/src/services/git-remote.ts +29 -0
- package/src/services/issue-lifecycle.ts +6 -6
- package/src/services/project.ts +47 -1
- package/src/services/send-message.ts +2 -4
- package/src/services/spawn-agent.ts +2 -2
- package/src/services/task-lifecycle.ts +6 -5
- package/src/settings/registry.ts +6 -4
- package/src/spawn-targets.ts +2 -2
- package/src/sse.ts +37 -18
- package/src/steward.ts +10 -6
- package/src/terminal-authz.ts +50 -0
- package/src/token-db.ts +3 -3
- package/src/validation.ts +1 -1
- package/src/workspace-actions.ts +46 -4
- package/src/workspace-auto-merge.ts +120 -13
- package/src/workspace-orphans.ts +1 -1
- package/src/workspace-probe.ts +19 -10
- package/public/assets/MessageBubble-jpshgh3K.js +0 -2
- package/public/assets/agents-QemvuxKO.js +0 -2
- package/public/assets/chat-xKXCafN9.js +0 -2
- package/public/assets/chat-xKXCafN9.js.map +0 -1
- package/public/assets/formatted-body-impl-DlasTD8X.js +0 -3
- package/public/assets/formatted-body-impl-DlasTD8X.js.map +0 -1
- package/public/assets/index-D_fNJnsI.js.map +0 -1
- package/public/assets/messages-DRKulS-2.js +0 -2
- package/public/assets/overview-Dbq2lr7Y.js +0 -2
- package/public/assets/security-BH8kTdA-.js +0 -3
- package/public/assets/security-BH8kTdA-.js.map +0 -1
- package/public/assets/settings-DYNFGC2-.js +0 -6
- package/public/assets/store-BuuJUn70.js +0 -9
- package/public/assets/store-BuuJUn70.js.map +0 -1
package/src/routes/tasks.ts
CHANGED
|
@@ -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;
|
package/src/routes/workspaces.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 }),
|
package/src/runtime-tokens.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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(
|
|
523
|
-
|
|
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
|
|
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 (!
|
|
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;
|
|
@@ -18,6 +18,26 @@ export function parseOwnerRepoFromRemote(url: string | undefined): string | unde
|
|
|
18
18
|
let remoteRepoCache: Map<string, string | undefined> | undefined;
|
|
19
19
|
let remoteUrlCache: Map<string, string | undefined> | undefined;
|
|
20
20
|
|
|
21
|
+
export async function remoteUrlFromGitRemoteAsync(repoRoot: string | undefined): Promise<string | undefined> {
|
|
22
|
+
if (!repoRoot) return undefined;
|
|
23
|
+
if (!remoteUrlCache) remoteUrlCache = new Map();
|
|
24
|
+
if (remoteUrlCache.has(repoRoot)) return remoteUrlCache.get(repoRoot);
|
|
25
|
+
let url: string | undefined;
|
|
26
|
+
try {
|
|
27
|
+
const proc = Bun.spawn(["git", "-C", repoRoot, "remote", "get-url", "origin"], {
|
|
28
|
+
stdin: "ignore",
|
|
29
|
+
stdout: "pipe",
|
|
30
|
+
stderr: "ignore",
|
|
31
|
+
});
|
|
32
|
+
const [stdout, exitCode] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
|
|
33
|
+
if (exitCode === 0) url = stdout.trim() || undefined;
|
|
34
|
+
} catch {
|
|
35
|
+
url = undefined;
|
|
36
|
+
}
|
|
37
|
+
remoteUrlCache.set(repoRoot, url);
|
|
38
|
+
return url;
|
|
39
|
+
}
|
|
40
|
+
|
|
21
41
|
export function remoteUrlFromGitRemote(repoRoot: string | undefined): string | undefined {
|
|
22
42
|
if (!repoRoot) return undefined;
|
|
23
43
|
if (!remoteUrlCache) remoteUrlCache = new Map();
|
|
@@ -37,6 +57,15 @@ export function remoteUrlFromGitRemote(repoRoot: string | undefined): string | u
|
|
|
37
57
|
return url;
|
|
38
58
|
}
|
|
39
59
|
|
|
60
|
+
export async function repoFromGitRemoteAsync(repoRoot: string | undefined): Promise<string | undefined> {
|
|
61
|
+
if (!repoRoot) return undefined;
|
|
62
|
+
if (!remoteRepoCache) remoteRepoCache = new Map();
|
|
63
|
+
if (remoteRepoCache.has(repoRoot)) return remoteRepoCache.get(repoRoot);
|
|
64
|
+
const repo = parseOwnerRepoFromRemote(await remoteUrlFromGitRemoteAsync(repoRoot));
|
|
65
|
+
remoteRepoCache.set(repoRoot, repo);
|
|
66
|
+
return repo;
|
|
67
|
+
}
|
|
68
|
+
|
|
40
69
|
/**
|
|
41
70
|
* `owner/repo` from a repo root's `origin` remote, cached per root. Works from any
|
|
42
71
|
* subdirectory of the repo (`git -C` walks up). Returns undefined when the dir isn't a
|
|
@@ -13,7 +13,7 @@ import { defaultIssueRepo } from "../config";
|
|
|
13
13
|
// `repoFromGitRemote` / `parseOwnerRepoFromRemote` live in the shared ./git-remote module
|
|
14
14
|
// (#533) so project auto-seed reuses the exact same logic. Re-exported so this module's
|
|
15
15
|
// existing importers and tests keep their entry point.
|
|
16
|
-
import { parseOwnerRepoFromRemote, repoFromGitRemote, __resetRemoteRepoCacheForTests } from "./git-remote";
|
|
16
|
+
import { parseOwnerRepoFromRemote, repoFromGitRemote, repoFromGitRemoteAsync, __resetRemoteRepoCacheForTests } from "./git-remote";
|
|
17
17
|
export { parseOwnerRepoFromRemote, repoFromGitRemote, __resetRemoteRepoCacheForTests };
|
|
18
18
|
import type { IssueTrackerAdapter, IssueTrackerIssueSummary } from "../issue-tracker";
|
|
19
19
|
import { recordAndEmitIssueActivity } from "./issue-activity";
|
|
@@ -90,8 +90,8 @@ function logFailure(opts: IssueLifecycleOptions, message: string): void {
|
|
|
90
90
|
* from its own git remote so a single relay serving many repos closes the RIGHT repo's issue;
|
|
91
91
|
* env (`AGENT_RELAY_DEFAULT_ISSUE_REPO`/`GITHUB_REPOSITORY`) is an override/fallback.
|
|
92
92
|
*/
|
|
93
|
-
function landDefaultRepo(workspace: LifecycleWorkspace): string | undefined {
|
|
94
|
-
return
|
|
93
|
+
async function landDefaultRepo(workspace: LifecycleWorkspace): Promise<string | undefined> {
|
|
94
|
+
return (await repoFromGitRemoteAsync(workspace.repoRoot)) ?? defaultIssueRepo();
|
|
95
95
|
}
|
|
96
96
|
|
|
97
97
|
function cleanText(value: string | undefined): string | undefined {
|
|
@@ -197,10 +197,10 @@ export function parseBacklinkIssueRefs(text: string | undefined, fallbackDefault
|
|
|
197
197
|
return issueRefMap(refs);
|
|
198
198
|
}
|
|
199
199
|
|
|
200
|
-
function resolveIssueRefsForLand(input: LandedIssueLifecycleInput): { source: LandIssueRefSource; refs: IssueRef[] } {
|
|
200
|
+
async function resolveIssueRefsForLand(input: LandedIssueLifecycleInput): Promise<{ source: LandIssueRefSource; refs: IssueRef[] }> {
|
|
201
201
|
const associated = associatedIssueRefs(input.workspace);
|
|
202
202
|
if (associated.length > 0) return { source: "association", refs: associated };
|
|
203
|
-
const fallbackDefaultRepo = landDefaultRepo(input.workspace);
|
|
203
|
+
const fallbackDefaultRepo = await landDefaultRepo(input.workspace);
|
|
204
204
|
const subjects = landedSubjectTexts(input);
|
|
205
205
|
const keyworded = issueRefMap(subjects.flatMap((subject) => parseClosingIssueRefs(subject, fallbackDefaultRepo)));
|
|
206
206
|
if (keyworded.length > 0) return { source: "commit", refs: keyworded };
|
|
@@ -399,7 +399,7 @@ export async function handleLandedIssueLifecycle(input: LandedIssueLifecycleInpu
|
|
|
399
399
|
skipped: string[];
|
|
400
400
|
}> {
|
|
401
401
|
if ((input.workspace.baseRef ?? "main") !== "main") return { source: "commit", closed: [], reopened: [], skipped: ["non-default-branch"] };
|
|
402
|
-
const resolved = resolveIssueRefsForLand(input);
|
|
402
|
+
const resolved = await resolveIssueRefsForLand(input);
|
|
403
403
|
const closed: CloseIssueLifecycleResult[] = [];
|
|
404
404
|
for (const ref of resolved.refs) {
|
|
405
405
|
closed.push(await closeIssueRefForLand(ref, resolved.source, input, opts));
|
package/src/services/project.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { statSync } from "node:fs";
|
|
|
2
2
|
import { basename, dirname, join, resolve } from "node:path";
|
|
3
3
|
import { addProjectRoot, ensureProject, resolveProjectForCwd, rootsForProject, setProjectIssueRepo } from "../db";
|
|
4
4
|
import { ingestClaudeMd } from "../project-ingest";
|
|
5
|
-
import { remoteUrlFromGitRemote, repoFromGitRemote } from "./git-remote";
|
|
5
|
+
import { remoteUrlFromGitRemote, remoteUrlFromGitRemoteAsync, repoFromGitRemote, repoFromGitRemoteAsync } from "./git-remote";
|
|
6
6
|
import type { Project, ProjectRoot } from "../types";
|
|
7
7
|
|
|
8
8
|
interface EnsureProjectRootInput {
|
|
@@ -56,6 +56,26 @@ export function ensureProjectRoot(input: EnsureProjectRootInput): EnsureProjectR
|
|
|
56
56
|
return { project, roots };
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
export async function ensureProjectRootAsync(input: EnsureProjectRootInput): Promise<EnsureProjectRootResult> {
|
|
60
|
+
const project = ensureProject({
|
|
61
|
+
slug: input.slug,
|
|
62
|
+
...(input.title !== undefined ? { title: input.title } : {}),
|
|
63
|
+
...(input.description !== undefined ? { description: input.description } : {}),
|
|
64
|
+
...(input.tenantId !== undefined ? { tenantId: input.tenantId } : {}),
|
|
65
|
+
...(input.issueRepo !== undefined ? { issueRepo: input.issueRepo } : {}),
|
|
66
|
+
});
|
|
67
|
+
const remoteUrl = input.remoteUrl ?? await remoteUrlFromGitRemoteAsync(input.rootPath);
|
|
68
|
+
const roots = addProjectRoot(project.id, input.rootPath, { remoteUrl, ref: input.ref });
|
|
69
|
+
if (input.issueRepo === undefined && !project.issueRepo) {
|
|
70
|
+
const derived = await repoFromGitRemoteAsync(input.rootPath);
|
|
71
|
+
if (derived) {
|
|
72
|
+
const updated = setProjectIssueRepo(project.id, derived, { onlyIfNull: true });
|
|
73
|
+
if (updated) return { project: updated, roots };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return { project, roots };
|
|
77
|
+
}
|
|
78
|
+
|
|
59
79
|
export function ingestProjectClaudeMd(projectId: string, authorId: string): ClaudeMdIngestResult {
|
|
60
80
|
return ingestClaudeMd(projectId, authorId);
|
|
61
81
|
}
|
|
@@ -112,3 +132,29 @@ export function autoSeedProjectForCwd(cwd: string, authorId: string): AutoSeedPr
|
|
|
112
132
|
return { project, repoRoot, roots, ingestError: err instanceof Error ? err : new Error(String(err)) };
|
|
113
133
|
}
|
|
114
134
|
}
|
|
135
|
+
|
|
136
|
+
export async function autoSeedProjectForCwdAsync(cwd: string, authorId: string): Promise<AutoSeedProjectResult | null> {
|
|
137
|
+
const existing = resolveProjectForCwd(cwd);
|
|
138
|
+
if (existing) {
|
|
139
|
+
const repoRoot = findGitRepoRoot(cwd) ?? cwd;
|
|
140
|
+
let project = existing;
|
|
141
|
+
if (!existing.issueRepo) {
|
|
142
|
+
const derived = await repoFromGitRemoteAsync(repoRoot);
|
|
143
|
+
const updated = derived ? setProjectIssueRepo(existing.id, derived, { onlyIfNull: true }) : null;
|
|
144
|
+
if (updated) project = updated;
|
|
145
|
+
}
|
|
146
|
+
return { project, repoRoot, roots: rootsForProject(existing.id) };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const repoRoot = findGitRepoRoot(cwd);
|
|
150
|
+
if (!repoRoot) return null;
|
|
151
|
+
const slug = basename(repoRoot).trim();
|
|
152
|
+
if (!slug) return null;
|
|
153
|
+
|
|
154
|
+
const { project, roots } = await ensureProjectRootAsync({ slug, rootPath: repoRoot });
|
|
155
|
+
try {
|
|
156
|
+
return { project, repoRoot, roots, ingest: ingestProjectClaudeMd(project.id, authorId) };
|
|
157
|
+
} catch (err) {
|
|
158
|
+
return { project, repoRoot, roots, ingestError: err instanceof Error ? err : new Error(String(err)) };
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -30,7 +30,6 @@ import {
|
|
|
30
30
|
promoteLineageCapturedResponse,
|
|
31
31
|
sendMessageWithResult,
|
|
32
32
|
channelObligationAnsweredBy,
|
|
33
|
-
spawnObligationAnsweredBy,
|
|
34
33
|
} from "../db";
|
|
35
34
|
import { planSend, type DeliveryReceipt } from "../agent-ref";
|
|
36
35
|
import { emitMessageQueued, emitNewMessage } from "../sse";
|
|
@@ -178,12 +177,11 @@ export function sendMessageService(
|
|
|
178
177
|
): SendMessageResult {
|
|
179
178
|
const receipt = validateSendMessageAuthorization(input, ctx, opts);
|
|
180
179
|
// Reply-obligation linking: some real answers are ordinary sends without replyTo/thread linkage.
|
|
181
|
-
// Channel bridge replies
|
|
180
|
+
// Channel bridge replies have a single unambiguous open obligation case;
|
|
182
181
|
// link those at send time so the obligation clears with one message and the Stop hook does not nag
|
|
183
182
|
// the agent into a redundant `/reply`.
|
|
184
183
|
if (!input.replyTo && !isMechanicalMessageKind(input.kind)) {
|
|
185
|
-
const answered = channelObligationAnsweredBy(input.from, input.to)
|
|
186
|
-
?? spawnObligationAnsweredBy(input.from, input.to);
|
|
184
|
+
const answered = channelObligationAnsweredBy(input.from, input.to);
|
|
187
185
|
if (answered !== null) input.replyTo = answered;
|
|
188
186
|
}
|
|
189
187
|
const suppressedParent = suppressResultsOnlyAck(input);
|
|
@@ -39,7 +39,7 @@ import type { ProjectMcpPolicy } from "../project-mcp";
|
|
|
39
39
|
import type { ProjectSettingsPolicy } from "../project-settings";
|
|
40
40
|
import type { ProjectSkillsPolicy } from "../project-skills";
|
|
41
41
|
import { applyDefaultSharedMcp, type SharedMcpSpawnOverride } from "../shared-mcp";
|
|
42
|
-
import {
|
|
42
|
+
import { autoSeedProjectForCwdAsync } from "./project";
|
|
43
43
|
import { hydrateTaskContext, projectTaskHydration } from "./task-context-hydration";
|
|
44
44
|
import { createTaskEntity, recordTaskEntityDispatchDecision } from "./task-entity";
|
|
45
45
|
import { autoSubscribeTaskCoordinator } from "./task-semantic-events";
|
|
@@ -414,7 +414,7 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
|
|
|
414
414
|
// stays filterable by project.
|
|
415
415
|
let callerProject = resolveProjectForCwd(resolvedCwd);
|
|
416
416
|
try {
|
|
417
|
-
const seeded =
|
|
417
|
+
const seeded = await autoSeedProjectForCwdAsync(resolvedCwd, requestedBy);
|
|
418
418
|
callerProject = seeded?.project ?? callerProject;
|
|
419
419
|
if (seeded?.ingestError) {
|
|
420
420
|
console.warn(`project auto-seed failed to ingest CLAUDE.md for ${resolvedCwd}: ${seeded.ingestError.message}`);
|
|
@@ -100,20 +100,21 @@ export function __setDriveTaskOnLandForTests(resolver: DriveTaskResolver | undef
|
|
|
100
100
|
driveTaskOnLandForTests = resolver;
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
-
function landedDiffSummary(input: LandedTaskLifecycleInput, log: LogFn): string | undefined {
|
|
103
|
+
async function landedDiffSummary(input: LandedTaskLifecycleInput, log: LogFn): Promise<string | undefined> {
|
|
104
104
|
if (diffSummaryResolverForTests) return diffSummaryResolverForTests(input);
|
|
105
105
|
const repoRoot = input.workspace.repoRoot?.trim();
|
|
106
106
|
const baseSha = input.workspace.baseSha?.trim();
|
|
107
107
|
const tipSha = input.mergedSha?.trim();
|
|
108
108
|
if (!repoRoot || !baseSha || !tipSha || baseSha === tipSha) return undefined;
|
|
109
109
|
try {
|
|
110
|
-
const proc = Bun.
|
|
110
|
+
const proc = Bun.spawn(["git", "-C", repoRoot, "diff", "--shortstat", `${baseSha}..${tipSha}`], {
|
|
111
111
|
stdin: "ignore",
|
|
112
112
|
stdout: "pipe",
|
|
113
113
|
stderr: "pipe",
|
|
114
114
|
});
|
|
115
|
-
|
|
116
|
-
|
|
115
|
+
const [stdout, exitCode] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
|
|
116
|
+
if (exitCode !== 0) return undefined;
|
|
117
|
+
const out = stdout.trim();
|
|
117
118
|
return out || undefined;
|
|
118
119
|
} catch (error) {
|
|
119
120
|
log(`task lifecycle diff summary failed for ${input.workspace.id}: ${errMessage(error)}`);
|
|
@@ -454,7 +455,7 @@ export async function handleLandedTaskLifecycle(
|
|
|
454
455
|
const taskIds = boundTaskIdsForWorkspace(input.workspace);
|
|
455
456
|
if (taskIds.length === 0) return { handled: false, reason: "no-bound-task", tasks: [] };
|
|
456
457
|
|
|
457
|
-
const diffSummary = landedDiffSummary(input, log);
|
|
458
|
+
const diffSummary = await landedDiffSummary(input, log);
|
|
458
459
|
const outcomes: TaskLandOutcome[] = [];
|
|
459
460
|
for (const taskId of taskIds) {
|
|
460
461
|
try {
|
package/src/settings/registry.ts
CHANGED
|
@@ -238,10 +238,10 @@ const DISPLAY_PREFS_SCHEMA: JSONSchema = {
|
|
|
238
238
|
// sticky-bottom intent follow them across devices instead of dying in per-browser module state.
|
|
239
239
|
// The value is a MAP keyed by conversation/agent id — one user-settings row holds every
|
|
240
240
|
// conversation's anchor, so this is ONE descriptor (not a per-agent descriptor explosion).
|
|
241
|
-
// Each entry is a LOGICAL anchor, never a pixel scrollTop: `
|
|
242
|
-
//
|
|
243
|
-
// follow-the-bottom intent. `maxProperties` bounds unbounded
|
|
244
|
-
// store evicts oldest-touched before it can be hit.
|
|
241
|
+
// Each entry is a LOGICAL anchor, never a pixel scrollTop: `anchorMessageId` is the durable
|
|
242
|
+
// message-id anchor, `topKey` is the compatibility timeline entry key, `offset` the within-row
|
|
243
|
+
// pixel offset, `stick` the follow-the-bottom intent. `maxProperties` bounds unbounded
|
|
244
|
+
// conversation growth; the dashboard store evicts oldest-touched before it can be hit.
|
|
245
245
|
const CHAT_SCROLL_SCHEMA: JSONSchema = {
|
|
246
246
|
type: "object",
|
|
247
247
|
maxProperties: 200,
|
|
@@ -252,6 +252,8 @@ const CHAT_SCROLL_SCHEMA: JSONSchema = {
|
|
|
252
252
|
properties: {
|
|
253
253
|
stick: { type: "boolean" },
|
|
254
254
|
topKey: { type: ["string", "null"], maxLength: 200 },
|
|
255
|
+
anchorMessageId: { type: "number", minimum: 0, maximum: 10_000_000_000 },
|
|
256
|
+
anchorEndMessageId: { type: "number", minimum: 0, maximum: 10_000_000_000 },
|
|
255
257
|
offset: { type: "number", minimum: 0, maximum: 10_000_000 },
|
|
256
258
|
},
|
|
257
259
|
},
|
package/src/spawn-targets.ts
CHANGED
|
@@ -277,7 +277,7 @@ function spawnQuotaWindow(
|
|
|
277
277
|
? Math.max(0, burnWindow.earlyWallMs) / timeToResetMs
|
|
278
278
|
: null;
|
|
279
279
|
const weight = burnWindow.role === "7d" ? 3 : 1;
|
|
280
|
-
const hardBackstop = providerCfg?.hardBackstopUtilization
|
|
280
|
+
const hardBackstop = providerCfg?.hardBackstopUtilization;
|
|
281
281
|
// #760 — floor default is now 0 (off). The projection/pace gate (earlyFraction≈0 when the
|
|
282
282
|
// recency-weighted rate projects no early wall) replaces the blunt utilization floor, which
|
|
283
283
|
// used to hide a legitimate low-util-high-burst alarm. Operators may still opt back in.
|
|
@@ -289,7 +289,7 @@ function spawnQuotaWindow(
|
|
|
289
289
|
// over-pace penalty and ≥ preferSlackAt projected headroom at reset.
|
|
290
290
|
const target = providerCfg?.targetUtilization ?? DEFAULT_QUOTA_TARGET_UTILIZATION;
|
|
291
291
|
const preferSlackAt = providerCfg?.preferSlackAt ?? DEFAULT_QUOTA_PREFER_SLACK_AT;
|
|
292
|
-
const blockedNow = quotaWindow.utilization >= hardBackstop;
|
|
292
|
+
const blockedNow = hardBackstop === 0 ? false : quotaWindow.utilization >= (hardBackstop ?? 0.99);
|
|
293
293
|
let burnRatio = burnWindow.burnRatio;
|
|
294
294
|
let projectedUtilAtReset = burnWindow.projectedUtilAtReset;
|
|
295
295
|
let slack = burnWindow.slack;
|