agent-relay-server 0.83.1 → 0.85.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/openapi.json +151 -1
- package/package.json +2 -2
- package/public/assets/{MessageBubble-BTBQr8uG.js → MessageBubble-DqFyach0.js} +2 -2
- package/public/assets/{MessageBubble-BTBQr8uG.js.map → MessageBubble-DqFyach0.js.map} +1 -1
- package/public/assets/{PlanGraphPanel-CoWJJVyD.js → PlanGraphPanel-CCvKbYWe.js} +2 -2
- package/public/assets/{PlanGraphPanel-CoWJJVyD.js.map → PlanGraphPanel-CCvKbYWe.js.map} +1 -1
- package/public/assets/{activity-XqRFu_9T.js → activity-C6RvknQQ.js} +2 -2
- package/public/assets/{activity-XqRFu_9T.js.map → activity-C6RvknQQ.js.map} +1 -1
- package/public/assets/{agents-BWmmWj4Q.js → agents-D7HZJ5eG.js} +2 -2
- package/public/assets/{agents-BWmmWj4Q.js.map → agents-D7HZJ5eG.js.map} +1 -1
- package/public/assets/{automation-DRtOENs0.js → automation-BUazA3lr.js} +2 -2
- package/public/assets/{automation-DRtOENs0.js.map → automation-BUazA3lr.js.map} +1 -1
- package/public/assets/{chat-gfUxbTus.js → chat-DBTEaf-H.js} +2 -2
- package/public/assets/{chat-gfUxbTus.js.map → chat-DBTEaf-H.js.map} +1 -1
- package/public/assets/display-CWMHll1J.js.map +1 -1
- package/public/assets/{formatted-body-impl-DpNHGXXQ.js → formatted-body-impl-KG0iExZ-.js} +2 -2
- package/public/assets/{formatted-body-impl-DpNHGXXQ.js.map → formatted-body-impl-KG0iExZ-.js.map} +1 -1
- package/public/assets/index-LUIvWcx6.css +2 -0
- package/public/assets/index-TZskQfQL.js +23 -0
- package/public/assets/index-TZskQfQL.js.map +1 -0
- package/public/assets/{insights-DPaJ5Hme.js → insights-JL1s7Uul.js} +2 -2
- package/public/assets/{insights-DPaJ5Hme.js.map → insights-JL1s7Uul.js.map} +1 -1
- package/public/assets/{maintenance-DsszLb92.js → maintenance-CTMfhXFM.js} +2 -2
- package/public/assets/{maintenance-DsszLb92.js.map → maintenance-CTMfhXFM.js.map} +1 -1
- package/public/assets/{managed-agents-DqWPxFE0.js → managed-agents-C4cb3xQ4.js} +2 -2
- package/public/assets/{managed-agents-DqWPxFE0.js.map → managed-agents-C4cb3xQ4.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-CWR4ILHK.js → markdown-preview-impl-DQIi0O7r.js} +2 -2
- package/public/assets/{markdown-preview-impl-CWR4ILHK.js.map → markdown-preview-impl-DQIi0O7r.js.map} +1 -1
- package/public/assets/{memory-C2s9bJ38.js → memory-DJPXFdgw.js} +2 -2
- package/public/assets/{memory-C2s9bJ38.js.map → memory-DJPXFdgw.js.map} +1 -1
- package/public/assets/{messages-DHRJNAvy.js → messages-RSD2g4Wx.js} +2 -2
- package/public/assets/{messages-DHRJNAvy.js.map → messages-RSD2g4Wx.js.map} +1 -1
- package/public/assets/{orchestrators-BGNNllJc.js → orchestrators-BGIkULBF.js} +2 -2
- package/public/assets/{orchestrators-BGNNllJc.js.map → orchestrators-BGIkULBF.js.map} +1 -1
- package/public/assets/{overview-C1vZfyhW.js → overview-ByE9V8pe.js} +2 -2
- package/public/assets/{overview-C1vZfyhW.js.map → overview-ByE9V8pe.js.map} +1 -1
- package/public/assets/{pairs-C1uzo8x0.js → pairs-C284ntvD.js} +2 -2
- package/public/assets/{pairs-C1uzo8x0.js.map → pairs-C284ntvD.js.map} +1 -1
- package/public/assets/{security-XhAAP6q3.js → security-vDglScsg.js} +2 -2
- package/public/assets/{security-XhAAP6q3.js.map → security-vDglScsg.js.map} +1 -1
- package/public/assets/{select-DEGUFEjx.js → select-DaOiWDBR.js} +2 -2
- package/public/assets/{select-DEGUFEjx.js.map → select-DaOiWDBR.js.map} +1 -1
- package/public/assets/{settings-DTbVUdlZ.js → settings-DiLcbSqx.js} +2 -2
- package/public/assets/{settings-DTbVUdlZ.js.map → settings-DiLcbSqx.js.map} +1 -1
- package/public/assets/{tasks-zwGo7hon.js → tasks-DWJdJra3.js} +2 -2
- package/public/assets/{tasks-zwGo7hon.js.map → tasks-DWJdJra3.js.map} +1 -1
- package/public/assets/{teams-Brc5D8ox.js → teams-FejXkix4.js} +2 -2
- package/public/assets/{teams-Brc5D8ox.js.map → teams-FejXkix4.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-C6yUAL8G.js → terminal-viewer-impl-Bkt1w42o.js} +2 -2
- package/public/assets/{terminal-viewer-impl-C6yUAL8G.js.map → terminal-viewer-impl-Bkt1w42o.js.map} +1 -1
- package/public/assets/{user-avatar-Bw1R011j.js → user-avatar-ClE2dMD8.js} +2 -2
- package/public/assets/{user-avatar-Bw1R011j.js.map → user-avatar-ClE2dMD8.js.map} +1 -1
- package/public/assets/{work-queue-ge4R7keo.js → work-queue-DZdv9kOb.js} +2 -2
- package/public/assets/{work-queue-ge4R7keo.js.map → work-queue-DZdv9kOb.js.map} +1 -1
- package/public/index.html +2 -2
- package/src/agent-death-diagnosis.ts +255 -0
- package/src/agent-lifecycle-events.ts +42 -19
- package/src/auth/index.ts +23 -0
- package/src/auth/password.ts +122 -0
- package/src/auth/provider.ts +60 -0
- package/src/auth/session.ts +92 -0
- package/src/bus.ts +18 -4
- package/src/db/agents.ts +35 -2
- package/src/db/auth-identities.ts +119 -0
- package/src/db/crash-reports.ts +115 -0
- package/src/db/index.ts +2 -0
- package/src/db/migrations.ts +11 -0
- package/src/index.ts +6 -1
- package/src/maintenance/jobs.ts +6 -1
- package/src/maintenance/teams.ts +68 -1
- package/src/mcp/index.ts +2 -1
- package/src/mcp/tools-messaging.ts +38 -0
- package/src/notification-types.ts +2 -0
- package/src/routes/_shared.ts +40 -2
- package/src/routes/agents.ts +11 -1
- package/src/routes/auth.ts +107 -0
- package/src/routes/commands.ts +21 -3
- package/src/routes/index.ts +10 -1
- package/src/routes/orchestrator.ts +14 -1
- package/src/routes/tokens.ts +8 -2
- package/src/security.ts +1 -1
- package/src/services/terminal-provider-exit.ts +33 -0
- package/src/spawn-targets.ts +19 -8
- package/src/team-idle-tracker.ts +15 -0
- package/src/validation.ts +2 -0
- package/src/workspace-actions.ts +5 -1
- package/src/workspace-auto-merge.ts +87 -4
- package/src/workspace-merge.ts +10 -0
- package/public/assets/index-D8TVUdlC.js +0 -22
- package/public/assets/index-D8TVUdlC.js.map +0 -1
- package/public/assets/index-DgZLDL0K.css +0 -2
package/src/maintenance/jobs.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
createActivityEvent,
|
|
8
8
|
evaluatePoolBindings,
|
|
9
9
|
expireQueuedMessages,
|
|
10
|
+
getAgent,
|
|
10
11
|
pruneOrphanedSharedWorkspaces,
|
|
11
12
|
pruneOfflineAgents,
|
|
12
13
|
pruneOldMessages,
|
|
@@ -19,6 +20,7 @@ import {
|
|
|
19
20
|
sweepArtifacts,
|
|
20
21
|
} from "../db";
|
|
21
22
|
import { notifyAgentOffline } from "../agent-lifecycle-events";
|
|
23
|
+
import { diagnoseAgentDeath } from "../agent-death-diagnosis";
|
|
22
24
|
import { autoMergeCleanFastForwards } from "../workspace-auto-merge";
|
|
23
25
|
import { reapOrphanedWorktrees } from "../workspace-orphans";
|
|
24
26
|
import { getLifecycleManager } from "../lifecycle-manager";
|
|
@@ -173,7 +175,10 @@ export const definitions: MaintenanceJobDefinition[] = [
|
|
|
173
175
|
for (const id of reapedAgentIds) {
|
|
174
176
|
emitAgentStatus(id);
|
|
175
177
|
getLifecycleManager().onAgentDisappeared(id);
|
|
176
|
-
|
|
178
|
+
// #636 — a heartbeat-lost death still gets a best-effort post-mortem from the agent's last
|
|
179
|
+
// known meta (lastExit/lastError/auth) + heartbeat gap, riding the terminal event.
|
|
180
|
+
const diagnosis = diagnoseAgentDeath({ agent: getAgent(id), reason: "heartbeat lost" });
|
|
181
|
+
notifyAgentOffline(id, "heartbeat lost", diagnosis);
|
|
177
182
|
createActivityEvent({
|
|
178
183
|
clientId: "server-agent-" + id + "-heartbeat-lost-" + Date.now(),
|
|
179
184
|
kind: "state",
|
package/src/maintenance/teams.ts
CHANGED
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
clearTeamZeroMembersSince,
|
|
3
3
|
countLiveTeamMembers,
|
|
4
4
|
createActivityEvent,
|
|
5
|
+
getCrashReport,
|
|
5
6
|
getTeamBusyState,
|
|
6
7
|
getTeamRoster,
|
|
7
8
|
listActiveTeams,
|
|
@@ -13,10 +14,69 @@ import { dissolveTeamById } from "../services/team";
|
|
|
13
14
|
import { getTeamsConfig } from "../teams-config-store";
|
|
14
15
|
import { teamReapGraceMs } from "./constants";
|
|
15
16
|
import { notifyTarget } from "./events";
|
|
16
|
-
import { teamIdleTracker } from "../team-idle-tracker";
|
|
17
|
+
import { teamIdleTracker, teamMemberWatchTracker, type TeamMemberWatch } from "../team-idle-tracker";
|
|
18
|
+
import type { AgentCard } from "../types";
|
|
17
19
|
|
|
18
20
|
export function resetTeamIdleWatchdogForTests(): void {
|
|
19
21
|
teamIdleTracker.clear();
|
|
22
|
+
teamMemberWatchTracker.clear();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A member counts as "alive" (worth watching for a future silence) when it's past onboarding and
|
|
26
|
+
* heartbeating — i.e. a status that implies it became ready, not a never-started spawn. */
|
|
27
|
+
function memberIsAlive(member: AgentCard, freshCutoff: number): boolean {
|
|
28
|
+
return (member.status === "idle" || member.status === "busy" || member.status === "online") && member.lastSeen > freshCutoff;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* #634 — detect a team member that was ready/busy and then went silent (heartbeat-gap), EVEN IF it
|
|
33
|
+
* dropped from the roster entirely, and ping the coordinator once. The in-memory watch remembers
|
|
34
|
+
* each member's last-active time, so a vanished agent (no roster row) is still caught. Re-arms when
|
|
35
|
+
* a member recovers. Skips the ping when a #633 crash report already exists (the coordinator was
|
|
36
|
+
* already woken by the terminal event) — this is the backstop for the NO-terminal-event case.
|
|
37
|
+
*/
|
|
38
|
+
function detectSilentMembers(
|
|
39
|
+
teamId: string,
|
|
40
|
+
coordinatorId: string | undefined,
|
|
41
|
+
now: number,
|
|
42
|
+
thresholdMs: number,
|
|
43
|
+
out: { pingedMembers: string[]; watchedMembers: number },
|
|
44
|
+
): void {
|
|
45
|
+
const freshCutoff = now - thresholdMs;
|
|
46
|
+
const watch = teamMemberWatchTracker.get(teamId) ?? new Map<string, TeamMemberWatch>();
|
|
47
|
+
teamMemberWatchTracker.set(teamId, watch);
|
|
48
|
+
|
|
49
|
+
for (const member of getTeamRoster(teamId)) {
|
|
50
|
+
if (member.id === coordinatorId) continue; // the coordinator is the recipient, not a watched worker
|
|
51
|
+
if (!memberIsAlive(member, freshCutoff)) continue;
|
|
52
|
+
const existing = watch.get(member.id);
|
|
53
|
+
// Recovery re-arms the ping: a member seen alive again after a silence can re-trigger later.
|
|
54
|
+
const recovered = existing ? member.lastSeen > existing.lastActiveAt : false;
|
|
55
|
+
watch.set(member.id, {
|
|
56
|
+
lastActiveAt: Math.max(member.lastSeen, existing?.lastActiveAt ?? 0),
|
|
57
|
+
pinged: recovered ? false : existing?.pinged ?? false,
|
|
58
|
+
...(member.label ? { label: member.label } : {}),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
out.watchedMembers += watch.size;
|
|
63
|
+
for (const [memberId, entry] of watch) {
|
|
64
|
+
const gapMs = now - entry.lastActiveAt;
|
|
65
|
+
if (gapMs < thresholdMs || entry.pinged) continue;
|
|
66
|
+
entry.pinged = true;
|
|
67
|
+
if (getCrashReport(memberId)) continue; // terminal event already woke the coordinator (#633)
|
|
68
|
+
if (!coordinatorId) continue;
|
|
69
|
+
const minutes = Math.floor(gapMs / 60_000);
|
|
70
|
+
notifyTarget(
|
|
71
|
+
"team.member_silent",
|
|
72
|
+
{ teamId, agentId: memberId },
|
|
73
|
+
coordinatorId,
|
|
74
|
+
"Team member went silent",
|
|
75
|
+
`Member \`${entry.label ?? memberId}\` in team \`${teamId}\` has sent no heartbeat for ${minutes} min — it may have died without a terminal event. Check it and restart its unfinished work, or stand it down.`,
|
|
76
|
+
{ kind: "team.member_silent", teamId, coordinatorId, memberId, label: entry.label, silentForMs: gapMs, lastActiveAt: entry.lastActiveAt },
|
|
77
|
+
);
|
|
78
|
+
out.pingedMembers.push(memberId);
|
|
79
|
+
}
|
|
20
80
|
}
|
|
21
81
|
|
|
22
82
|
function teamIdlePayload(teamId: string, coordinatorId: string, thresholdMinutes: number, idleForMs: number, firstNoBusyAt: number) {
|
|
@@ -79,9 +139,13 @@ export function runTeamIdleWatchdog(input: { now?: number } = {}): Record<string
|
|
|
79
139
|
const pingedTeamIds: string[] = [];
|
|
80
140
|
const alreadyPingedTeamIds: string[] = [];
|
|
81
141
|
const missingCoordinatorTeamIds: string[] = [];
|
|
142
|
+
const memberSilence = { pingedMembers: [] as string[], watchedMembers: 0 };
|
|
82
143
|
|
|
83
144
|
for (const team of listActiveTeams()) {
|
|
84
145
|
seen.add(team.id);
|
|
146
|
+
// #634 — runs every cycle, independent of the team-level idle gate below: a single silent
|
|
147
|
+
// member must surface even while OTHER members keep the team "busy".
|
|
148
|
+
detectSilentMembers(team.id, team.coordinatorId, now, thresholdMs, memberSilence);
|
|
85
149
|
const busy = getTeamBusyState(team.id, cutoff);
|
|
86
150
|
if (busy.busyMemberIds.length > 0) {
|
|
87
151
|
teamIdleTracker.delete(team.id);
|
|
@@ -112,6 +176,7 @@ export function runTeamIdleWatchdog(input: { now?: number } = {}): Record<string
|
|
|
112
176
|
}
|
|
113
177
|
|
|
114
178
|
for (const key of teamIdleTracker.keys()) if (!seen.has(key)) teamIdleTracker.delete(key);
|
|
179
|
+
for (const key of teamMemberWatchTracker.keys()) if (!seen.has(key)) teamMemberWatchTracker.delete(key);
|
|
115
180
|
|
|
116
181
|
return {
|
|
117
182
|
enabled: true,
|
|
@@ -122,6 +187,8 @@ export function runTeamIdleWatchdog(input: { now?: number } = {}): Record<string
|
|
|
122
187
|
pingedTeamIds,
|
|
123
188
|
alreadyPingedTeamIds,
|
|
124
189
|
missingCoordinatorTeamIds,
|
|
190
|
+
silentMemberIds: memberSilence.pingedMembers,
|
|
191
|
+
watchedMembers: memberSilence.watchedMembers,
|
|
125
192
|
};
|
|
126
193
|
}
|
|
127
194
|
|
package/src/mcp/index.ts
CHANGED
|
@@ -16,7 +16,7 @@ import { errMessage, isRecord } from "agent-relay-sdk";
|
|
|
16
16
|
import type { JsonRpcId, JsonRpcRequest, McpAuthContext, ToolDefinition } from "./types";
|
|
17
17
|
import { positiveIdOrUndefined, recordField, stringField } from "./validation";
|
|
18
18
|
import { IDENTITY_TOOLS, hasAnyScope, mcpAuthContext, relayCompactAndResume, relayRecall, relayWhoami } from "./identity";
|
|
19
|
-
import { MESSAGING_TOOLS, relayAgentStatus, relayAttachArtifact, relayFindAgents, relayGetMessage, relayGetThread, relayReply, relaySendMessage, relayUploadArtifact } from "./tools-messaging";
|
|
19
|
+
import { MESSAGING_TOOLS, relayAgentCrashReport, relayAgentStatus, relayAttachArtifact, relayFindAgents, relayGetMessage, relayGetThread, relayReply, relaySendMessage, relayUploadArtifact } from "./tools-messaging";
|
|
20
20
|
import { SPAWN_TOOLS, relayShutdownAgent, relaySpawnAgent, relaySpawnTargets } from "./tools-spawn";
|
|
21
21
|
import { WORKSPACE_TOOLS, callerIsolatedWorkspace, relayWorkspaceList, relayWorkspaceMutation, relayWorkspaceStatus } from "./tools-workspace";
|
|
22
22
|
|
|
@@ -122,6 +122,7 @@ async function callTool(auth: McpAuthContext, params: unknown): Promise<Record<s
|
|
|
122
122
|
else if (name === "relay_upload_artifact") result = await relayUploadArtifact(auth, args);
|
|
123
123
|
else if (name === "relay_attach_artifact") result = relayAttachArtifact(auth, args);
|
|
124
124
|
else if (name === "relay_agent_status") result = relayAgentStatus(args);
|
|
125
|
+
else if (name === "relay_agent_crash_report") result = relayAgentCrashReport(auth, args);
|
|
125
126
|
else if (name === "relay_find_agents") result = relayFindAgents(auth, args);
|
|
126
127
|
else if (name === "relay_whoami") result = relayWhoami(auth);
|
|
127
128
|
else if (name === "relay_compact_and_resume") result = relayCompactAndResume(auth, args);
|
|
@@ -8,7 +8,9 @@ import {
|
|
|
8
8
|
createArtifact,
|
|
9
9
|
getAgent,
|
|
10
10
|
getArtifact,
|
|
11
|
+
getCrashReport,
|
|
11
12
|
getMessage,
|
|
13
|
+
listCrashReports,
|
|
12
14
|
getOrchestrator,
|
|
13
15
|
getTask,
|
|
14
16
|
getThread,
|
|
@@ -190,6 +192,19 @@ export const MESSAGING_TOOLS: ToolDefinition[] = [
|
|
|
190
192
|
additionalProperties: false,
|
|
191
193
|
},
|
|
192
194
|
},
|
|
195
|
+
{
|
|
196
|
+
name: "relay_agent_crash_report",
|
|
197
|
+
description: "Retrieve the post-mortem for a dead agent (#636): a likely-cause label (oom · killed · clean-exit · token-401 · onboarding-gate · provider-crash · transport-loss · unknown) + log tail + heartbeat/active-work context. Pass agentId for one report (yourself or a child you spawned); omit it to list your own spawned children's reports. The report survives the reaper, so it answers 'why did my worker die?' after it's gone.",
|
|
198
|
+
requiredScopes: ["agents:read", "agent:read"],
|
|
199
|
+
inputSchema: {
|
|
200
|
+
type: "object",
|
|
201
|
+
properties: {
|
|
202
|
+
agentId: { type: "string", description: "The dead agent's id. Must be yourself or one of your spawned children. Omit to list your own children's crash reports." },
|
|
203
|
+
limit: { type: "integer", minimum: 1, maximum: 200, description: "Max reports when listing (default 50)." },
|
|
204
|
+
},
|
|
205
|
+
additionalProperties: false,
|
|
206
|
+
},
|
|
207
|
+
},
|
|
193
208
|
];
|
|
194
209
|
|
|
195
210
|
function runMcpSend(auth: McpAuthContext, input: SendMessageInput): Message & { delivery: DeliveryReceipt } {
|
|
@@ -450,6 +465,29 @@ export function relayFindAgents(auth: McpAuthContext, args: Record<string, unkno
|
|
|
450
465
|
return { agents: safe, total, count: safe.length, truncated: true, nextOffset: offset + safe.length };
|
|
451
466
|
}
|
|
452
467
|
|
|
468
|
+
// #636 — self/parent-scoped crash post-mortem retrieval. A caller may read its OWN report or any of
|
|
469
|
+
// its spawned children's; never an unrelated agent's. Survives the reaper (separate crash_reports table).
|
|
470
|
+
export function relayAgentCrashReport(auth: McpAuthContext, args: Record<string, unknown>): Record<string, unknown> {
|
|
471
|
+
const caller = callerAgentId(auth);
|
|
472
|
+
const agentId = optionalString(args.agentId, "agentId", 240);
|
|
473
|
+
if (agentId) {
|
|
474
|
+
// Authorize FIRST, regardless of whether a report exists — the self/parent contract must hold
|
|
475
|
+
// even for an agentId with no report (else any agent:read caller leaks `{report:null}`). Prefer
|
|
476
|
+
// the live agent row's lineage; fall back to the report's recorded parent once the agent is reaped.
|
|
477
|
+
const agent = getAgent(agentId);
|
|
478
|
+
const report = getCrashReport(agentId);
|
|
479
|
+
const parent = agent?.spawnedBy ?? report?.parentId;
|
|
480
|
+
const authorized = !!caller && (caller === agentId || (parent !== undefined && caller === parent));
|
|
481
|
+
if (!authorized) throw new McpAuthError("crash report is scoped to the agent itself or its spawn-parent");
|
|
482
|
+
return { agentId, report: report ?? null };
|
|
483
|
+
}
|
|
484
|
+
// No agentId: list the caller's own spawned children's reports.
|
|
485
|
+
if (!caller) throw new McpAuthError("relay_agent_crash_report requires a resolvable caller agent to list reports");
|
|
486
|
+
const limit = optionalPositiveInt(args.limit, "limit");
|
|
487
|
+
const reports = listCrashReports({ parentId: caller, ...(limit ? { limit } : {}) });
|
|
488
|
+
return { reports, count: reports.length };
|
|
489
|
+
}
|
|
490
|
+
|
|
453
491
|
function payloadWithAttachments(
|
|
454
492
|
payload: Record<string, unknown> | undefined,
|
|
455
493
|
attachments: AttachmentRef[] | undefined,
|
|
@@ -11,6 +11,7 @@ export type NotificationType =
|
|
|
11
11
|
| "agent.transient_land_hold"
|
|
12
12
|
| "agent.rate_limit_resume"
|
|
13
13
|
| "team.idle"
|
|
14
|
+
| "team.member_silent"
|
|
14
15
|
| "plan.bound_agent_exited"
|
|
15
16
|
| "workspace.steward_task"
|
|
16
17
|
| "workspace.conflict"
|
|
@@ -62,6 +63,7 @@ export const DEFAULT_TTL_MS: Record<NotificationType, number | null> = {
|
|
|
62
63
|
"agent.transient_land_hold": null,
|
|
63
64
|
"agent.rate_limit_resume": 15 * MINUTES,
|
|
64
65
|
"team.idle": null,
|
|
66
|
+
"team.member_silent": null,
|
|
65
67
|
"plan.bound_agent_exited": null,
|
|
66
68
|
"workspace.steward_task": null,
|
|
67
69
|
"workspace.conflict": null,
|
package/src/routes/_shared.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { ValidationError, createActivityEvent, createCallbackDelivery, finishCal
|
|
|
5
5
|
import { cleanEpoch, cleanString, optionalEnum } from "../validation";
|
|
6
6
|
import { emitActivityEvent, emitMessageQueued, emitNewMessage } from "../sse";
|
|
7
7
|
import { emitRelayEvent } from "../events";
|
|
8
|
-
import { canonicalHumanAgentId, DEFAULT_USER_ID, errMessage, isRecord, userSinkId } from "agent-relay-sdk";
|
|
8
|
+
import { canonicalHumanAgentId, DEFAULT_USER_ID, errMessage, isHumanAgentId, isRecord, userIdFromAddress, userSinkId } from "agent-relay-sdk";
|
|
9
9
|
import { authorizeAgentForUser, visibleAgentIdsForUser, type AgentAction } from "../agent-authz";
|
|
10
10
|
import { generateSpawnRequestId } from "../spawn-command";
|
|
11
11
|
import { getComponentAuth, getIntegrationAuth, isAuthorized, isRequestAuthorizedFor } from "../security";
|
|
@@ -58,13 +58,51 @@ export function authAuditMetadata(req: Request): Record<string, unknown> {
|
|
|
58
58
|
},
|
|
59
59
|
};
|
|
60
60
|
}
|
|
61
|
-
|
|
61
|
+
if (!hasPresentedCredential(req)) return {};
|
|
62
|
+
// Root (god-token) credential: stamp the seeded-admin identity it now carries (#389 break-glass),
|
|
63
|
+
// so the audit trail records *who* acted instead of an anonymous root.
|
|
64
|
+
const acting = resolveActingUser(req);
|
|
65
|
+
return acting
|
|
66
|
+
? { auth: { type: "root", userId: acting.userId, source: acting.source } }
|
|
67
|
+
: { auth: { type: "root" } };
|
|
62
68
|
}
|
|
63
69
|
|
|
64
70
|
export function isRootCredentialRequest(req: Request): boolean {
|
|
65
71
|
return hasPresentedCredential(req) && isAuthorized(req) && !getComponentAuth(req) && !getIntegrationAuth(req);
|
|
66
72
|
}
|
|
67
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Which human is acting, and how their request was authenticated (#389). The single bridge from a
|
|
76
|
+
* request to a `users.id`:
|
|
77
|
+
* - a human session token (component token whose `sub` is a `user:<id>` address) → that user, via
|
|
78
|
+
* the SDK resolver (never a hand-rolled namespace) — `source: "session"`;
|
|
79
|
+
* - the raw god-token / root credential (`isRootCredentialRequest`) → the seeded admin
|
|
80
|
+
* `DEFAULT_USER_ID` with `source: "break-glass"`. This is what gives the env token an *identity*
|
|
81
|
+
* instead of the anonymous "server" it carried before, while it KEEPS bypassing scope checks
|
|
82
|
+
* unchanged (the gate in src/index.ts is untouched). Works with zero `auth_identities` rows, so
|
|
83
|
+
* it is simultaneously the break-glass path and the first-user bootstrap.
|
|
84
|
+
*
|
|
85
|
+
* Returns null for a machine component/integration token (no human is acting).
|
|
86
|
+
*/
|
|
87
|
+
export type ActingUserSource = "session" | "break-glass";
|
|
88
|
+
|
|
89
|
+
export interface ActingUser {
|
|
90
|
+
userId: string;
|
|
91
|
+
source: ActingUserSource;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function resolveActingUser(req: Request): ActingUser | null {
|
|
95
|
+
const component = getComponentAuth(req);
|
|
96
|
+
if (component && isHumanAgentId(component.sub)) {
|
|
97
|
+
const userId = userIdFromAddress(component.sub);
|
|
98
|
+
if (userId) return { userId, source: "session" };
|
|
99
|
+
}
|
|
100
|
+
if (isRootCredentialRequest(req)) {
|
|
101
|
+
return { userId: DEFAULT_USER_ID, source: "break-glass" };
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
68
106
|
function sanitizeAttribution(raw: unknown): string | undefined {
|
|
69
107
|
if (typeof raw !== "string") return undefined;
|
|
70
108
|
// eslint-disable-next-line no-control-regex
|
package/src/routes/agents.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Auto-split from routes.ts (#299). Domain: agents.
|
|
2
2
|
import { VALID_AGENT_STATUSES, agentSessionStatus, auditEvent, canViewAgentRoute, error, json, memoryContext, memoryErrorResponse, normalizeAgentSessionGuard, parseBody, parseId, parseQueryInt, visibleAgentIdsForRequest, type Handler } from "./_shared";
|
|
3
|
-
import { ValidationError, deleteAgent, getAgent, getAgentTimeline, heartbeat, listAgentChatHistory, listAgents, listArtifactsForEntity, listContextSnapshots, listPendingReplyObligations, listQuotaSnapshots, markReady, searchAgents, setLabel, setStatus, setTags, validateAgentSession } from "../db";
|
|
3
|
+
import { ValidationError, deleteAgent, getAgent, getAgentTimeline, getCrashReport, heartbeat, listAgentChatHistory, listAgents, listArtifactsForEntity, listContextSnapshots, listPendingReplyObligations, listQuotaSnapshots, markReady, searchAgents, setLabel, setStatus, setTags, validateAgentSession } from "../db";
|
|
4
4
|
import { attachBranchState, attachBranchStates } from "../agent-branch-state";
|
|
5
5
|
import { attachIssueRepo, attachIssueRepos } from "../agent-issue-repo";
|
|
6
6
|
import { cleanStringArray } from "../validation";
|
|
@@ -68,6 +68,16 @@ export const getAgentById: Handler = (req, params) => {
|
|
|
68
68
|
return json(attachIssueRepo(attachBranchState(agent)));
|
|
69
69
|
};
|
|
70
70
|
|
|
71
|
+
// #636 — "why did it die" data source. The crash report outlives the agent row (separate table),
|
|
72
|
+
// so this resolves even after the reaper removed the agent. Existence-hiding still applies WHILE the
|
|
73
|
+
// agent exists; once reaped, an authenticated operator can read the post-mortem.
|
|
74
|
+
export const getAgentCrashReportRoute: Handler = (req, params) => {
|
|
75
|
+
const id = params.id!;
|
|
76
|
+
const agent = getAgent(id);
|
|
77
|
+
if (agent && !canViewAgentRoute(req, agent.id)) return error("agent not found", 404);
|
|
78
|
+
return json({ agentId: id, report: getCrashReport(id) });
|
|
79
|
+
};
|
|
80
|
+
|
|
71
81
|
export const getAgentReplyObligations: Handler = (_req, params) => {
|
|
72
82
|
const agent = getAgent(params.id!);
|
|
73
83
|
return agent ? json(listPendingReplyObligations(agent.id)) : error("agent not found", 404);
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Authentication & session routes (#389). These four paths sit on a pre-auth PUBLIC ALLOWLIST
|
|
2
|
+
// (src/index.ts) so they are reachable unauthenticated — each handler owns its own auth: login
|
|
3
|
+
// verifies credentials, refresh validates the refresh token, logout revokes the presented session.
|
|
4
|
+
// Everything else on the relay still flows through the existing component-token gate.
|
|
5
|
+
import { isRecord, type User } from "agent-relay-sdk";
|
|
6
|
+
import { error, json, parseBody, type Handler } from "./_shared";
|
|
7
|
+
import { getAuthProvider, listAuthProviders, mintUserSession, resolveRefreshToken } from "../auth";
|
|
8
|
+
import { getAuthIdentity, getUser } from "../db";
|
|
9
|
+
import { revokeToken } from "../token-db.ts";
|
|
10
|
+
import { getComponentAuth } from "../security";
|
|
11
|
+
|
|
12
|
+
/** Public projection of a user for login/refresh responses — never leaks internal-only columns. */
|
|
13
|
+
function userView(user: User): Record<string, unknown> {
|
|
14
|
+
return {
|
|
15
|
+
id: user.id,
|
|
16
|
+
handle: user.handle,
|
|
17
|
+
displayName: user.displayName,
|
|
18
|
+
role: user.role,
|
|
19
|
+
tenantId: user.tenantId,
|
|
20
|
+
avatarUrl: user.avatarUrl,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function sessionResponse(user: User): Response {
|
|
25
|
+
const session = mintUserSession(user);
|
|
26
|
+
return json({
|
|
27
|
+
tokenType: "Bearer",
|
|
28
|
+
accessToken: session.accessToken,
|
|
29
|
+
refreshToken: session.refreshToken,
|
|
30
|
+
expiresIn: session.expiresIn,
|
|
31
|
+
user: userView(user),
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** GET /api/auth/providers — the login screen reads this to render available login methods. */
|
|
36
|
+
export const getAuthProvidersRoute: Handler = () =>
|
|
37
|
+
json({
|
|
38
|
+
providers: listAuthProviders().map((provider) => ({
|
|
39
|
+
id: provider.id,
|
|
40
|
+
displayName: provider.displayName,
|
|
41
|
+
flow: provider.flow,
|
|
42
|
+
})),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* POST /api/auth/login — body `{ provider, ...credentials }`. Resolves the provider, verifies the
|
|
47
|
+
* credentials to a `providerSubject`, maps it through `auth_identities` to a user, and mints a
|
|
48
|
+
* session. Every failure returns the same generic 401 (no provider/user/credential enumeration).
|
|
49
|
+
*/
|
|
50
|
+
export const postAuthLoginRoute: Handler = async (req) => {
|
|
51
|
+
const parsed = await parseBody<unknown>(req);
|
|
52
|
+
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
53
|
+
if (!isRecord(parsed.body)) return error("login body required", 400);
|
|
54
|
+
const providerId = parsed.body.provider;
|
|
55
|
+
if (typeof providerId !== "string" || !providerId) return error("provider required", 400);
|
|
56
|
+
|
|
57
|
+
const provider = getAuthProvider(providerId);
|
|
58
|
+
if (!provider) return error("invalid credentials", 401);
|
|
59
|
+
|
|
60
|
+
const result = await provider.verify(parsed.body);
|
|
61
|
+
if (!result) return error("invalid credentials", 401);
|
|
62
|
+
|
|
63
|
+
const identity = getAuthIdentity(provider.id, result.providerSubject);
|
|
64
|
+
if (!identity) return error("invalid credentials", 401);
|
|
65
|
+
|
|
66
|
+
const user = getUser(identity.userId);
|
|
67
|
+
if (!user || user.status !== "active") return error("invalid credentials", 401);
|
|
68
|
+
|
|
69
|
+
return sessionResponse(user);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* POST /api/auth/refresh — body `{ refreshToken }`. Validates the refresh token and mints a fresh
|
|
74
|
+
* session, rotating the refresh token: the consumed one is revoked so a captured refresh token is
|
|
75
|
+
* single-use. Returns the same generic 401 on any invalid/expired/revoked token.
|
|
76
|
+
*/
|
|
77
|
+
export const postAuthRefreshRoute: Handler = async (req) => {
|
|
78
|
+
const parsed = await parseBody<unknown>(req);
|
|
79
|
+
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
80
|
+
if (!isRecord(parsed.body)) return error("refresh body required", 400);
|
|
81
|
+
const refreshToken = parsed.body.refreshToken;
|
|
82
|
+
if (typeof refreshToken !== "string" || !refreshToken) return error("refreshToken required", 400);
|
|
83
|
+
|
|
84
|
+
const resolved = resolveRefreshToken(refreshToken);
|
|
85
|
+
if (!resolved) return error("invalid refresh token", 401);
|
|
86
|
+
|
|
87
|
+
const response = sessionResponse(resolved.user);
|
|
88
|
+
// Rotate: revoke the consumed refresh token so it can't be replayed (the fresh pair supersedes it).
|
|
89
|
+
if (resolved.payload.jti) revokeToken(resolved.payload.jti, "rotation");
|
|
90
|
+
return response;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* POST /api/auth/logout — revokes the presented session token and, if supplied, its refresh token.
|
|
95
|
+
* Idempotent: revoking an already-revoked or absent token is a no-op. Always returns 204.
|
|
96
|
+
*/
|
|
97
|
+
export const postAuthLogoutRoute: Handler = async (req) => {
|
|
98
|
+
const component = getComponentAuth(req);
|
|
99
|
+
if (component?.jti) revokeToken(component.jti, "admin");
|
|
100
|
+
|
|
101
|
+
const parsed = await parseBody<unknown>(req);
|
|
102
|
+
if (parsed.ok && isRecord(parsed.body) && typeof parsed.body.refreshToken === "string") {
|
|
103
|
+
const resolved = resolveRefreshToken(parsed.body.refreshToken);
|
|
104
|
+
if (resolved?.payload.jti) revokeToken(resolved.payload.jti, "admin");
|
|
105
|
+
}
|
|
106
|
+
return new Response(null, { status: 204 });
|
|
107
|
+
};
|
package/src/routes/commands.ts
CHANGED
|
@@ -16,7 +16,7 @@ import { notifyBranchLandFailed, notifyBranchLanded } from "../branch-landed";
|
|
|
16
16
|
import { seedProviderConfigs } from "../provider-config-store";
|
|
17
17
|
import { notifyAgentSpawnFailed } from "../agent-lifecycle-events";
|
|
18
18
|
import { handleReviewerMergeResult } from "../reviewer-pipeline";
|
|
19
|
-
import { scheduleRepoMerge } from "../workspace-auto-merge";
|
|
19
|
+
import { CLEAR_MERGE_NO_PROGRESS, noteMergeNoProgress, scheduleRepoMerge } from "../workspace-auto-merge";
|
|
20
20
|
import { enqueueContinuationInjectionAfterSelfResume } from "../self-resume";
|
|
21
21
|
import { type Command, type CommandStatus, type CreateCommandInput, type WorkspaceStatus } from "../types";
|
|
22
22
|
|
|
@@ -220,6 +220,11 @@ export const patchCommand: Handler = async (req, params) => {
|
|
|
220
220
|
mergeResult: command.result,
|
|
221
221
|
mergeCommandId: command.id,
|
|
222
222
|
mergedAt: Date.now(),
|
|
223
|
+
// A merge that settles `succeeded` made genuine progress (land / recycle /
|
|
224
|
+
// noop-resolve / PR-opened) — clear any prior no-progress back-off so the lane
|
|
225
|
+
// is attempted normally next time (#638).
|
|
226
|
+
...CLEAR_MERGE_NO_PROGRESS,
|
|
227
|
+
mergeError: undefined,
|
|
223
228
|
...(normalizedStatus !== resultStatus ? { mergeStatusNormalizedFrom: resultStatus } : {}),
|
|
224
229
|
// The land consumes the steward's claim (#208 steward contract / vent
|
|
225
230
|
// #62): a successful land must release it, or `steward inspect` keeps
|
|
@@ -259,12 +264,25 @@ export const patchCommand: Handler = async (req, params) => {
|
|
|
259
264
|
}
|
|
260
265
|
}
|
|
261
266
|
} else if (command.status === "failed" && command.correlationId) {
|
|
262
|
-
// Merge couldn't complete — don't leave it stuck in merge_planned.
|
|
267
|
+
// Merge couldn't complete — don't leave it stuck in merge_planned. A no-progress
|
|
268
|
+
// merge now settles `failed` (orchestrator #638) carrying the result; honor the
|
|
269
|
+
// orchestrator's intended target status when it gave a valid one so a real
|
|
270
|
+
// `conflict` routes to the steward (a non-landable status the auto-merge job
|
|
271
|
+
// skips, which itself stops the loop) rather than bouncing back to the landable
|
|
272
|
+
// `review_requested`. Surface the error and count the no-progress attempt so the
|
|
273
|
+
// auto-merge job backs off after N instead of busy-looping (#638).
|
|
263
274
|
const current = getWorkspace(command.correlationId);
|
|
264
275
|
if (current && current.status === "merge_planned") {
|
|
265
|
-
|
|
276
|
+
const failedResultStatus = isRecord(command.result)
|
|
277
|
+
? optionalEnum(command.result.status, "result.status", VALID_WORKSPACE_STATUSES) as WorkspaceStatus | undefined
|
|
278
|
+
: undefined;
|
|
279
|
+
const nextStatus: WorkspaceStatus = failedResultStatus && failedResultStatus !== "merge_planned"
|
|
280
|
+
? failedResultStatus
|
|
281
|
+
: "review_requested";
|
|
282
|
+
updateWorkspaceStatus(command.correlationId, nextStatus, {
|
|
266
283
|
mergeCommandId: command.id,
|
|
267
284
|
mergeError: command.error ?? "merge failed",
|
|
285
|
+
...noteMergeNoProgress(current.metadata),
|
|
268
286
|
});
|
|
269
287
|
notifyBranchLandFailed({
|
|
270
288
|
workspace: current,
|
package/src/routes/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Auto-split from routes.ts (#299). Router: route table + matchRoute dispatch.
|
|
2
|
-
import { deleteAgentActiveMemories, deleteAgentById, findAgents, getAgentActiveMemories, getAgentById, getAgentChatHistory, getAgentContextSnapshots, getAgentQuotaSnapshots, getAgentReplyObligations, getAgentTimelineRoute, getAgents, getMessageArtifactsRoute, getTaskArtifactsRoute, patchAgentLabel, patchAgentReady, patchAgentStatus, patchAgentTags, postHeartbeat } from "./agents";
|
|
2
|
+
import { deleteAgentActiveMemories, deleteAgentById, findAgents, getAgentActiveMemories, getAgentById, getAgentChatHistory, getAgentContextSnapshots, getAgentCrashReportRoute, getAgentQuotaSnapshots, getAgentReplyObligations, getAgentTimelineRoute, getAgents, getMessageArtifactsRoute, getTaskArtifactsRoute, patchAgentLabel, patchAgentReady, patchAgentStatus, patchAgentTags, postHeartbeat } from "./agents";
|
|
3
3
|
import { deleteAgentProfileRoute, getAgentProfileRoute, getAgentProfilesRoute, putAgentProfileRoute } from "./agent-profiles";
|
|
4
4
|
import { deleteAgentTerminalSession, getAgentDrive, getAgentPresence, postAgentAction, postAgentDrive, postAgentPermissionDecision, postAgentPresence, postAgentPrompt, postAgentTerminalSession } from "./agent-sessions";
|
|
5
5
|
import { deleteMyAvatar, deleteUserAvatarById, getMe, getUserAvatar, getUsers, headUserAvatar, patchMe, patchUserById, postMyAvatar, postUserAvatarById } from "./users";
|
|
@@ -18,6 +18,7 @@ import { deleteSpawnPolicyRoute, getSpawnPoliciesHealth, getSpawnPoliciesRoute,
|
|
|
18
18
|
import { deleteTokenProfileRoute, getTokenById, getTokenMe, getTokenProfiles, getTokens, patchTokenProfile, postIntegrationRuntimeToken, postInteractiveRunnerRuntimeToken, postMcpRuntimeToken, postOrchestratorRunnerToken, postRuntimeTokenRenew, postToken, postTokenProfile, postTokenRevoke } from "./tokens";
|
|
19
19
|
import { deleteWorkspaceById, getWorkspaceById, getWorkspaceDiagnostics, getWorkspaceDiff, getWorkspaceGitState, getWorkspaceMergePreview, getWorkspaceOrphans, getWorkspaceRecoveryBranches, getWorkspaceStewards, getWorkspaces, postWorkspaceAction, postWorkspaceCleanupStale, postWorkspaceOrphanReclaim, postWorkspaceRecoveryBranchDiscard } from "./workspaces";
|
|
20
20
|
import { error, type Handler } from "./_shared";
|
|
21
|
+
import { getAuthProvidersRoute, postAuthLoginRoute, postAuthLogoutRoute, postAuthRefreshRoute } from "./auth";
|
|
21
22
|
import { getActivityEvents, postActivityEvent } from "./activity";
|
|
22
23
|
import { getAnalyticsRoute, getHealthRoute, getMaintenanceJobs, getStatsRoute, postMaintenanceJobRun, postSystemReap } from "./stats";
|
|
23
24
|
import { getApiDocs, getApiSpec } from "./spec";
|
|
@@ -69,6 +70,13 @@ function route(method: string, path: string, handler: Handler): Route {
|
|
|
69
70
|
}
|
|
70
71
|
|
|
71
72
|
const routes: Route[] = [
|
|
73
|
+
// Auth & sessions (#389). On the pre-auth public allowlist (src/index.ts) — each handler owns
|
|
74
|
+
// its own auth (login verifies credentials, refresh validates the refresh token, logout revokes).
|
|
75
|
+
route("GET", "/api/auth/providers", getAuthProvidersRoute),
|
|
76
|
+
route("POST", "/api/auth/login", postAuthLoginRoute),
|
|
77
|
+
route("POST", "/api/auth/refresh", postAuthRefreshRoute),
|
|
78
|
+
route("POST", "/api/auth/logout", postAuthLogoutRoute),
|
|
79
|
+
|
|
72
80
|
route("POST", "/api/artifacts", postArtifact),
|
|
73
81
|
route("GET", "/api/artifacts", getArtifacts),
|
|
74
82
|
route("GET", "/api/artifacts/:id/content", getArtifactContent),
|
|
@@ -113,6 +121,7 @@ const routes: Route[] = [
|
|
|
113
121
|
route("GET", "/api/agents/:id/timeline", getAgentTimelineRoute),
|
|
114
122
|
route("GET", "/api/agents/:id/active-memories", getAgentActiveMemories),
|
|
115
123
|
route("GET", "/api/agents/:id/reply-obligations", getAgentReplyObligations),
|
|
124
|
+
route("GET", "/api/agents/:id/crash-report", getAgentCrashReportRoute),
|
|
116
125
|
route("DELETE", "/api/agents/:id/active-memories", deleteAgentActiveMemories),
|
|
117
126
|
route("GET", "/api/agents/:id", getAgentById),
|
|
118
127
|
route("PATCH", "/api/agents/:id/status", patchAgentStatus),
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { APPROVAL_MODES, SPAWN_PROVIDERS, VALID_WORKSPACE_MODES, isRecord } from "agent-relay-sdk";
|
|
3
3
|
import { CONTRACT_VERSIONS, parseRuntimeCapabilities, parseRuntimeContracts, parseRuntimePackage, type RuntimeCapabilities, type RuntimeContracts, type RuntimePackageMetadata } from "../contracts";
|
|
4
4
|
import { notifyAgentOffline } from "../agent-lifecycle-events";
|
|
5
|
+
import { diagnoseAgentDeath } from "../agent-death-diagnosis";
|
|
5
6
|
import { VERSION } from "../config";
|
|
6
7
|
import { ValidationError, deleteOrchestrator, getOrchestrator, listOrchestrators, orchestratorHeartbeat, recordAgentExitDiagnostics, setOrchestratorUpgradeState, updateManagedAgents, upsertOrchestrator } from "../db";
|
|
7
8
|
import { auditEvent, authAuditMetadata, authorizeRoute, cleanJsonArray, cleanSafeNumber, dashboardAttribution, emitCommand, error, isRootCredentialRequest, json, parseBody, spawnRequestId, type Handler } from "./_shared";
|
|
@@ -209,7 +210,10 @@ export const patchOrchestratorAgents: Handler = async (req, params) => {
|
|
|
209
210
|
// (its WHERE excludes status='offline'), guaranteeing a single notifyAgentOffline emission.
|
|
210
211
|
// notifyAgentOffline no-ops for non-spawned agents. Only fire when we actually recorded the
|
|
211
212
|
// exit (agent !== null) so an already-offline row can't double-notify.
|
|
212
|
-
|
|
213
|
+
// #636 — classify the death from the captured exit diagnostics + agent state and ride the
|
|
214
|
+
// verdict on the terminal event (also persisted as a crash report inside notifyAgentOffline).
|
|
215
|
+
const diagnosis = diagnoseAgentDeath({ agent, reason: exitReason(exited), exit: exited });
|
|
216
|
+
notifyAgentOffline(exited.agentId, exitReason(exited), diagnosis);
|
|
213
217
|
}
|
|
214
218
|
auditEvent({
|
|
215
219
|
clientId: [
|
|
@@ -324,6 +328,15 @@ function cleanManagedSessionExitDiagnostics(value: unknown, index: number): Mana
|
|
|
324
328
|
runnerInfoFile: cleanString(value.runnerInfoFile, `exitedAgents[${index}].runnerInfoFile`, { max: 500 }),
|
|
325
329
|
runnerInfoPresent: typeof value.runnerInfoPresent === "boolean" ? value.runnerInfoPresent : undefined,
|
|
326
330
|
systemd,
|
|
331
|
+
// #636 — termination signal / exit code / OOM probe captured by the orchestrator.
|
|
332
|
+
signal: cleanString(value.signal, `exitedAgents[${index}].signal`, { max: 40 }),
|
|
333
|
+
exitCode: cleanSafeNumber(value.exitCode),
|
|
334
|
+
oom: isRecord(value.oom)
|
|
335
|
+
? {
|
|
336
|
+
source: cleanString(value.oom.source, `exitedAgents[${index}].oom.source`, { required: true, max: 80 })!,
|
|
337
|
+
detail: cleanString(value.oom.detail, `exitedAgents[${index}].oom.detail`, { max: 240 }),
|
|
338
|
+
}
|
|
339
|
+
: undefined,
|
|
327
340
|
unavailable,
|
|
328
341
|
lastError: cleanString(value.lastError, `exitedAgents[${index}].lastError`, { required: true, max: 1000 })!,
|
|
329
342
|
};
|
package/src/routes/tokens.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Auto-split from routes.ts (#299). Domain: tokens.
|
|
2
|
-
import { SPAWN_PROVIDERS, isRecord } from "agent-relay-sdk";
|
|
2
|
+
import { SPAWN_PROVIDERS, isRecord, userSinkId } from "agent-relay-sdk";
|
|
3
3
|
import { ValidationError, getOrchestrator, upsertIntegrationRegistry } from "../db";
|
|
4
|
-
import { auditEvent, authAuditMetadata, authorizeRoute, error, isRootCredentialRequest, json, parseBody, type Handler } from "./_shared";
|
|
4
|
+
import { auditEvent, authAuditMetadata, authorizeRoute, error, isRootCredentialRequest, json, parseBody, resolveActingUser, type Handler } from "./_shared";
|
|
5
5
|
import { cleanString, cleanStringArray, cleanTokenConstraints, optionalEnum } from "../validation";
|
|
6
6
|
import { createToken, deleteTokenProfile, getToken, getTokenProfile, listTokenProfiles, listTokens, renewToken, revokeToken, updateTokenProfile, upsertTokenProfile } from "../token-db";
|
|
7
7
|
import { getComponentAuth, getIntegrationAuth, isAuthorized } from "../security";
|
|
@@ -36,6 +36,12 @@ export const getTokenMe: Handler = (req) => {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
if (isAuthorized(req)) {
|
|
39
|
+
// Root (god-token) now carries the seeded-admin identity (#389): report *who* is acting
|
|
40
|
+
// (break-glass → user:default) instead of the anonymous "server" it showed before.
|
|
41
|
+
const acting = resolveActingUser(req);
|
|
42
|
+
if (acting) {
|
|
43
|
+
return json({ actor: userSinkId(acting.userId), userId: acting.userId, source: acting.source, kind: "server", scopes: ["*"] });
|
|
44
|
+
}
|
|
39
45
|
return json({ actor: "server", kind: "server", scopes: ["*"] });
|
|
40
46
|
}
|
|
41
47
|
return error("unauthorized", 401);
|
package/src/security.ts
CHANGED
|
@@ -539,7 +539,7 @@ function isTokenConstraints(value: unknown): value is TokenConstraints {
|
|
|
539
539
|
for (const [key, item] of Object.entries(record)) {
|
|
540
540
|
if (["terminalAttach", "logsRead", "canDelegate"].includes(key)) {
|
|
541
541
|
if (typeof item !== "boolean") return false;
|
|
542
|
-
} else if (key === "cwd" || key === "spawnedBy" || key === "teamId" || key === "projectId") {
|
|
542
|
+
} else if (key === "cwd" || key === "spawnedBy" || key === "teamId" || key === "projectId" || key === "tenantId") {
|
|
543
543
|
if (typeof item !== "string") return false;
|
|
544
544
|
} else if (key === "maxSpawnedAgents") {
|
|
545
545
|
if (typeof item !== "number") return false;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// #633 — owning service for the silent clean-exit terminal event. A runner whose provider
|
|
2
|
+
// terminally exits stays alive (manual-resume hold), so neither the orchestrator tmux-gone path nor
|
|
3
|
+
// the heartbeat reaper fires. The runner marks the offline status edge with a `terminalProviderExit`
|
|
4
|
+
// blob; this service turns that edge into a terminal `agent.exited` event with a #636 diagnosis, and
|
|
5
|
+
// clears the hold when the provider genuinely resumes. Kept out of the bus transport so the
|
|
6
|
+
// transport stays thin (transport-thinness ratchet, #348).
|
|
7
|
+
import { isRecord } from "agent-relay-sdk";
|
|
8
|
+
import { clearAgentMetaKeys } from "../db";
|
|
9
|
+
import { notifyAgentOffline } from "../agent-lifecycle-events";
|
|
10
|
+
import { diagnoseAgentDeath } from "../agent-death-diagnosis";
|
|
11
|
+
import type { AgentCard } from "../types";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* React to an agent status transition observed on the bus.
|
|
15
|
+
* - dead→live with a stale marker: clear the hold so a keepalive can resurrect normally and a future
|
|
16
|
+
* death isn't mislabelled off a stale marker.
|
|
17
|
+
* - live→offline carrying a `terminalProviderExit` marker: fire the terminal event once (edge), with
|
|
18
|
+
* a death diagnosis (also persisted as a crash report inside notifyAgentOffline). No-op for
|
|
19
|
+
* non-spawned agents (notifyAgentOffline guards on lineage) and graceful shutdowns (no marker).
|
|
20
|
+
*/
|
|
21
|
+
export function reconcileTerminalProviderExit(agentId: string, before: AgentCard | null | undefined, after: AgentCard | null | undefined): void {
|
|
22
|
+
if (after && after.status !== "offline" && before?.status === "offline" && isRecord(after.meta?.terminalProviderExit)) {
|
|
23
|
+
clearAgentMetaKeys(agentId, ["terminalProviderExit"]);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (!after || after.status !== "offline") return;
|
|
27
|
+
if (before && before.status === "offline") return;
|
|
28
|
+
const marker = after.meta?.terminalProviderExit;
|
|
29
|
+
if (!isRecord(marker)) return;
|
|
30
|
+
const reasonText = typeof marker.reason === "string" ? marker.reason : "provider exited";
|
|
31
|
+
const reason = `provider exited (${reasonText})`;
|
|
32
|
+
notifyAgentOffline(agentId, reason, diagnoseAgentDeath({ agent: after, reason }));
|
|
33
|
+
}
|