@sema-agent/server 1.314.0 → 1.316.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/dist/approval-hmac.d.ts +17 -0
- package/dist/approval-hmac.js +27 -0
- package/dist/config-center/apply-effective.d.ts +22 -0
- package/dist/config-center/apply-effective.js +283 -0
- package/dist/config-center/http-client.d.ts +23 -0
- package/dist/config-center/http-client.js +109 -0
- package/dist/config-center/restart-signal.d.ts +12 -0
- package/dist/config-center/restart-signal.js +70 -0
- package/dist/config-center/skills-mcp.d.ts +13 -0
- package/dist/config-center/skills-mcp.js +113 -0
- package/dist/config-center/types.d.ts +143 -0
- package/dist/config-center/types.js +2 -0
- package/dist/fleet/fleet-bus.js +92 -83
- package/dist/hooks/hook-runner.js +18 -9
- package/dist/http/idempotency.d.ts +1 -0
- package/dist/http/idempotency.js +3 -0
- package/dist/http/principal-gate.d.ts +12 -0
- package/dist/http/principal-gate.js +52 -0
- package/dist/http/route-ctx.d.ts +67 -0
- package/dist/http/route-ctx.js +2 -0
- package/dist/http/routes/attachments.d.ts +4 -0
- package/dist/http/routes/attachments.js +106 -0
- package/dist/http/routes/capabilities.d.ts +4 -0
- package/dist/http/routes/capabilities.js +111 -0
- package/dist/http/routes/images.d.ts +20 -0
- package/dist/http/routes/images.js +572 -0
- package/dist/http/routes/memory-policy.d.ts +4 -0
- package/dist/http/routes/memory-policy.js +105 -0
- package/dist/http/routes/observability.d.ts +4 -0
- package/dist/http/routes/observability.js +63 -0
- package/dist/http/routes/session-sync.d.ts +36 -0
- package/dist/http/routes/session-sync.js +513 -0
- package/dist/http/routes/sessions-list.d.ts +4 -0
- package/dist/http/routes/sessions-list.js +117 -0
- package/dist/http/routes/sessions.d.ts +17 -0
- package/dist/http/routes/sessions.js +746 -0
- package/dist/http/send.d.ts +6 -0
- package/dist/http/send.js +19 -0
- package/dist/http/server.d.ts +101 -76
- package/dist/http/server.js +129 -2410
- package/dist/http/sse-log.d.ts +25 -0
- package/dist/http/sse-log.js +59 -0
- package/dist/http/workspace-content.d.ts +3 -0
- package/dist/http/workspace-content.js +20 -0
- package/dist/index.d.ts +1 -1
- package/dist/leader/leader.js +5 -2
- package/dist/leader/wire.js +169 -165
- package/dist/main.js +462 -451
- package/dist/plugins/approval-store-sql.d.ts +52 -0
- package/dist/plugins/approval-store-sql.js +119 -0
- package/dist/plugins/breaker-state-sql.d.ts +29 -0
- package/dist/plugins/breaker-state-sql.js +104 -0
- package/dist/plugins/checkpoint-store-sql.d.ts +67 -0
- package/dist/plugins/checkpoint-store-sql.js +224 -0
- package/dist/plugins/file-snapshot-store-sql.d.ts +38 -0
- package/dist/plugins/file-snapshot-store-sql.js +210 -0
- package/dist/plugins/image-bake-store-sql.d.ts +53 -0
- package/dist/plugins/image-bake-store-sql.js +463 -0
- package/dist/plugins/image-index-sql.d.ts +33 -0
- package/dist/plugins/image-index-sql.js +328 -0
- package/dist/plugins/k8s-bg-scripts.d.ts +19 -0
- package/dist/plugins/k8s-bg-scripts.js +129 -0
- package/dist/plugins/k8s-exec-protocol.d.ts +20 -0
- package/dist/plugins/k8s-exec-protocol.js +87 -0
- package/dist/plugins/outcome-ledger-sql.d.ts +70 -0
- package/dist/plugins/outcome-ledger-sql.js +158 -0
- package/dist/plugins/pg-approval-store.d.ts +1 -27
- package/dist/plugins/pg-approval-store.js +1 -83
- package/dist/plugins/pg-breaker-state.d.ts +1 -19
- package/dist/plugins/pg-breaker-state.js +1 -88
- package/dist/plugins/pg-checkpoint-store.d.ts +1 -33
- package/dist/plugins/pg-checkpoint-store.js +1 -189
- package/dist/plugins/pg-cost-quota.js +3 -143
- package/dist/plugins/pg-file-snapshot-store.d.ts +1 -27
- package/dist/plugins/pg-file-snapshot-store.js +1 -190
- package/dist/plugins/pg-image-bake.d.ts +1 -40
- package/dist/plugins/pg-image-bake.js +1 -420
- package/dist/plugins/pg-image-index.d.ts +1 -21
- package/dist/plugins/pg-image-index.js +1 -217
- package/dist/plugins/pg-outcome-ledger.d.ts +2 -22
- package/dist/plugins/pg-outcome-ledger.js +1 -104
- package/dist/plugins/pg-rate-limiter.d.ts +2 -32
- package/dist/plugins/pg-rate-limiter.js +4 -147
- package/dist/plugins/pg-resume-anchor-store.d.ts +1 -13
- package/dist/plugins/pg-resume-anchor-store.js +1 -22
- package/dist/plugins/pg-run-store.d.ts +1 -75
- package/dist/plugins/pg-run-store.js +1 -287
- package/dist/plugins/pg-session-policy-store.d.ts +1 -11
- package/dist/plugins/pg-session-policy-store.js +1 -90
- package/dist/plugins/pg-tool-result-store.d.ts +1 -15
- package/dist/plugins/pg-tool-result-store.js +1 -52
- package/dist/plugins/pg-workflow-journal-store.d.ts +1 -20
- package/dist/plugins/pg-workflow-journal-store.js +1 -34
- package/dist/plugins/pg-workflow-run-store.d.ts +1 -55
- package/dist/plugins/pg-workflow-run-store.js +1 -220
- package/dist/plugins/remote-env-k8s.d.ts +5 -38
- package/dist/plugins/remote-env-k8s.js +7 -213
- package/dist/plugins/resume-anchor-store-sql.d.ts +23 -0
- package/dist/plugins/resume-anchor-store-sql.js +38 -0
- package/dist/plugins/run-store-sql.d.ts +89 -0
- package/dist/plugins/run-store-sql.js +397 -0
- package/dist/plugins/session-policy-store-sql.d.ts +22 -0
- package/dist/plugins/session-policy-store-sql.js +109 -0
- package/dist/plugins/sql-driver.d.ts +25 -0
- package/dist/plugins/sql-driver.js +59 -0
- package/dist/plugins/tidb-approval-store.d.ts +1 -39
- package/dist/plugins/tidb-approval-store.js +1 -71
- package/dist/plugins/tidb-breaker-state.d.ts +1 -17
- package/dist/plugins/tidb-breaker-state.js +1 -74
- package/dist/plugins/tidb-checkpoint-store.d.ts +1 -49
- package/dist/plugins/tidb-checkpoint-store.js +1 -191
- package/dist/plugins/tidb-file-snapshot-store.d.ts +1 -27
- package/dist/plugins/tidb-file-snapshot-store.js +1 -192
- package/dist/plugins/tidb-image-bake.d.ts +1 -38
- package/dist/plugins/tidb-image-bake.js +1 -348
- package/dist/plugins/tidb-image-index.d.ts +1 -19
- package/dist/plugins/tidb-image-index.js +1 -149
- package/dist/plugins/tidb-outcome-ledger.d.ts +1 -57
- package/dist/plugins/tidb-outcome-ledger.js +1 -97
- package/dist/plugins/tidb-resume-anchor-store.d.ts +1 -13
- package/dist/plugins/tidb-resume-anchor-store.js +1 -22
- package/dist/plugins/tidb-run-store.d.ts +1 -75
- package/dist/plugins/tidb-run-store.js +1 -306
- package/dist/plugins/tidb-session-policy-store.d.ts +1 -11
- package/dist/plugins/tidb-session-policy-store.js +1 -86
- package/dist/plugins/tidb-tool-result-store.d.ts +1 -13
- package/dist/plugins/tidb-tool-result-store.js +1 -38
- package/dist/plugins/tidb-workflow-journal-store.d.ts +1 -20
- package/dist/plugins/tidb-workflow-journal-store.js +1 -34
- package/dist/plugins/tidb-workflow-run-store.d.ts +1 -61
- package/dist/plugins/tidb-workflow-run-store.js +1 -231
- package/dist/plugins/tool-result-store-sql.d.ts +25 -0
- package/dist/plugins/tool-result-store-sql.js +69 -0
- package/dist/plugins/workflow-journal-store-sql.d.ts +30 -0
- package/dist/plugins/workflow-journal-store-sql.js +53 -0
- package/dist/plugins/workflow-run-store-sql.d.ts +85 -0
- package/dist/plugins/workflow-run-store-sql.js +299 -0
- package/dist/plugins/write-behind-counter.d.ts +14 -3
- package/dist/plugins/write-behind-counter.js +39 -12
- package/dist/principal-jwt.d.ts +44 -0
- package/dist/principal-jwt.js +95 -0
- package/dist/security.d.ts +2 -58
- package/dist/security.js +3 -118
- package/dist/sema-registry.d.ts +5 -200
- package/dist/sema-registry.js +4 -567
- package/package.json +1 -1
|
@@ -0,0 +1,746 @@
|
|
|
1
|
+
import { materializeMcpTools, sanitizePathComponent, SessionPolicyError } from "@sema-agent/core";
|
|
2
|
+
import { mcpForScenario } from "../../sema-registry.js";
|
|
3
|
+
import { isUuidV7, isUuidShape } from "../../security.js";
|
|
4
|
+
import { windowMessages, truncateMessageBlobs } from "../../audit.js";
|
|
5
|
+
import { redactSecrets } from "../../trace/redact.js";
|
|
6
|
+
import { MAX_SETTINGS_OUTPUT_STYLE_CHARS, MAX_SETTINGS_PERMISSION_RULES, MAX_SETTINGS_ENV_VARS, MAX_SETTINGS_ENV_KEY_CHARS, MAX_SETTINGS_ENV_VALUE_CHARS } from "../../task-settings.js";
|
|
7
|
+
import { workspaceContentType, looksBinary } from "../workspace-content.js";
|
|
8
|
+
import { tarHeader, tarPadding, splitTarPath, TAR_END } from "../tar.js";
|
|
9
|
+
import { sendJson, sendError, sseHeaders } from "../send.js";
|
|
10
|
+
import { gatedPrincipal, explicitOperatorOk } from "../principal-gate.js";
|
|
11
|
+
const MCP_STATUS_TTL_MS = 5_000;
|
|
12
|
+
const MCP_MATERIALIZE_TIMEOUT_MS = 10_000;
|
|
13
|
+
export function createSessionsLocal() {
|
|
14
|
+
return {
|
|
15
|
+
sseConnections: 0,
|
|
16
|
+
sseOwnerLookups: 0,
|
|
17
|
+
mcpStatusCache: new Map(),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
export async function handleSessions(req, res, url, ctx) {
|
|
21
|
+
const miss = { fell: false };
|
|
22
|
+
await handleSessionsBody(req, res, url, ctx, miss);
|
|
23
|
+
return !miss.fell;
|
|
24
|
+
}
|
|
25
|
+
async function handleSessionsBody(req, res, url, ctx, miss) {
|
|
26
|
+
const { deps } = ctx;
|
|
27
|
+
const { readJson, safeDecode, runOwnerOk, sessionOwnerScope, sessionOwnerScopeForWrite } = ctx.helpers;
|
|
28
|
+
const sse = ctx.local.sessions;
|
|
29
|
+
const { mcpStatusCache } = ctx.local.sessions;
|
|
30
|
+
{
|
|
31
|
+
const im = /^\/v1\/sessions\/([^/]+)\/init$/.exec(url);
|
|
32
|
+
if (im && req.method === "GET") {
|
|
33
|
+
const principal = gatedPrincipal(req, deps.config);
|
|
34
|
+
if (deps.config.requirePrincipal && !principal) {
|
|
35
|
+
sendJson(res, 401, { error: `missing principal header '${deps.config.principalHeader}'` });
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
sendJson(res, 200, {
|
|
39
|
+
sessionId: im[1],
|
|
40
|
+
model: { provider: deps.config.model.provider, modelId: deps.config.model.id },
|
|
41
|
+
...(deps.config.autonomy ? { mode: deps.config.autonomy } : {}),
|
|
42
|
+
});
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
{
|
|
47
|
+
const stm = /^\/v1\/sessions\/([^/]+)\/settings$/.exec(url);
|
|
48
|
+
if (stm && req.method === "GET") {
|
|
49
|
+
const principal = gatedPrincipal(req, deps.config);
|
|
50
|
+
if (deps.config.requirePrincipal && !principal) {
|
|
51
|
+
sendJson(res, 401, { error: `missing principal header '${deps.config.principalHeader}'` });
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
sendJson(res, 200, {
|
|
55
|
+
effective: {
|
|
56
|
+
model: deps.config.model.id,
|
|
57
|
+
...(deps.config.autonomy ? { mode: deps.config.autonomy } : {}),
|
|
58
|
+
approvalRequire: deps.config.approvalRequire,
|
|
59
|
+
commandPolicy: deps.config.commandPolicy ?? [],
|
|
60
|
+
limits: { maxTaskCostUsd: deps.config.maxTaskCostUsd, maxTaskTokens: deps.config.maxTaskTokens },
|
|
61
|
+
},
|
|
62
|
+
applied: { model: deps.config.model.id },
|
|
63
|
+
});
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
{
|
|
68
|
+
const mm = /^\/v1\/sessions\/([^/]+)\/mcp$/.exec(url);
|
|
69
|
+
if (mm && req.method === "GET") {
|
|
70
|
+
if (deps.config.requirePrincipal && !gatedPrincipal(req, deps.config)) {
|
|
71
|
+
sendJson(res, 401, { error: `missing principal header '${deps.config.principalHeader}'` });
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const specs = mcpForScenario(deps.config.mcpServers, deps.config.defaultScenario);
|
|
75
|
+
if (!specs || specs.length === 0) {
|
|
76
|
+
sendJson(res, 200, { servers: [], asOf: new Date().toISOString() });
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const principal = gatedPrincipal(req, deps.config);
|
|
80
|
+
const cacheKey = `${deps.config.defaultScenario}::${principal ?? ""}`;
|
|
81
|
+
const cached = mcpStatusCache.get(cacheKey);
|
|
82
|
+
if (!cached || Date.now() - cached.at > MCP_STATUS_TTL_MS) {
|
|
83
|
+
if (mcpStatusCache.size > 1024)
|
|
84
|
+
mcpStatusCache.clear();
|
|
85
|
+
const panel = (async () => {
|
|
86
|
+
const matP = materializeMcpTools(specs, principal);
|
|
87
|
+
let timer;
|
|
88
|
+
const mat = await Promise.race([matP.catch(() => null), new Promise((r) => { timer = setTimeout(() => r(null), MCP_MATERIALIZE_TIMEOUT_MS); })]);
|
|
89
|
+
if (timer)
|
|
90
|
+
clearTimeout(timer);
|
|
91
|
+
if (!mat) {
|
|
92
|
+
void matP.then((m) => m?.dispose().catch(() => undefined)).catch(() => undefined);
|
|
93
|
+
return { servers: [], asOf: new Date().toISOString(), degraded: true };
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
return {
|
|
97
|
+
asOf: new Date().toISOString(),
|
|
98
|
+
servers: mat.statuses.map((s) => ({
|
|
99
|
+
name: s.name,
|
|
100
|
+
status: s.status,
|
|
101
|
+
...(s.serverInfo ? { serverInfo: s.serverInfo } : {}),
|
|
102
|
+
...(s.toolNames ? { toolNames: s.toolNames } : {}),
|
|
103
|
+
...(s.error ? { error: redactSecrets(String(s.error)).slice(0, 200) } : {}),
|
|
104
|
+
})),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
finally {
|
|
108
|
+
await mat.dispose().catch(() => undefined);
|
|
109
|
+
}
|
|
110
|
+
})();
|
|
111
|
+
mcpStatusCache.set(cacheKey, { at: Date.now(), panel });
|
|
112
|
+
}
|
|
113
|
+
sendJson(res, 200, await mcpStatusCache.get(cacheKey).panel);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
{
|
|
118
|
+
const hm = req.method === "GET" ? /^\/v1\/sessions\/([^/]+)\/head$/.exec(url) : null;
|
|
119
|
+
if (hm) {
|
|
120
|
+
const getLeafId = deps.sessionStorage?.getLeafId?.bind(deps.sessionStorage);
|
|
121
|
+
const ownerOf = deps.sessionStorage?.ownerOf?.bind(deps.sessionStorage);
|
|
122
|
+
if (!getLeafId || !ownerOf) {
|
|
123
|
+
sendJson(res, 501, { error: "session head probe requires a durable session store (SESSION_BACKEND=tidb|pg|local)" });
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const headSession = decodeURIComponent(hm[1]);
|
|
127
|
+
const getHead = deps.sessionStorage?.getHead?.bind(deps.sessionStorage);
|
|
128
|
+
let owner;
|
|
129
|
+
let leafId;
|
|
130
|
+
if (getHead) {
|
|
131
|
+
const head = await getHead(headSession);
|
|
132
|
+
if (head === undefined) {
|
|
133
|
+
sendJson(res, 404, { error: "session not found" });
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
owner = head.owner;
|
|
137
|
+
if (!runOwnerOk(req, res, owner ?? null))
|
|
138
|
+
return;
|
|
139
|
+
leafId = head.leafId;
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
owner = await ownerOf(headSession);
|
|
143
|
+
if (owner === undefined) {
|
|
144
|
+
sendJson(res, 404, { error: "session not found" });
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (!runOwnerOk(req, res, owner ?? null))
|
|
148
|
+
return;
|
|
149
|
+
leafId = await getLeafId(headSession);
|
|
150
|
+
}
|
|
151
|
+
const etag = `"${leafId ?? "empty"}"`;
|
|
152
|
+
if (req.headers["if-none-match"] === etag) {
|
|
153
|
+
res.writeHead(304, { etag }).end();
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
res.setHeader("etag", etag);
|
|
157
|
+
sendJson(res, 200, { sessionId: headSession, leafId });
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
{
|
|
162
|
+
const em = req.method === "GET" ? /^\/v1\/sessions\/([^/]+)\/events$/.exec(url) : null;
|
|
163
|
+
if (em) {
|
|
164
|
+
const ownerOf = deps.sessionStorage?.ownerOf?.bind(deps.sessionStorage);
|
|
165
|
+
const watch = deps.sessionWatch;
|
|
166
|
+
if (!deps.sessionStorage?.getLeafId || !ownerOf || !watch) {
|
|
167
|
+
sendJson(res, 501, { error: "session events require a durable session store (SESSION_BACKEND=tidb|pg|local)" });
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const evSession = decodeURIComponent(em[1]);
|
|
171
|
+
if (sse.sseConnections >= (deps.sessionEventsMaxConnections ?? 256)) {
|
|
172
|
+
res.writeHead(503, { "retry-after": "5", "content-type": "application/json" });
|
|
173
|
+
res.end(JSON.stringify({ error: "session-events connection cap reached — fall back to /head polling" }));
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
sse.sseConnections++;
|
|
177
|
+
let hb;
|
|
178
|
+
let unsubscribe;
|
|
179
|
+
let closed = false;
|
|
180
|
+
const cleanup = () => {
|
|
181
|
+
if (closed)
|
|
182
|
+
return;
|
|
183
|
+
closed = true;
|
|
184
|
+
sse.sseConnections--;
|
|
185
|
+
if (hb)
|
|
186
|
+
clearInterval(hb);
|
|
187
|
+
unsubscribe?.();
|
|
188
|
+
};
|
|
189
|
+
res.on("close", cleanup);
|
|
190
|
+
req.on("close", cleanup);
|
|
191
|
+
try {
|
|
192
|
+
const stallBound = deps.sessionEventsStallMs ?? 60_000;
|
|
193
|
+
if (sse.sseOwnerLookups >= 64) {
|
|
194
|
+
cleanup();
|
|
195
|
+
res.writeHead(503, { "retry-after": "5", "content-type": "application/json" });
|
|
196
|
+
res.end(JSON.stringify({ error: "session-events owner-lookup concurrency cap — retry" }));
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
sse.sseOwnerLookups++;
|
|
200
|
+
const ownerP = ownerOf(evSession);
|
|
201
|
+
void ownerP.catch(() => undefined).then(() => { sse.sseOwnerLookups--; });
|
|
202
|
+
let stallTimer;
|
|
203
|
+
let owner;
|
|
204
|
+
try {
|
|
205
|
+
owner = await Promise.race([
|
|
206
|
+
ownerP,
|
|
207
|
+
new Promise((r) => { stallTimer = setTimeout(() => r("__stall__"), stallBound); stallTimer.unref?.(); }),
|
|
208
|
+
]);
|
|
209
|
+
}
|
|
210
|
+
finally {
|
|
211
|
+
if (stallTimer)
|
|
212
|
+
clearTimeout(stallTimer);
|
|
213
|
+
}
|
|
214
|
+
if (closed)
|
|
215
|
+
return;
|
|
216
|
+
if (owner === "__stall__") {
|
|
217
|
+
cleanup();
|
|
218
|
+
res.writeHead(503, { "retry-after": "5", "content-type": "application/json" });
|
|
219
|
+
res.end(JSON.stringify({ error: "session-events owner lookup stalled — retry" }));
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if (owner === undefined) {
|
|
223
|
+
cleanup();
|
|
224
|
+
sendJson(res, 404, { error: "session not found" });
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (!runOwnerOk(req, res, owner ?? null)) {
|
|
228
|
+
cleanup();
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
const q = new URL(req.url ?? url, "http://x").searchParams;
|
|
232
|
+
const lastLeafId = q.get("lastLeafId");
|
|
233
|
+
let first = true;
|
|
234
|
+
let lastAccepted;
|
|
235
|
+
let lastWritten;
|
|
236
|
+
let writeBlocked = false;
|
|
237
|
+
let blockedAt = 0;
|
|
238
|
+
let pendingHead;
|
|
239
|
+
const stallMs = deps.sessionEventsStallMs ?? 60_000;
|
|
240
|
+
const writeRaw = (chunk) => {
|
|
241
|
+
if (closed)
|
|
242
|
+
return;
|
|
243
|
+
try {
|
|
244
|
+
const okW = res.write(chunk);
|
|
245
|
+
if (!okW && !writeBlocked) {
|
|
246
|
+
writeBlocked = true;
|
|
247
|
+
blockedAt = Date.now();
|
|
248
|
+
res.once("drain", () => {
|
|
249
|
+
writeBlocked = false;
|
|
250
|
+
const p = pendingHead;
|
|
251
|
+
pendingHead = undefined;
|
|
252
|
+
if (p && !closed && p.v !== lastWritten)
|
|
253
|
+
doWrite(p.v);
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
catch { }
|
|
258
|
+
};
|
|
259
|
+
const doWrite = (leafId) => {
|
|
260
|
+
lastWritten = leafId;
|
|
261
|
+
writeRaw(`event: head\ndata: ${JSON.stringify({ sessionId: evSession, leafId })}\n\n`);
|
|
262
|
+
};
|
|
263
|
+
const emit = (leafId) => {
|
|
264
|
+
if (closed || leafId === lastAccepted)
|
|
265
|
+
return;
|
|
266
|
+
lastAccepted = leafId;
|
|
267
|
+
const suppress = first && lastLeafId !== null && lastLeafId === (leafId ?? "null");
|
|
268
|
+
first = false;
|
|
269
|
+
if (suppress) {
|
|
270
|
+
lastWritten = leafId;
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (writeBlocked) {
|
|
274
|
+
pendingHead = { v: leafId };
|
|
275
|
+
if (Date.now() - blockedAt > stallMs) {
|
|
276
|
+
cleanup();
|
|
277
|
+
try {
|
|
278
|
+
res.end();
|
|
279
|
+
}
|
|
280
|
+
catch { }
|
|
281
|
+
}
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
doWrite(leafId);
|
|
285
|
+
};
|
|
286
|
+
let started = false;
|
|
287
|
+
let preVal;
|
|
288
|
+
const onHead = (leafId) => {
|
|
289
|
+
if (!started)
|
|
290
|
+
preVal = { v: leafId };
|
|
291
|
+
else
|
|
292
|
+
emit(leafId);
|
|
293
|
+
};
|
|
294
|
+
const onEnd = () => { cleanup(); try {
|
|
295
|
+
res.end();
|
|
296
|
+
}
|
|
297
|
+
catch { } };
|
|
298
|
+
unsubscribe = watch.subscribe(evSession, owner ?? null, onHead, onEnd);
|
|
299
|
+
if (!unsubscribe) {
|
|
300
|
+
cleanup();
|
|
301
|
+
res.writeHead(503, { "retry-after": "5", "content-type": "application/json" });
|
|
302
|
+
res.end(JSON.stringify({ error: "session-events probe cap reached — fall back to /head polling" }));
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache, no-transform", connection: "keep-alive", "x-accel-buffering": "no" });
|
|
306
|
+
writeRaw(": connected\n\n");
|
|
307
|
+
started = true;
|
|
308
|
+
if (preVal !== undefined)
|
|
309
|
+
emit(preVal.v);
|
|
310
|
+
const hbMs = deps.sessionEventsHeartbeatMs ?? 25_000;
|
|
311
|
+
let ownerCheckPending = false;
|
|
312
|
+
let ownerCheckStartedAt = 0;
|
|
313
|
+
hb = setInterval(() => {
|
|
314
|
+
if (closed)
|
|
315
|
+
return;
|
|
316
|
+
if (writeBlocked && Date.now() - blockedAt > stallMs) {
|
|
317
|
+
onEnd();
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
if (!writeBlocked)
|
|
321
|
+
writeRaw(": hb\n\n");
|
|
322
|
+
if (ownerCheckPending) {
|
|
323
|
+
if (Date.now() - ownerCheckStartedAt > stallMs)
|
|
324
|
+
onEnd();
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
ownerCheckPending = true;
|
|
328
|
+
ownerCheckStartedAt = Date.now();
|
|
329
|
+
void ownerOf(evSession).then((o) => {
|
|
330
|
+
ownerCheckPending = false;
|
|
331
|
+
if (!closed && o !== owner)
|
|
332
|
+
onEnd();
|
|
333
|
+
}).catch(() => { ownerCheckPending = false; });
|
|
334
|
+
}, hbMs);
|
|
335
|
+
hb.unref?.();
|
|
336
|
+
}
|
|
337
|
+
catch (e) {
|
|
338
|
+
cleanup();
|
|
339
|
+
if (!res.headersSent)
|
|
340
|
+
sendJson(res, 500, { error: `session events failed: ${e instanceof Error ? e.message : "unknown"}` });
|
|
341
|
+
else
|
|
342
|
+
try {
|
|
343
|
+
res.end();
|
|
344
|
+
}
|
|
345
|
+
catch { }
|
|
346
|
+
}
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
if (deps.sessionAudit && req.method === "GET") {
|
|
351
|
+
const sm = /^\/v1\/sessions\/([^/]+)$/.exec(url);
|
|
352
|
+
if (sm) {
|
|
353
|
+
const audit = await deps.sessionAudit(sm[1]);
|
|
354
|
+
if (!audit) {
|
|
355
|
+
sendJson(res, 404, { error: "session not found" });
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
if (!runOwnerOk(req, res, audit.owner))
|
|
359
|
+
return;
|
|
360
|
+
const q = new URL(req.url ?? url, "http://x").searchParams;
|
|
361
|
+
const qi = (k) => {
|
|
362
|
+
const v = q.get(k);
|
|
363
|
+
if (v === null)
|
|
364
|
+
return undefined;
|
|
365
|
+
const n = Number(v);
|
|
366
|
+
return Number.isFinite(n) && n >= 0 ? n : undefined;
|
|
367
|
+
};
|
|
368
|
+
const msgIndex = qi("message");
|
|
369
|
+
if (msgIndex !== undefined) {
|
|
370
|
+
const messages = audit.messages;
|
|
371
|
+
if (msgIndex >= messages.length) {
|
|
372
|
+
sendJson(res, 404, { error: "message index out of range", total: messages.length });
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
sendJson(res, 200, { sessionId: audit.sessionId, floorEntryId: audit.floorEntryId, index: msgIndex, message: messages[msgIndex] });
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
const tail = qi("tail");
|
|
379
|
+
const before = qi("before");
|
|
380
|
+
const blobCap = qi("blobCap");
|
|
381
|
+
if (tail === undefined && before === undefined && blobCap === undefined) {
|
|
382
|
+
sendJson(res, 200, audit);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
let messages = audit.messages;
|
|
386
|
+
let window;
|
|
387
|
+
if (tail !== undefined || before !== undefined) {
|
|
388
|
+
const w = windowMessages(messages, { ...(tail !== undefined ? { tail } : {}), ...(before !== undefined ? { before } : {}), ...(qi("limit") !== undefined ? { limit: qi("limit") } : {}) });
|
|
389
|
+
messages = w.messages;
|
|
390
|
+
window = w.window;
|
|
391
|
+
}
|
|
392
|
+
if (blobCap !== undefined && blobCap > 0)
|
|
393
|
+
messages = truncateMessageBlobs(messages, blobCap);
|
|
394
|
+
sendJson(res, 200, { ...audit, messages, ...(window ? { window } : {}) });
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
{
|
|
399
|
+
const fm = req.method === "POST" ? /^\/v1\/sessions\/([^/]+)\/fork$/.exec(url) : null;
|
|
400
|
+
if (fm) {
|
|
401
|
+
const fork = deps.sessionStorage?.fork?.bind(deps.sessionStorage);
|
|
402
|
+
const ownerOf = deps.sessionStorage?.ownerOf?.bind(deps.sessionStorage);
|
|
403
|
+
if (!fork || !ownerOf) {
|
|
404
|
+
sendJson(res, 501, { error: "session fork requires a durable session store (SESSION_BACKEND=tidb)" });
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
const scope = sessionOwnerScopeForWrite(req);
|
|
408
|
+
if (!scope.fleetWide && scope.gateOwner === null) {
|
|
409
|
+
sendJson(res, 401, { error: "unauthorized" });
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
const sourceId = safeDecode(fm[1]);
|
|
413
|
+
if (sourceId === null || !isUuidV7(sourceId)) {
|
|
414
|
+
sendJson(res, 400, { error: "invalid session id (must be a uuidv7)" });
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
const owner = await ownerOf(sourceId);
|
|
418
|
+
if (owner === undefined) {
|
|
419
|
+
sendJson(res, 404, { error: "session not found" });
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
if (!scope.fleetWide && owner !== scope.gateOwner) {
|
|
423
|
+
sendJson(res, 404, { error: "session not found" });
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
const newOwner = scope.fleetWide ? (owner ?? null) : scope.gateOwner;
|
|
427
|
+
const newId = await fork(sourceId, newOwner);
|
|
428
|
+
if (newId === null) {
|
|
429
|
+
sendJson(res, 404, { error: "session not found" });
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
sendJson(res, 200, { sessionId: newId });
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
{
|
|
437
|
+
const dm = req.method === "DELETE" ? /^\/v1\/sessions\/([^/]+)$/.exec(url) : null;
|
|
438
|
+
if (dm) {
|
|
439
|
+
const purge = deps.purgeSession;
|
|
440
|
+
const ownerOf = deps.sessionStorage?.ownerOf?.bind(deps.sessionStorage);
|
|
441
|
+
if (!purge || !ownerOf) {
|
|
442
|
+
sendJson(res, 501, { error: "session delete requires a durable session store (SESSION_BACKEND=tidb)" });
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
const scope = sessionOwnerScopeForWrite(req);
|
|
446
|
+
if (!scope.fleetWide && scope.gateOwner === null) {
|
|
447
|
+
sendJson(res, 401, { error: "unauthorized" });
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
const sessionId = safeDecode(dm[1]);
|
|
451
|
+
if (sessionId === null || !isUuidV7(sessionId)) {
|
|
452
|
+
sendJson(res, 400, { error: "invalid session id (must be a uuidv7)" });
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
const owner = await ownerOf(sessionId);
|
|
456
|
+
if (owner === undefined) {
|
|
457
|
+
sendJson(res, 200, { deleted: false });
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
if (!scope.fleetWide && owner !== scope.gateOwner) {
|
|
461
|
+
sendJson(res, 200, { deleted: false });
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
const activeTaskId = await deps.runStore?.getActiveTaskId(sessionId);
|
|
465
|
+
if (activeTaskId) {
|
|
466
|
+
sendJson(res, 409, { error: "session has an active run; cancel it before deleting", activeTaskId });
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
const result = await purge(sessionId, owner);
|
|
470
|
+
if ("active" in result) {
|
|
471
|
+
sendJson(res, 409, { error: "session has an active run; cancel it before deleting", activeTaskId: result.active });
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
sendJson(res, 200, result);
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
{
|
|
479
|
+
const pm = req.method === "GET" || req.method === "PUT" ? /^\/v1\/sessions\/([^/]+)\/policy$/.exec(url) : null;
|
|
480
|
+
if (pm) {
|
|
481
|
+
const store = deps.sessionPolicyStore;
|
|
482
|
+
const ownerOf = deps.sessionStorage?.ownerOf?.bind(deps.sessionStorage);
|
|
483
|
+
if (!store || !ownerOf) {
|
|
484
|
+
sendJson(res, 501, { error: "session policy requires a session-store backend" });
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
const principal = gatedPrincipal(req, deps.config);
|
|
488
|
+
if (deps.config.requirePrincipal && principal === undefined) {
|
|
489
|
+
sendJson(res, 401, { error: "unauthorized" });
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
const sessionId = safeDecode(pm[1]);
|
|
493
|
+
if (sessionId === null || !isUuidV7(sessionId)) {
|
|
494
|
+
sendJson(res, 400, { error: "invalid session id (must be a uuidv7)" });
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
const owner = await ownerOf(sessionId);
|
|
498
|
+
if (owner === undefined) {
|
|
499
|
+
sendJson(res, 404, { error: "session not found" });
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
if (owner === null) {
|
|
503
|
+
sendJson(res, 409, { error: "session policy requires an owned session (set REQUIRE_PRINCIPAL / attach a principal); this session is ownerless" });
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
const operator = explicitOperatorOk(principal, deps.config.operatorPrincipals);
|
|
507
|
+
if (!operator && owner !== (principal ?? null)) {
|
|
508
|
+
sendJson(res, 404, { error: "session not found" });
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
const target = owner;
|
|
512
|
+
if (req.method === "GET") {
|
|
513
|
+
sendJson(res, 200, { rules: (await store.getRules(sessionId, target)) ?? { rev: 0 } });
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
let body;
|
|
517
|
+
try {
|
|
518
|
+
body = (await readJson(req));
|
|
519
|
+
}
|
|
520
|
+
catch {
|
|
521
|
+
sendJson(res, 400, { error: "invalid JSON body" });
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
if (typeof body.rules !== "object" || body.rules === null || Array.isArray(body.rules)) {
|
|
525
|
+
sendJson(res, 400, { error: "rules must be a SessionPermissionRules object" });
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
if (body.expectedRev !== undefined && (typeof body.expectedRev !== "number" || !Number.isInteger(body.expectedRev) || body.expectedRev < 0)) {
|
|
529
|
+
sendJson(res, 400, { error: "expectedRev must be a non-negative integer" });
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
const raw = body.rules;
|
|
533
|
+
const rules = {};
|
|
534
|
+
for (const f of ["toolAllow", "toolDeny", "allowDirs", "commandAllow", "commandDeny"]) {
|
|
535
|
+
if (raw[f] === undefined)
|
|
536
|
+
continue;
|
|
537
|
+
if (!Array.isArray(raw[f]) || !raw[f].every((x) => typeof x === "string")) {
|
|
538
|
+
sendJson(res, 400, { error: `${f} must be an array of strings` });
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
rules[f] = raw[f];
|
|
542
|
+
}
|
|
543
|
+
try {
|
|
544
|
+
const stored = await store.putRules(sessionId, target, rules, { ...(typeof body.expectedRev === "number" ? { expectedRev: body.expectedRev } : {}), operator });
|
|
545
|
+
sendJson(res, 200, { rules: stored });
|
|
546
|
+
}
|
|
547
|
+
catch (e) {
|
|
548
|
+
if (e instanceof SessionPolicyError) {
|
|
549
|
+
sendError(res, e.code === "conflict" ? 409 : 403, e.code, e.message);
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
throw e;
|
|
553
|
+
}
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
{
|
|
558
|
+
const wm = req.method === "GET" ? /^\/v1\/sessions\/([^/]+)\/workspace(?:\/([^/]+)(?:\/(tree|file|archive))?)?$/.exec(url) : null;
|
|
559
|
+
if (wm) {
|
|
560
|
+
const fs = deps.fileSnapshotStore;
|
|
561
|
+
const ownerOf = deps.sessionStorage?.ownerOf?.bind(deps.sessionStorage);
|
|
562
|
+
if (!fs || typeof fs.listKeys !== "function" || typeof fs.exportManifest !== "function" || typeof fs.getBlob !== "function" || !ownerOf) {
|
|
563
|
+
sendJson(res, 501, { error: "workspace browse requires a durable snapshot store (rewindFiles wiring) and a durable session store" });
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
const scope = sessionOwnerScope(req);
|
|
567
|
+
if (!scope.fleetWide && scope.gateOwner === null) {
|
|
568
|
+
sendJson(res, 401, { error: "unauthorized" });
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
const wsSession = safeDecode(wm[1]);
|
|
572
|
+
if (wsSession === null || !isUuidShape(wsSession)) {
|
|
573
|
+
sendJson(res, 400, { error: "invalid session id (must be a canonical uuid)" });
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
const wsOwner = await ownerOf(wsSession);
|
|
577
|
+
if (wsOwner === undefined || (!scope.fleetWide && wsOwner !== scope.gateOwner)) {
|
|
578
|
+
sendJson(res, 404, { error: "session not found" });
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
const q = new URL(req.url ?? "", "http://x").searchParams;
|
|
582
|
+
const keys = (await fs.listKeys(wsSession)).sort().reverse();
|
|
583
|
+
const sub = wm[3];
|
|
584
|
+
if (wm[2] === undefined) {
|
|
585
|
+
const limRaw = q.get("limit");
|
|
586
|
+
if (limRaw !== null && !(/^[0-9]+$/.test(limRaw) && Number(limRaw) >= 1 && Number(limRaw) <= 100)) {
|
|
587
|
+
sendJson(res, 400, { error: "limit must be a decimal integer in 1..100" });
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
const lim = limRaw === null ? 20 : Number(limRaw);
|
|
591
|
+
const page = keys.slice(0, lim);
|
|
592
|
+
const snapshots = [];
|
|
593
|
+
for (const k of page) {
|
|
594
|
+
const m = await fs.exportManifest(wsSession, k);
|
|
595
|
+
if (m === null)
|
|
596
|
+
continue;
|
|
597
|
+
let bytes;
|
|
598
|
+
if (typeof fs.blobSizes === "function") {
|
|
599
|
+
const sizes = await fs.blobSizes([...new Set(m.values())]);
|
|
600
|
+
bytes = 0;
|
|
601
|
+
for (const h of m.values())
|
|
602
|
+
bytes += sizes.get(h) ?? 0;
|
|
603
|
+
}
|
|
604
|
+
snapshots.push({ key: k, files: m.size, ...(bytes !== undefined ? { bytes } : {}) });
|
|
605
|
+
}
|
|
606
|
+
sendJson(res, 200, {
|
|
607
|
+
sessionId: wsSession,
|
|
608
|
+
snapshots,
|
|
609
|
+
...(snapshots.length > 0 ? { latest: snapshots[0].key } : {}),
|
|
610
|
+
total: snapshots.length,
|
|
611
|
+
});
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
const keyRaw = safeDecode(wm[2]);
|
|
615
|
+
if (keyRaw === null) {
|
|
616
|
+
sendJson(res, 400, { error: "malformed snapshot key" });
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
const key = keyRaw === "latest" ? keys[0] : keyRaw;
|
|
620
|
+
const manifest = key !== undefined ? await fs.exportManifest(wsSession, key) : null;
|
|
621
|
+
if (key === undefined || manifest === null) {
|
|
622
|
+
sendJson(res, 404, { error: "snapshot not found" });
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
if (sub === "tree") {
|
|
626
|
+
const limRaw = q.get("limit");
|
|
627
|
+
if (limRaw !== null && !(/^[0-9]+$/.test(limRaw) && Number(limRaw) >= 1 && Number(limRaw) <= 2000)) {
|
|
628
|
+
sendJson(res, 400, { error: "limit must be a decimal integer in 1..2000" });
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
const offRaw = q.get("offset");
|
|
632
|
+
if (offRaw !== null && !/^[0-9]+$/.test(offRaw)) {
|
|
633
|
+
sendJson(res, 400, { error: "offset must be a non-negative integer" });
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
const lim = limRaw === null ? 500 : Number(limRaw);
|
|
637
|
+
const off = offRaw === null ? 0 : Number(offRaw);
|
|
638
|
+
const all = [...manifest.entries()].sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
|
|
639
|
+
const page = all.slice(off, off + lim);
|
|
640
|
+
const sizes = typeof fs.blobSizes === "function" ? await fs.blobSizes([...new Set(page.map(([, h]) => h))]) : undefined;
|
|
641
|
+
sendJson(res, 200, {
|
|
642
|
+
sessionId: wsSession,
|
|
643
|
+
key,
|
|
644
|
+
entries: page.map(([path, hash]) => ({ path, hash, ...(sizes?.get(hash) !== undefined ? { size: sizes.get(hash) } : {}) })),
|
|
645
|
+
total: all.length,
|
|
646
|
+
...(off + lim < all.length ? { nextOffset: off + lim } : {}),
|
|
647
|
+
});
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
if (sub === "file") {
|
|
651
|
+
const pathParam = q.get("path");
|
|
652
|
+
if (!pathParam) {
|
|
653
|
+
sendJson(res, 400, { error: "missing ?path=<relPath from the tree>" });
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
const hash = manifest.get(pathParam);
|
|
657
|
+
if (hash === undefined) {
|
|
658
|
+
sendJson(res, 404, { error: "no such file in this snapshot (paths are the tree's relPaths, verbatim)" });
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
const cap = deps.config.workspaceFileMaxBytes ?? 8 * 1024 * 1024;
|
|
662
|
+
if (typeof fs.blobSizes === "function") {
|
|
663
|
+
const known = (await fs.blobSizes([hash])).get(hash);
|
|
664
|
+
if (known !== undefined && known > cap) {
|
|
665
|
+
sendError(res, 413, "workspace_file_too_large", "file exceeds the single-file read limit — download it via the archive endpoint", { code: "workspace_file_too_large", sizeBytes: known, limit: cap });
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
const bytes = await fs.getBlob(hash);
|
|
670
|
+
if (bytes === undefined) {
|
|
671
|
+
sendJson(res, 404, { error: "file content no longer available (snapshot raced a reap)" });
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
if (bytes.byteLength > cap) {
|
|
675
|
+
sendError(res, 413, "workspace_file_too_large", "file exceeds the single-file read limit — download it via the archive endpoint", { code: "workspace_file_too_large", sizeBytes: bytes.byteLength, limit: cap });
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
const ct = workspaceContentType(pathParam);
|
|
679
|
+
const bin = looksBinary(bytes);
|
|
680
|
+
res.writeHead(200, {
|
|
681
|
+
"content-type": ct,
|
|
682
|
+
"content-length": bytes.byteLength,
|
|
683
|
+
"x-content-type-options": "nosniff",
|
|
684
|
+
"x-sema-content-binary": bin ? "true" : "false",
|
|
685
|
+
});
|
|
686
|
+
res.end(Buffer.from(bytes));
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
if (sub === "archive") {
|
|
690
|
+
const entries = [...manifest.entries()].sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
|
|
691
|
+
const bad = entries.filter(([p]) => splitTarPath(p) === null).map(([p]) => p);
|
|
692
|
+
if (bad.length > 0) {
|
|
693
|
+
sendJson(res, 422, { error: `snapshot contains ${bad.length} path(s) not representable in ustar (>255 bytes or unsplittable)`, paths: bad.slice(0, 5) });
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
if (typeof fs.hasBlobs === "function") {
|
|
697
|
+
const present = await fs.hasBlobs([...new Set(entries.map(([, h]) => h))]);
|
|
698
|
+
const missing = entries.filter(([, h]) => !present.has(h)).length;
|
|
699
|
+
if (missing > 0) {
|
|
700
|
+
sendJson(res, 502, { error: `snapshot is missing ${missing} blob(s) (raced a reap or a partial import) — retry, or pick another snapshot` });
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
res.writeHead(200, {
|
|
705
|
+
"content-type": "application/x-tar",
|
|
706
|
+
"content-disposition": `attachment; filename="workspace-${key}.tar"`,
|
|
707
|
+
"x-content-type-options": "nosniff",
|
|
708
|
+
});
|
|
709
|
+
const write = async (buf) => {
|
|
710
|
+
if (res.writableEnded || res.destroyed)
|
|
711
|
+
throw new Error("client gone");
|
|
712
|
+
if (!res.write(buf)) {
|
|
713
|
+
await new Promise((resolve, reject) => {
|
|
714
|
+
const onDrain = () => { cleanup(); resolve(); };
|
|
715
|
+
const onGone = () => { cleanup(); reject(new Error("client gone (closed while draining)")); };
|
|
716
|
+
const cleanup = () => { res.off("drain", onDrain); res.off("close", onGone); res.off("error", onGone); };
|
|
717
|
+
res.once("drain", onDrain);
|
|
718
|
+
res.once("close", onGone);
|
|
719
|
+
res.once("error", onGone);
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
};
|
|
723
|
+
try {
|
|
724
|
+
for (const [p, h] of entries) {
|
|
725
|
+
const bytes = await fs.getBlob(h);
|
|
726
|
+
if (bytes === undefined)
|
|
727
|
+
throw new Error(`blob vanished mid-stream: ${h}`);
|
|
728
|
+
await write(tarHeader(p, bytes.byteLength, 0));
|
|
729
|
+
await write(Buffer.from(bytes));
|
|
730
|
+
await write(tarPadding(bytes.byteLength));
|
|
731
|
+
}
|
|
732
|
+
await write(TAR_END);
|
|
733
|
+
res.end();
|
|
734
|
+
}
|
|
735
|
+
catch {
|
|
736
|
+
res.destroy();
|
|
737
|
+
}
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
sendJson(res, 404, { error: "unknown workspace verb — use /tree, /file?path=, or /archive" });
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
miss.fell = true;
|
|
745
|
+
}
|
|
746
|
+
//# sourceMappingURL=sessions.js.map
|