agent-relay-server 0.120.6 → 0.121.1
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 +5 -4
- package/public/assets/{MessageBubble-So86mU9-.js → MessageBubble-CLqxCG9b.js} +2 -2
- package/public/assets/{MessageBubble-So86mU9-.js.map → MessageBubble-CLqxCG9b.js.map} +1 -1
- package/public/assets/{PlanGraphPanel-DBZYAsww.js → PlanGraphPanel-BkkToFEb.js} +2 -2
- package/public/assets/{PlanGraphPanel-DBZYAsww.js.map → PlanGraphPanel-BkkToFEb.js.map} +1 -1
- package/public/assets/{activity-CZNwmXg1.js → activity-D-ocGQGs.js} +2 -2
- package/public/assets/{activity-CZNwmXg1.js.map → activity-D-ocGQGs.js.map} +1 -1
- package/public/assets/{agent-profiles-BhadoGwi.js → agent-profiles-CRPgYTyb.js} +2 -2
- package/public/assets/{agent-profiles-BhadoGwi.js.map → agent-profiles-CRPgYTyb.js.map} +1 -1
- package/public/assets/{agents-DWn1xx9x.js → agents-Chw5E_2q.js} +2 -2
- package/public/assets/{agents-DWn1xx9x.js.map → agents-Chw5E_2q.js.map} +1 -1
- package/public/assets/{analytics-DifGnk7q.js → analytics-BmNDG0hR.js} +2 -2
- package/public/assets/{analytics-DifGnk7q.js.map → analytics-BmNDG0hR.js.map} +1 -1
- package/public/assets/{automation-BExrtKkb.js → automation-D7g90kTv.js} +2 -2
- package/public/assets/{automation-BExrtKkb.js.map → automation-D7g90kTv.js.map} +1 -1
- package/public/assets/{branch-state-badge-CyC5Qrhc.js → branch-state-badge-Bv7DhLBZ.js} +2 -2
- package/public/assets/{branch-state-badge-CyC5Qrhc.js.map → branch-state-badge-Bv7DhLBZ.js.map} +1 -1
- package/public/assets/{channels-BLkdt2XR.js → channels-CLwPeEPi.js} +2 -2
- package/public/assets/{channels-BLkdt2XR.js.map → channels-CLwPeEPi.js.map} +1 -1
- package/public/assets/{chat-C7OhKtr6.js → chat-Cgj7VKzc.js} +2 -2
- package/public/assets/{chat-C7OhKtr6.js.map → chat-Cgj7VKzc.js.map} +1 -1
- package/public/assets/{connectors-C0n12Je8.js → connectors-Br6fiTny.js} +2 -2
- package/public/assets/{connectors-C0n12Je8.js.map → connectors-Br6fiTny.js.map} +1 -1
- package/public/assets/{formatted-body-BCQT2qSb.js → formatted-body-CmkuD23M.js} +3 -3
- package/public/assets/{formatted-body-BCQT2qSb.js.map → formatted-body-CmkuD23M.js.map} +1 -1
- package/public/assets/{formatted-body-impl-Bk7wnESl.js → formatted-body-impl-DbdFmbwW.js} +2 -2
- package/public/assets/{formatted-body-impl-Bk7wnESl.js.map → formatted-body-impl-DbdFmbwW.js.map} +1 -1
- package/public/assets/{index-CRraCeo_.js → index-D9OogaB5.js} +6 -6
- package/public/assets/{index-CRraCeo_.js.map → index-D9OogaB5.js.map} +1 -1
- package/public/assets/{insights-DEgES6jz.js → insights-qvaPqWCV.js} +2 -2
- package/public/assets/{insights-DEgES6jz.js.map → insights-qvaPqWCV.js.map} +1 -1
- package/public/assets/{integrations-v14ehskT.js → integrations-DYEGSqq_.js} +2 -2
- package/public/assets/{integrations-v14ehskT.js.map → integrations-DYEGSqq_.js.map} +1 -1
- package/public/assets/{maintenance-BgVB6_gO.js → maintenance-C0HIEB-J.js} +2 -2
- package/public/assets/{maintenance-BgVB6_gO.js.map → maintenance-C0HIEB-J.js.map} +1 -1
- package/public/assets/{managed-agents-8_NKSkhI.js → managed-agents-DdS7aI38.js} +2 -2
- package/public/assets/{managed-agents-8_NKSkhI.js.map → managed-agents-DdS7aI38.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-yxQkrd2M.js → markdown-preview-impl-BnXMH1z1.js} +2 -2
- package/public/assets/{markdown-preview-impl-yxQkrd2M.js.map → markdown-preview-impl-BnXMH1z1.js.map} +1 -1
- package/public/assets/{memory-CJD8eekg.js → memory-CSwx7bz8.js} +2 -2
- package/public/assets/{memory-CJD8eekg.js.map → memory-CSwx7bz8.js.map} +1 -1
- package/public/assets/{messages-DLHMZbHZ.js → messages-Be9CmvDv.js} +2 -2
- package/public/assets/{messages-DLHMZbHZ.js.map → messages-Be9CmvDv.js.map} +1 -1
- package/public/assets/{orchestrators-DEOep7FX.js → orchestrators-BgtvvHvo.js} +2 -2
- package/public/assets/{orchestrators-DEOep7FX.js.map → orchestrators-BgtvvHvo.js.map} +1 -1
- package/public/assets/{overview-BgpfO6mt.js → overview-BvhbPWtF.js} +2 -2
- package/public/assets/{overview-BgpfO6mt.js.map → overview-BvhbPWtF.js.map} +1 -1
- package/public/assets/{pairs-8NVKHh96.js → pairs-CpXSOMx0.js} +2 -2
- package/public/assets/{pairs-8NVKHh96.js.map → pairs-CpXSOMx0.js.map} +1 -1
- package/public/assets/{projects-CZJW35C7.js → projects-DdPNPFJI.js} +2 -2
- package/public/assets/{projects-CZJW35C7.js.map → projects-DdPNPFJI.js.map} +1 -1
- package/public/assets/{prompt-settings-fqyLwUa-.js → prompt-settings-BpZse7-X.js} +2 -2
- package/public/assets/{prompt-settings-fqyLwUa-.js.map → prompt-settings-BpZse7-X.js.map} +1 -1
- package/public/assets/{registry-BKbLL4mO.js → registry-pb1gTZEg.js} +2 -2
- package/public/assets/{registry-BKbLL4mO.js.map → registry-pb1gTZEg.js.map} +1 -1
- package/public/assets/{security-BlTFTbq0.js → security-BgcX1n9D.js} +2 -2
- package/public/assets/{security-BlTFTbq0.js.map → security-BgcX1n9D.js.map} +1 -1
- package/public/assets/{select-Zd_qh0uU.js → select-DZPVpKPv.js} +2 -2
- package/public/assets/{select-Zd_qh0uU.js.map → select-DZPVpKPv.js.map} +1 -1
- package/public/assets/{settings-nHuSVRkL.js → settings-hd5kzdRB.js} +4 -4
- package/public/assets/settings-hd5kzdRB.js.map +1 -0
- package/public/assets/store-DKTLubBT.js +9 -0
- package/public/assets/store-DKTLubBT.js.map +1 -0
- package/public/assets/{tasks-C3JhQ8vB.js → tasks-C5d2LU5E.js} +2 -2
- package/public/assets/{tasks-C3JhQ8vB.js.map → tasks-C5d2LU5E.js.map} +1 -1
- package/public/assets/{teams-C9E9Yt39.js → teams-B8f2pGJe.js} +2 -2
- package/public/assets/{teams-C9E9Yt39.js.map → teams-B8f2pGJe.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-FP0JlsvS.js → terminal-viewer-impl-CxAscH0U.js} +2 -2
- package/public/assets/{terminal-viewer-impl-FP0JlsvS.js.map → terminal-viewer-impl-CxAscH0U.js.map} +1 -1
- package/public/assets/types-uVOXgvMT.js.map +1 -1
- package/public/assets/{work-queue-Ema7Hy3s.js → work-queue-9imc7aNK.js} +2 -2
- package/public/assets/{work-queue-Ema7Hy3s.js.map → work-queue-9imc7aNK.js.map} +1 -1
- package/public/assets/{workspaces-DI317fMk.js → workspaces-CLYc3yF3.js} +2 -2
- package/public/assets/{workspaces-DI317fMk.js.map → workspaces-CLYc3yF3.js.map} +1 -1
- package/public/index.html +3 -3
- package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/runner/plugins/claude/monitors/relay-monitor.provisioned.mjs +4 -0
- package/src/agent-card-projection.ts +39 -9
- package/src/agent-issue-repo.ts +10 -3
- package/src/agent-lifecycle-events.ts +23 -1
- package/src/agent-ref.ts +18 -3
- package/src/bus-outbox.ts +74 -1
- package/src/bus.ts +84 -5
- package/src/channel-target.ts +4 -0
- package/src/cli/steward.ts +15 -14
- package/src/commands-db.ts +63 -9
- package/src/config-store.ts +2 -0
- package/src/config.ts +54 -0
- package/src/db/claim-sql.ts +16 -0
- package/src/db/connection.ts +3 -1
- package/src/db/continuations.ts +44 -3
- package/src/db/index.ts +1 -0
- package/src/db/maintenance-worker.ts +91 -0
- package/src/db/message-reads.ts +34 -4
- package/src/db/messages.ts +147 -8
- package/src/db/migrations.ts +7 -0
- package/src/db/pairs.ts +1 -0
- package/src/db/retention.ts +130 -0
- package/src/db/schema.ts +11 -0
- package/src/db/stats.ts +22 -0
- package/src/event-loop-lag.ts +90 -0
- package/src/gate-resolver.ts +124 -0
- package/src/lifecycle-manager.ts +41 -10
- package/src/lifecycle-signal-registry.ts +87 -0
- package/src/maintenance/constants.ts +5 -1
- package/src/maintenance/jobs.ts +48 -8
- package/src/maintenance/workspaces.ts +75 -44
- package/src/managed-policy-escalation.ts +68 -3
- package/src/mcp/tools-spawn.ts +16 -6
- package/src/mcp-plan-tools.ts +2 -3
- package/src/notification-settings.ts +11 -0
- package/src/notification-types.ts +4 -0
- package/src/operator-alarm.ts +37 -0
- package/src/retention-config-store.ts +32 -0
- package/src/retention-config.ts +58 -0
- package/src/routes/agents.ts +4 -0
- package/src/routes/messages.ts +13 -1
- package/src/routes/orchestrator-proxy.ts +13 -1
- package/src/routes/orchestrator.ts +4 -0
- package/src/routing-policy-store.ts +11 -1
- package/src/security.ts +20 -0
- package/src/self-resume.ts +185 -21
- package/src/server.ts +4 -0
- package/src/services/plan-graph.ts +42 -12
- package/src/services/register-agent.ts +5 -1
- package/src/services/send-message.ts +99 -21
- package/src/services/spawn-agent.ts +21 -2
- package/src/services/spawn-registration-watchdog.ts +74 -9
- package/src/services/task-semantic-events.ts +17 -1
- package/src/settings/registry.ts +11 -0
- package/src/spawn-command.ts +3 -0
- package/src/spawn-targets.ts +26 -1
- package/src/sse.ts +67 -14
- package/src/workspace-auto-merge.ts +90 -34
- package/src/workspace-escalation.ts +52 -3
- package/src/workspace-human-escalation.ts +68 -0
- package/src/workspace-orphans.ts +11 -6
- package/src/workspace-phase.ts +22 -1
- package/public/assets/settings-nHuSVRkL.js.map +0 -1
- package/public/assets/store-nXGxgz2Q.js +0 -9
- package/public/assets/store-nXGxgz2Q.js.map +0 -1
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { isRecord } from "agent-relay-sdk";
|
|
2
|
+
import { ValidationError } from "./db/connection.ts";
|
|
3
|
+
|
|
4
|
+
export const RETENTION_NAMESPACE = "retention";
|
|
5
|
+
export const RETENTION_KEY = "default";
|
|
6
|
+
|
|
7
|
+
export interface RetentionConfig {
|
|
8
|
+
messageRetentionDays: number;
|
|
9
|
+
activityEventRetentionDays: number;
|
|
10
|
+
activityEventMaxRows: number;
|
|
11
|
+
spawnCommandTerminalRetentionDays: number;
|
|
12
|
+
pruneBatchSize: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const RETENTION_CONFIG_DEFAULTS: RetentionConfig = {
|
|
16
|
+
messageRetentionDays: 30,
|
|
17
|
+
activityEventRetentionDays: 14,
|
|
18
|
+
activityEventMaxRows: 100_000,
|
|
19
|
+
spawnCommandTerminalRetentionDays: 14,
|
|
20
|
+
pruneBatchSize: 10_000,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const RETENTION_CONFIG_SCHEMA = {
|
|
24
|
+
type: "object",
|
|
25
|
+
properties: {
|
|
26
|
+
messageRetentionDays: { type: "integer", minimum: 1, maximum: 3650 },
|
|
27
|
+
activityEventRetentionDays: { type: "integer", minimum: 1, maximum: 3650 },
|
|
28
|
+
activityEventMaxRows: { type: "integer", minimum: 1, maximum: 10_000_000 },
|
|
29
|
+
spawnCommandTerminalRetentionDays: { type: "integer", minimum: 1, maximum: 3650 },
|
|
30
|
+
pruneBatchSize: { type: "integer", minimum: 1, maximum: 50_000 },
|
|
31
|
+
},
|
|
32
|
+
additionalProperties: false,
|
|
33
|
+
} as const;
|
|
34
|
+
|
|
35
|
+
function cleanInteger(value: unknown, field: string, fallback: number, min: number, max: number): number {
|
|
36
|
+
if (value === undefined || value === null) return fallback;
|
|
37
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < min || value > max) {
|
|
38
|
+
throw new ValidationError(`${field} must be an integer between ${min} and ${max}`);
|
|
39
|
+
}
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function validateRetentionConfig(value: unknown): RetentionConfig {
|
|
44
|
+
if (!isRecord(value)) throw new ValidationError("retention config value must be an object");
|
|
45
|
+
return {
|
|
46
|
+
messageRetentionDays: cleanInteger(value.messageRetentionDays, "messageRetentionDays", RETENTION_CONFIG_DEFAULTS.messageRetentionDays, 1, 3650),
|
|
47
|
+
activityEventRetentionDays: cleanInteger(value.activityEventRetentionDays, "activityEventRetentionDays", RETENTION_CONFIG_DEFAULTS.activityEventRetentionDays, 1, 3650),
|
|
48
|
+
activityEventMaxRows: cleanInteger(value.activityEventMaxRows, "activityEventMaxRows", RETENTION_CONFIG_DEFAULTS.activityEventMaxRows, 1, 10_000_000),
|
|
49
|
+
spawnCommandTerminalRetentionDays: cleanInteger(
|
|
50
|
+
value.spawnCommandTerminalRetentionDays,
|
|
51
|
+
"spawnCommandTerminalRetentionDays",
|
|
52
|
+
RETENTION_CONFIG_DEFAULTS.spawnCommandTerminalRetentionDays,
|
|
53
|
+
1,
|
|
54
|
+
3650,
|
|
55
|
+
),
|
|
56
|
+
pruneBatchSize: cleanInteger(value.pruneBatchSize, "pruneBatchSize", RETENTION_CONFIG_DEFAULTS.pruneBatchSize, 1, 50_000),
|
|
57
|
+
};
|
|
58
|
+
}
|
package/src/routes/agents.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { emitAgentRemoved, emitAgentStatus } from "../sse";
|
|
|
9
9
|
import { notifyAgentReady } from "../agent-lifecycle-events";
|
|
10
10
|
import { bindSpawnedTaskToReadyAgent } from "../services/register-agent";
|
|
11
11
|
import { flushQueuedDirectMessages } from "../services/managed-running";
|
|
12
|
+
import { flushIdleLineageReport } from "../services/send-message";
|
|
12
13
|
import { isAgentUnavailable } from "../db/agent-predicates";
|
|
13
14
|
import { getCompactionWatch } from "../compaction-watch";
|
|
14
15
|
import { isRecord } from "agent-relay-sdk";
|
|
@@ -198,6 +199,9 @@ export const patchAgentStatus: Handler = async (req, params) => {
|
|
|
198
199
|
const before = getAgent(params.id!);
|
|
199
200
|
if (!setStatus(params.id!, body.status as (typeof VALID_AGENT_STATUSES)[number], guard)) return error("agent not found", 404);
|
|
200
201
|
auditAgentStateTransition(params.id!, before, getAgent(params.id!));
|
|
202
|
+
// #1040 — the HTTP status PATCH is also a busy→idle edge (a runner without a live bus socket).
|
|
203
|
+
// Flush any held turn-final report-up so it reaches the spawner on this path too.
|
|
204
|
+
if (before?.status === "busy" && body.status !== "busy") flushIdleLineageReport(params.id!);
|
|
201
205
|
} catch (e) {
|
|
202
206
|
if (e instanceof ValidationError) return error(e.message, 400);
|
|
203
207
|
throw e;
|
package/src/routes/messages.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { emitMessageClaimed, emitMessageDeleted, emitMessageDeliveryUpdated, emi
|
|
|
7
7
|
import { errMessage, isMechanicalMessageKind, isRecord } from "agent-relay-sdk";
|
|
8
8
|
import { injectMemoryForMessageDelivery } from "../memory-service";
|
|
9
9
|
import { authContextFromRequest } from "../services/auth-context";
|
|
10
|
-
import { sendMessageService } from "../services/send-message";
|
|
10
|
+
import { sendMessagesBatchService, sendMessageService } from "../services/send-message";
|
|
11
11
|
import { AmbiguousTargetError, ServiceAuthError } from "../services/errors";
|
|
12
12
|
import { type AgentSessionGuard, type Message, type SendMessageInput } from "../types";
|
|
13
13
|
|
|
@@ -73,6 +73,7 @@ function isDirectTarget(to: string): boolean {
|
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
const VALID_MSG_KINDS = ["chat", "channel.event", "task", "pair", "control", "system", "session"];
|
|
76
|
+
const MAX_MESSAGE_BATCH = 100;
|
|
76
77
|
|
|
77
78
|
export const getQueuedMessagesRoute: Handler = (req) => {
|
|
78
79
|
const url = new URL(req.url);
|
|
@@ -149,6 +150,17 @@ export const postMessage: Handler = async (req) => {
|
|
|
149
150
|
const parsed = await parseBody<unknown>(req);
|
|
150
151
|
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
151
152
|
try {
|
|
153
|
+
if (isRecord(parsed.body) && Array.isArray(parsed.body.messages)) {
|
|
154
|
+
if (parsed.body.messages.length === 0 || parsed.body.messages.length > MAX_MESSAGE_BATCH) {
|
|
155
|
+
throw new ValidationError(`messages must contain 1-${MAX_MESSAGE_BATCH} items`);
|
|
156
|
+
}
|
|
157
|
+
const inputs = parsed.body.messages.map((message) => normalizeMessageInput(message));
|
|
158
|
+
if (inputs.some((input) => input.kind !== "session")) {
|
|
159
|
+
throw new ValidationError("batch messages only supports kind=session");
|
|
160
|
+
}
|
|
161
|
+
const results = sendMessagesBatchService(inputs, authContextFromRequest(req));
|
|
162
|
+
return json({ messages: results.map((result) => result.message) }, 201);
|
|
163
|
+
}
|
|
152
164
|
const input = normalizeMessageInput(parsed.body);
|
|
153
165
|
if (!input.idempotencyKey) {
|
|
154
166
|
input.idempotencyKey = cleanString(req.headers.get("Idempotency-Key") ?? undefined, "idempotencyKey", { max: 240 });
|
|
@@ -5,7 +5,7 @@ import { authorizeRoute, cleanJsonArray, error, json, type Handler } from "./_sh
|
|
|
5
5
|
import { cleanStringArray } from "../validation";
|
|
6
6
|
import { emitOrchestratorStatus } from "../sse";
|
|
7
7
|
import { type OrchestratorRuntimeInput, type SpawnProvider } from "../types";
|
|
8
|
-
import { relayToken } from "../config";
|
|
8
|
+
import { isTrustedOrchestratorApiUrl, relayToken } from "../config";
|
|
9
9
|
import { isTerminalSessionRequestAuthorized } from "../terminal-authz";
|
|
10
10
|
|
|
11
11
|
export const getOrchestratorDirectories: Handler = async (req, params) => {
|
|
@@ -13,6 +13,9 @@ export const getOrchestratorDirectories: Handler = async (req, params) => {
|
|
|
13
13
|
if (!orch) return error("orchestrator not found", 404);
|
|
14
14
|
if (!orch.apiUrl) return error("orchestrator does not expose an API", 422);
|
|
15
15
|
if (orch.status !== "online") return error("orchestrator is offline", 422);
|
|
16
|
+
// #1024 — never forward the relay master token (or the caller's query string) to an untrusted
|
|
17
|
+
// apiUrl. Refuse the proxy outright rather than leak credentials to an attacker-controlled host.
|
|
18
|
+
if (!isTrustedOrchestratorApiUrl(orch.apiUrl)) return error("orchestrator apiUrl is not a trusted origin", 502);
|
|
16
19
|
const url = new URL(req.url);
|
|
17
20
|
const path = url.searchParams.get("path") || "";
|
|
18
21
|
const proxyUrl = `${orch.apiUrl}/api/directories${path ? `?path=${encodeURIComponent(path)}` : ""}`;
|
|
@@ -30,6 +33,9 @@ export const postOrchestratorCreateDirectory: Handler = async (req, params) => {
|
|
|
30
33
|
if (!orch) return error("orchestrator not found", 404);
|
|
31
34
|
if (!orch.apiUrl) return error("orchestrator does not expose an API", 422);
|
|
32
35
|
if (orch.status !== "online") return error("orchestrator is offline", 422);
|
|
36
|
+
// #1024 — never forward the relay master token (or the caller's query string) to an untrusted
|
|
37
|
+
// apiUrl. Refuse the proxy outright rather than leak credentials to an attacker-controlled host.
|
|
38
|
+
if (!isTrustedOrchestratorApiUrl(orch.apiUrl)) return error("orchestrator apiUrl is not a trusted origin", 502);
|
|
33
39
|
try {
|
|
34
40
|
const body = await req.json();
|
|
35
41
|
const res = await fetch(`${orch.apiUrl}/api/directories`, {
|
|
@@ -62,6 +68,9 @@ async function proxyOrchestratorGet(req: Request, orchestratorId: string, path:
|
|
|
62
68
|
if (!orch) return error("orchestrator not found", 404);
|
|
63
69
|
if (!orch.apiUrl) return error("orchestrator does not expose an API", 422);
|
|
64
70
|
if (orch.status !== "online") return error("orchestrator is offline", 422);
|
|
71
|
+
// #1024 — never forward the relay master token (or the caller's query string) to an untrusted
|
|
72
|
+
// apiUrl. Refuse the proxy outright rather than leak credentials to an attacker-controlled host.
|
|
73
|
+
if (!isTrustedOrchestratorApiUrl(orch.apiUrl)) return error("orchestrator apiUrl is not a trusted origin", 502);
|
|
65
74
|
const incoming = new URL(req.url);
|
|
66
75
|
const proxyUrl = `${orch.apiUrl}${path}${incoming.search}`;
|
|
67
76
|
const headers: Record<string, string> = {};
|
|
@@ -85,6 +94,9 @@ async function proxyOrchestratorPost(req: Request, orchestratorId: string, path:
|
|
|
85
94
|
if (!orch) return error("orchestrator not found", 404);
|
|
86
95
|
if (!orch.apiUrl) return error("orchestrator does not expose an API", 422);
|
|
87
96
|
if (orch.status !== "online") return error("orchestrator is offline", 422);
|
|
97
|
+
// #1024 — never forward the relay master token (or the caller's query string) to an untrusted
|
|
98
|
+
// apiUrl. Refuse the proxy outright rather than leak credentials to an attacker-controlled host.
|
|
99
|
+
if (!isTrustedOrchestratorApiUrl(orch.apiUrl)) return error("orchestrator apiUrl is not a trusted origin", 502);
|
|
88
100
|
const incoming = new URL(req.url);
|
|
89
101
|
const proxyUrl = `${orch.apiUrl}${path}${incoming.search}`;
|
|
90
102
|
const headers: Record<string, string> = {
|
|
@@ -11,6 +11,7 @@ import { createCommand, updateCommand } from "../commands-db";
|
|
|
11
11
|
import { emitAgentStatus, emitOrchestratorStatus } from "../sse";
|
|
12
12
|
import { getLifecycleManager } from "../lifecycle-manager";
|
|
13
13
|
import { issueOrchestratorRuntimeToken } from "../runtime-tokens";
|
|
14
|
+
import { isOrchestratorRegistrationAuthorized } from "../security";
|
|
14
15
|
import { type ManagedAgent, type ManagedSessionExitDiagnostics, type OrchestratorRuntimeInput, type RegisterOrchestratorInput, type SpawnApprovalMode, type SpawnProvider } from "../types";
|
|
15
16
|
|
|
16
17
|
function cleanProtocolVersion(value: unknown): number | undefined {
|
|
@@ -51,6 +52,9 @@ function cleanRuntimeCapabilities(value: unknown): RuntimeCapabilities | undefin
|
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
export const postOrchestrator: Handler = async (req) => {
|
|
55
|
+
// #1024 — registering/upserting an orchestrator is admin-class; a provider-agent runtime token
|
|
56
|
+
// must NOT be able to seed a rogue orchestrator row (its apiUrl is the master-token exfil sink).
|
|
57
|
+
if (!isOrchestratorRegistrationAuthorized(req)) return error("forbidden", 403);
|
|
54
58
|
const parsed = await parseBody<unknown>(req);
|
|
55
59
|
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
56
60
|
try {
|
|
@@ -278,7 +278,7 @@ function normalizeProviderQuotaConfig(raw: unknown, provider: string): ProviderQ
|
|
|
278
278
|
if (!isRecord(raw)) throw new ValidationError(`providerQuota.${provider} must be an object`);
|
|
279
279
|
const cfg: ProviderQuotaRoutingConfig = {};
|
|
280
280
|
if (raw.hardBackstopUtilization !== undefined) {
|
|
281
|
-
cfg.hardBackstopUtilization =
|
|
281
|
+
cfg.hardBackstopUtilization = cleanHardBackstopFraction(raw.hardBackstopUtilization);
|
|
282
282
|
}
|
|
283
283
|
if (raw.burnPenaltyUtilizationFloor !== undefined) {
|
|
284
284
|
cfg.burnPenaltyUtilizationFloor = cleanFraction(raw.burnPenaltyUtilizationFloor, `providerQuota.${provider}.burnPenaltyUtilizationFloor`);
|
|
@@ -293,6 +293,16 @@ function cleanQuotaFraction(value: unknown, field: string): number {
|
|
|
293
293
|
return value;
|
|
294
294
|
}
|
|
295
295
|
|
|
296
|
+
function cleanHardBackstopFraction(value: unknown): number {
|
|
297
|
+
// hardBackstopUtilization=0 is an intentional contract: disable the hard backstop
|
|
298
|
+
// for that provider. Other invalid values normalize to the default instead.
|
|
299
|
+
if (value === 0) return 0;
|
|
300
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 1) {
|
|
301
|
+
return 0.99;
|
|
302
|
+
}
|
|
303
|
+
return value;
|
|
304
|
+
}
|
|
305
|
+
|
|
296
306
|
function cleanFraction(value: unknown, field: string): number {
|
|
297
307
|
if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 1) {
|
|
298
308
|
throw new ValidationError(`${field} must be a number between 0 and 1`);
|
package/src/security.ts
CHANGED
|
@@ -153,6 +153,26 @@ export function isComponentAuthorizedFor(auth: ComponentToken, check: Authorizat
|
|
|
153
153
|
return hasComponentScope(auth, check.scope) && constraintsAllow(auth, check.resource);
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
+
// #1024 — Orchestrator registration/upsert (POST /api/orchestrators) is a privileged bootstrap
|
|
157
|
+
// operation: the row it writes carries an attacker-controllable `apiUrl` that the orchestrator
|
|
158
|
+
// proxy later hands the relay MASTER token to (the C1 admin-token-exfil chain). The coarse scope
|
|
159
|
+
// gate only requires `command:write` — which every routine provider-agent runtime token holds —
|
|
160
|
+
// so gate it explicitly here. Allowed callers: the raw root/god credential, an admin
|
|
161
|
+
// (`system:admin`) token, or an established orchestrator-host token (role "orchestrator", which
|
|
162
|
+
// re-registers on every reconnect). A provider-agent runtime token is refused, closing the chain
|
|
163
|
+
// at its entry point.
|
|
164
|
+
export function isOrchestratorRegistrationAuthorized(req: Request): boolean {
|
|
165
|
+
const component = getComponentAuth(req);
|
|
166
|
+
if (component) {
|
|
167
|
+
return hasComponentScope(component, "system:admin") || component.role === "orchestrator";
|
|
168
|
+
}
|
|
169
|
+
// Not a component token: the raw root credential, an integration token, or no credential at all.
|
|
170
|
+
// The coarse gate (which runs before this handler) already governs those — a routine unauthorized
|
|
171
|
+
// request never reaches here, and the root credential is the legitimate bootstrap path. Mirror
|
|
172
|
+
// authorizeRoute's contract and don't impose a stricter bar than the rest of the route surface.
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
|
|
156
176
|
export function requiredScopeFor(method: string, pathname: string): string | null {
|
|
157
177
|
if (pathname === "/api/stats") return "stats:read";
|
|
158
178
|
if (pathname === "/api/health") return "health:read";
|
package/src/self-resume.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { isRecord } from "agent-relay-sdk";
|
|
2
2
|
import { createCommand, listCommands } from "./commands-db";
|
|
3
3
|
import { emitCommandEvent } from "./command-events";
|
|
4
|
+
import { MAX_BODY_BYTES, REAP_INTERVAL_MS } from "./config";
|
|
4
5
|
import { getSelfResumeConfig } from "./config-store";
|
|
5
6
|
import {
|
|
6
7
|
archiveContinuationSegment,
|
|
@@ -14,7 +15,7 @@ import {
|
|
|
14
15
|
import { emitRelayEvent } from "./events";
|
|
15
16
|
import { routeNotification } from "./notification-router";
|
|
16
17
|
import { renderTemplate } from "./prompt-resolver";
|
|
17
|
-
import type { AgentCard, Command, ContinuationDecisionEntry, ContinuationEnvelope, ContinuationLedgerEntry, SelfResumeMode } from "./types";
|
|
18
|
+
import type { AgentCard, Command, CommandStatus, ContinuationDecisionEntry, ContinuationEnvelope, ContinuationLedgerEntry, SelfResumeMode } from "./types";
|
|
18
19
|
|
|
19
20
|
interface CompactAndResumeInput {
|
|
20
21
|
agentId: string;
|
|
@@ -89,7 +90,8 @@ export function compactAndResume(input: CompactAndResumeInput): CompactAndResume
|
|
|
89
90
|
spentTokens: typeof agent.context?.tokensUsed === "number" ? agent.context.tokensUsed : 0,
|
|
90
91
|
now,
|
|
91
92
|
});
|
|
92
|
-
|
|
93
|
+
if (updated.droppedLedgerEntries.length) archiveDroppedLedgerEntries(agent.id, generation, updated.droppedLedgerEntries, now);
|
|
94
|
+
const continuation = renderContinuation(updated.envelope);
|
|
93
95
|
const command = mode === "native"
|
|
94
96
|
? createNativeCompact(agent.id, generation, continuation)
|
|
95
97
|
: createClearThenInject(agent.id, generation, continuation);
|
|
@@ -100,18 +102,55 @@ export function compactAndResume(input: CompactAndResumeInput): CompactAndResume
|
|
|
100
102
|
generation,
|
|
101
103
|
commandId: command.id,
|
|
102
104
|
continuationChars: continuation.length,
|
|
103
|
-
ledgerEntries: updated.ledger.length,
|
|
105
|
+
ledgerEntries: updated.envelope.ledger.length,
|
|
104
106
|
};
|
|
105
107
|
}
|
|
106
108
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
+
// #1023 — the ruledOut ledger bound (see updateContinuationAfterCompact) drops the
|
|
110
|
+
// oldest entries once the persisted ledger grows past its cap instead of letting a
|
|
111
|
+
// long-running agent render a multi-megabyte continuation. Dropped entries are not
|
|
112
|
+
// destroyed: they're archived alongside the workingState segments so relay_recall
|
|
113
|
+
// still finds them. Chunked well under archiveContinuationSegment's MAX_BODY_BYTES
|
|
114
|
+
// limit since a large drop batch could otherwise exceed a single segment's cap.
|
|
115
|
+
const DROPPED_LEDGER_ARCHIVE_CHUNK_BYTES = MAX_BODY_BYTES / 2;
|
|
116
|
+
|
|
117
|
+
function archiveDroppedLedgerEntries(agentId: string, generation: number, dropped: ContinuationLedgerEntry[], now: number): void {
|
|
118
|
+
let chunk: string[] = [];
|
|
119
|
+
let chunkBytes = 0;
|
|
120
|
+
const flush = () => {
|
|
121
|
+
if (!chunk.length) return;
|
|
122
|
+
archiveContinuationSegment({
|
|
123
|
+
agentId,
|
|
124
|
+
generation,
|
|
125
|
+
segment: `Ruled-out ledger entries dropped for size (search via relay_recall):\n${chunk.join("\n")}`,
|
|
126
|
+
now,
|
|
127
|
+
});
|
|
128
|
+
chunk = [];
|
|
129
|
+
chunkBytes = 0;
|
|
130
|
+
};
|
|
131
|
+
for (const entry of dropped) {
|
|
132
|
+
const line = renderLedgerLine(entry);
|
|
133
|
+
const lineBytes = new TextEncoder().encode(line).byteLength;
|
|
134
|
+
if (chunk.length && chunkBytes + lineBytes > DROPPED_LEDGER_ARCHIVE_CHUNK_BYTES) flush();
|
|
135
|
+
chunk.push(line);
|
|
136
|
+
chunkBytes += lineBytes;
|
|
137
|
+
}
|
|
138
|
+
flush();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
interface PendingInjection {
|
|
142
|
+
continuation: string;
|
|
143
|
+
mode: string;
|
|
144
|
+
generation?: number;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function extractPendingInjection(command: Command): PendingInjection | null {
|
|
109
148
|
const resume = isRecord(command.params.selfResume) ? command.params.selfResume : undefined;
|
|
110
149
|
const mode = typeof resume?.mode === "string" ? resume.mode : undefined;
|
|
111
150
|
const shouldInject =
|
|
112
151
|
command.type === "agent.clearContext" && mode === "clear-inject"
|
|
113
152
|
|| command.type === "agent.compact" && mode === "native";
|
|
114
|
-
if (!shouldInject) return null;
|
|
153
|
+
if (!shouldInject || !mode) return null;
|
|
115
154
|
const continuation = typeof resume?.continuation === "string"
|
|
116
155
|
? resume.continuation
|
|
117
156
|
: typeof command.params.instructions === "string"
|
|
@@ -119,6 +158,26 @@ export function enqueueContinuationInjectionAfterSelfResume(command: Command): C
|
|
|
119
158
|
: undefined;
|
|
120
159
|
if (!continuation) return null;
|
|
121
160
|
const generation = typeof resume?.generation === "number" ? resume.generation : undefined;
|
|
161
|
+
return { continuation, mode, generation };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function buildInjectionParams(command: Command, injection: PendingInjection): Record<string, unknown> {
|
|
165
|
+
return {
|
|
166
|
+
reason: "self-resume-continuation",
|
|
167
|
+
content: injection.continuation,
|
|
168
|
+
selfResume: {
|
|
169
|
+
generation: injection.generation,
|
|
170
|
+
mode: injection.mode,
|
|
171
|
+
...(command.type === "agent.clearContext" ? { afterClearCommandId: command.id } : {}),
|
|
172
|
+
...(command.type === "agent.compact" ? { afterCompactCommandId: command.id } : {}),
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function enqueueContinuationInjectionAfterSelfResume(command: Command): Command | null {
|
|
178
|
+
if (command.status !== "succeeded") return null;
|
|
179
|
+
const injection = extractPendingInjection(command);
|
|
180
|
+
if (!injection) return null;
|
|
122
181
|
const existing = listCommands({ target: command.target, type: "agent.injectContext", limit: 500 })
|
|
123
182
|
.find((item) => item.correlationId === command.id);
|
|
124
183
|
if (existing) return null;
|
|
@@ -127,16 +186,7 @@ export function enqueueContinuationInjectionAfterSelfResume(command: Command): C
|
|
|
127
186
|
source: "system",
|
|
128
187
|
target: command.target,
|
|
129
188
|
correlationId: command.id,
|
|
130
|
-
params:
|
|
131
|
-
reason: "self-resume-continuation",
|
|
132
|
-
content: continuation,
|
|
133
|
-
selfResume: {
|
|
134
|
-
generation,
|
|
135
|
-
mode,
|
|
136
|
-
...(command.type === "agent.clearContext" ? { afterClearCommandId: command.id } : {}),
|
|
137
|
-
...(command.type === "agent.compact" ? { afterCompactCommandId: command.id } : {}),
|
|
138
|
-
},
|
|
139
|
-
},
|
|
189
|
+
params: buildInjectionParams(command, injection),
|
|
140
190
|
});
|
|
141
191
|
emitCommandEvent(inject, "command.requested");
|
|
142
192
|
return inject;
|
|
@@ -146,19 +196,101 @@ export function enqueueContinuationInjectionAfterClear(command: Command): Comman
|
|
|
146
196
|
return enqueueContinuationInjectionAfterSelfResume(command);
|
|
147
197
|
}
|
|
148
198
|
|
|
199
|
+
const CLEAR_OR_COMPACT_TYPES = ["agent.clearContext", "agent.compact"] as const;
|
|
200
|
+
const INJECTION_SWEEP_TERMINAL_STATUSES: CommandStatus[] = ["succeeded", "timed_out"];
|
|
201
|
+
const INJECTION_SWEEP_LOOKBACK_MS = 24 * 60 * 60 * 1000;
|
|
202
|
+
const MAX_INJECTION_ATTEMPTS = 3;
|
|
203
|
+
const IN_FLIGHT_OR_DONE_STATUSES: CommandStatus[] = ["succeeded", "pending", "accepted", "running"];
|
|
204
|
+
|
|
205
|
+
// #1023 — periodic backstop for the three silent mind-wipe windows found in the
|
|
206
|
+
// self-resume audit (relay message #176981):
|
|
207
|
+
// 1. runner->relay `command.update` lost in transit, so the clear/compact command
|
|
208
|
+
// never reaches "succeeded" and just times out — the reactive inject in
|
|
209
|
+
// enqueueContinuationInjectionAfterSelfResume never fires.
|
|
210
|
+
// 2. relay crash between persisting "succeeded" and creating the inject command.
|
|
211
|
+
// 3. the inject command itself fails once (e.g. provider momentarily unavailable)
|
|
212
|
+
// with no retry.
|
|
213
|
+
// This re-derives the missing inject directly from the durably persisted command
|
|
214
|
+
// params — the same idempotency key (correlationId = the clear/compact command id)
|
|
215
|
+
// used by the reactive path, so running this concurrently with it is safe. A
|
|
216
|
+
// timed-out clear/compact is treated the same as succeeded: we cannot always tell
|
|
217
|
+
// whether the runner's destructive step actually completed before the update was
|
|
218
|
+
// lost, and a redundant-but-harmless re-injection is preferable to a permanent
|
|
219
|
+
// silent mind-wipe. Retries are capped at MAX_INJECTION_ATTEMPTS; once exhausted the
|
|
220
|
+
// parent is notified once instead of retrying forever against an unreachable agent.
|
|
221
|
+
export function sweepMissedContinuationInjections(now: number = Date.now()): { enqueuedCommandIds: string[] } {
|
|
222
|
+
const enqueuedCommandIds: string[] = [];
|
|
223
|
+
const since = now - INJECTION_SWEEP_LOOKBACK_MS;
|
|
224
|
+
for (const type of CLEAR_OR_COMPACT_TYPES) {
|
|
225
|
+
for (const command of listCommands({ type, since, limit: 500 })) {
|
|
226
|
+
if (!INJECTION_SWEEP_TERMINAL_STATUSES.includes(command.status)) continue;
|
|
227
|
+
const injection = extractPendingInjection(command);
|
|
228
|
+
if (!injection) continue;
|
|
229
|
+
if (!getAgent(command.target)) continue;
|
|
230
|
+
const attempts = listCommands({ target: command.target, type: "agent.injectContext", limit: 500 })
|
|
231
|
+
.filter((item) => item.correlationId === command.id);
|
|
232
|
+
if (attempts.some((item) => IN_FLIGHT_OR_DONE_STATUSES.includes(item.status))) continue;
|
|
233
|
+
if (attempts.length >= MAX_INJECTION_ATTEMPTS) {
|
|
234
|
+
notifyContinuationInjectionExhausted(command, attempts.length);
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
const inject = createCommand({
|
|
238
|
+
type: "agent.injectContext",
|
|
239
|
+
source: "system",
|
|
240
|
+
target: command.target,
|
|
241
|
+
correlationId: command.id,
|
|
242
|
+
params: buildInjectionParams(command, injection),
|
|
243
|
+
});
|
|
244
|
+
emitCommandEvent(inject, "command.requested");
|
|
245
|
+
enqueuedCommandIds.push(inject.id);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return { enqueuedCommandIds };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export const SELF_RESUME_INJECTION_SWEEP_JOB = {
|
|
252
|
+
id: "self-resume-injection-sweep",
|
|
253
|
+
title: "Self-resume injection sweep",
|
|
254
|
+
description: "Re-deliver self-resume continuations whose inject was lost to a transport blip, a relay crash between persisting the clear/compact and enqueuing the inject, or a failed inject attempt (#1023).",
|
|
255
|
+
intervalMs: REAP_INTERVAL_MS,
|
|
256
|
+
runOnStart: true,
|
|
257
|
+
handler() {
|
|
258
|
+
return sweepMissedContinuationInjections();
|
|
259
|
+
},
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
function notifyContinuationInjectionExhausted(command: Command, attempts: number): void {
|
|
263
|
+
const idempotencyKey = `self-resume-injection-exhausted:${command.target}:${command.id}`;
|
|
264
|
+
if (findMessageByIdempotencyKey(command.target, idempotencyKey)) return;
|
|
265
|
+
const agent = getAgent(command.target);
|
|
266
|
+
const payload = { kind: "agent.resume_injection_failed", agentId: command.target, commandId: command.id, attempts };
|
|
267
|
+
emitRelayEvent({ type: "agent.resume_injection_failed", source: "server", subject: command.target, data: payload });
|
|
268
|
+
const recipient = agent?.spawnedBy ?? "user";
|
|
269
|
+
routeNotification({
|
|
270
|
+
type: "agent.resume_injection_failed",
|
|
271
|
+
scope: { agentId: command.target, parent: agent?.spawnedBy },
|
|
272
|
+
recipients: [recipient],
|
|
273
|
+
message: {
|
|
274
|
+
subject: "Self-resume continuation failed to inject",
|
|
275
|
+
body: `Agent \`${command.target}\` completed a self-resume ${command.type === "agent.compact" ? "compact" : "clear"} but its continuation could not be delivered after ${attempts} attempt(s). The agent may be running with an empty or stale context — check it and consider a manual relay_compact_and_resume or restart.`,
|
|
276
|
+
payload,
|
|
277
|
+
idempotencyKey,
|
|
278
|
+
from: command.target,
|
|
279
|
+
replyExpected: false,
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
149
284
|
export function renderContinuation(
|
|
150
285
|
envelope: ContinuationEnvelope,
|
|
151
286
|
input: { intro?: string } = {},
|
|
152
287
|
): string {
|
|
153
|
-
const ledger = envelope.ledger.length
|
|
154
|
-
? envelope.ledger.map((entry) => `- gen ${entry.gen} @ ${new Date(entry.at).toISOString()}: ${entry.ruledOut}${entry.why ? `; why: ${entry.why}` : ""}`).join("\n")
|
|
155
|
-
: "- none";
|
|
156
288
|
const decisions = envelope.decisions.length ? envelope.decisions.map(renderDecisionEntry).join("\n") : "- none";
|
|
157
289
|
return renderTemplate("continuation.envelope", {
|
|
158
290
|
intro: input.intro ?? `You have been resumed by Agent Relay — generation ${envelope.generation}.`,
|
|
159
291
|
generation: String(envelope.generation),
|
|
160
292
|
objective: envelope.objective,
|
|
161
|
-
ruledOut: ledger,
|
|
293
|
+
ruledOut: renderLedger(envelope.ledger),
|
|
162
294
|
decisions,
|
|
163
295
|
workingState: envelope.workingState,
|
|
164
296
|
archiveRefs: envelope.archiveRefs.length ? envelope.archiveRefs.join("\n") : "- none",
|
|
@@ -170,6 +302,31 @@ function renderDecisionEntry(entry: ContinuationDecisionEntry): string {
|
|
|
170
302
|
return `- gen ${entry.gen} @ ${new Date(entry.at).toISOString()}${by}: ${entry.decision}${entry.rationale ? `; rationale: ${entry.rationale}` : ""}`;
|
|
171
303
|
}
|
|
172
304
|
|
|
305
|
+
function renderLedgerLine(entry: ContinuationLedgerEntry): string {
|
|
306
|
+
return `- gen ${entry.gen} @ ${new Date(entry.at).toISOString()}: ${entry.ruledOut}${entry.why ? `; why: ${entry.why}` : ""}`;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// #1023 — render-time backstop on top of the persisted bound in
|
|
310
|
+
// updateContinuationAfterCompact. Even if that bound is ever bypassed by a future
|
|
311
|
+
// bug, this guarantees the ledger section injected into a fresh context can't blow
|
|
312
|
+
// past a fixed size; it keeps the newest entries and notes what was omitted rather
|
|
313
|
+
// than truncating mid-entry.
|
|
314
|
+
const MAX_RENDERED_LEDGER_CHARS = 300_000;
|
|
315
|
+
|
|
316
|
+
function renderLedger(entries: ContinuationLedgerEntry[]): string {
|
|
317
|
+
if (!entries.length) return "- none";
|
|
318
|
+
const lines = entries.map(renderLedgerLine);
|
|
319
|
+
let start = 0;
|
|
320
|
+
let totalChars = lines.reduce((sum, line) => sum + line.length + 1, 0);
|
|
321
|
+
while (totalChars > MAX_RENDERED_LEDGER_CHARS && start < lines.length - 1) {
|
|
322
|
+
totalChars -= lines[start]!.length + 1;
|
|
323
|
+
start += 1;
|
|
324
|
+
}
|
|
325
|
+
if (start === 0) return lines.join("\n");
|
|
326
|
+
const omitted = start;
|
|
327
|
+
return `- (${omitted} older ruled-out ${omitted === 1 ? "entry" : "entries"} omitted for size — see relay_recall for the archived history)\n${lines.slice(start).join("\n")}`;
|
|
328
|
+
}
|
|
329
|
+
|
|
173
330
|
function assertResumeSupported(agent: AgentCard, mode: SelfResumeMode): void {
|
|
174
331
|
const context = agent.providerCapabilities?.context;
|
|
175
332
|
const resume = context?.resume ?? (context?.clear && context?.inject ? "clear-inject" : "none");
|
|
@@ -320,7 +477,7 @@ function notifySelfResumeBudgetWarning(agent: AgentCard, envelope: ContinuationE
|
|
|
320
477
|
function parseRuledOut(value: unknown, generation: number, now: number): ContinuationLedgerEntry[] {
|
|
321
478
|
if (value === undefined || value === null) return [];
|
|
322
479
|
if (!Array.isArray(value)) throw new ValidationError("ruledOut must be an array");
|
|
323
|
-
|
|
480
|
+
const entries = value.map((item, index) => {
|
|
324
481
|
if (typeof item === "string") return ledgerEntry(item, "", generation, now);
|
|
325
482
|
if (!isRecord(item)) throw new ValidationError(`ruledOut[${index}] must be a string or object`);
|
|
326
483
|
const ruledOut = typeof item.ruledOut === "string" ? item.ruledOut.trim() : "";
|
|
@@ -328,6 +485,13 @@ function parseRuledOut(value: unknown, generation: number, now: number): Continu
|
|
|
328
485
|
if (!ruledOut) throw new ValidationError(`ruledOut[${index}].ruledOut required`);
|
|
329
486
|
return ledgerEntry(ruledOut, why, generation, now);
|
|
330
487
|
});
|
|
488
|
+
// #1023 — ruledOut had no size limit at all (unlike the sibling workingState/objective
|
|
489
|
+
// fields, both capped at/near MAX_BODY_BYTES): a single legal call could inject a
|
|
490
|
+
// multi-megabyte continuation straight into the fresh context it was meant to fit
|
|
491
|
+
// inside. Bound total new content per call to the same ceiling as workingState.
|
|
492
|
+
const totalBytes = entries.reduce((sum, entry) => sum + new TextEncoder().encode(entry.ruledOut).byteLength + new TextEncoder().encode(entry.why).byteLength, 0);
|
|
493
|
+
if (totalBytes > MAX_BODY_BYTES) throw new ValidationError(`ruledOut exceeds ${MAX_BODY_BYTES} bytes for a single call — split it across multiple relay_compact_and_resume calls`);
|
|
494
|
+
return entries;
|
|
331
495
|
}
|
|
332
496
|
|
|
333
497
|
function ledgerEntry(ruledOut: string, why: string, generation: number, now: number): ContinuationLedgerEntry {
|
package/src/server.ts
CHANGED
|
@@ -13,8 +13,10 @@ import { installNotificationSubscriptionRule } from "./notification-subscription
|
|
|
13
13
|
import { installQuotaAdvisoryHandler } from "./quota-advisory";
|
|
14
14
|
import { startPlanLiveBindings } from "./services/plan-live-bindings";
|
|
15
15
|
import { startRelayScheduler } from "./services/scheduler";
|
|
16
|
+
import { startEventLoopLagSampler } from "./event-loop-lag";
|
|
16
17
|
import { recordWorkspaceIssueActivity } from "./services/spawn-issue-activity";
|
|
17
18
|
import { onWorkspaceCreate } from "./db";
|
|
19
|
+
import { warnIfNoHumanReachableEscalationTarget } from "./workspace-human-escalation";
|
|
18
20
|
import { isTerminalSessionRequestAuthorized } from "./terminal-authz";
|
|
19
21
|
import { resolve, sep } from "path";
|
|
20
22
|
import { gzipSync, brotliCompressSync, constants as zlibConstants } from "node:zlib";
|
|
@@ -80,6 +82,8 @@ export function startServer(): void {
|
|
|
80
82
|
getCompactionWatch().start();
|
|
81
83
|
reconcileRelayVersion();
|
|
82
84
|
startConnectorStatusPoller();
|
|
85
|
+
warnIfNoHumanReachableEscalationTarget();
|
|
86
|
+
startEventLoopLagSampler();
|
|
83
87
|
startMaintenanceScheduler();
|
|
84
88
|
startRelayScheduler();
|
|
85
89
|
|