@lelouchhe/webagent 0.3.0 → 0.4.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/README.md +58 -23
- package/bin/webagent.mjs +119 -8
- package/config.toml +96 -3
- package/dist/index.html +64 -41
- package/dist/js/app.GSAIYHML.js +4 -0
- package/dist/js/chunk.AJZBJBMO.js +1 -0
- package/dist/js/chunk.CGWFHJI2.js +76 -0
- package/dist/js/chunk.D4ZYHJAM.js +1 -0
- package/dist/js/chunk.VZXGXFNN.js +5 -0
- package/dist/js/login.PYIK52HN.js +1 -0
- package/dist/js/viewer.6DT53STL.js +1 -0
- package/dist/login.html +49 -0
- package/dist/share-viewer.00gubshk.css +114 -0
- package/dist/share-viewer.html +53 -0
- package/dist/styles.012p32dz.css +1443 -0
- package/dist/sw.js +79 -27
- package/dist/theme-init.js +6 -0
- package/lib/agent-detect.js +110 -0
- package/lib/atomic-write.js +50 -0
- package/lib/attachment-dispatch.js +86 -0
- package/lib/attachment-interceptor.js +130 -0
- package/lib/attachment-labels.js +139 -0
- package/lib/attachments.js +154 -0
- package/lib/auth-middleware.js +102 -0
- package/lib/auth-store.js +269 -0
- package/lib/auth.js +89 -0
- package/lib/bootstrap.js +70 -0
- package/lib/bridge.js +244 -93
- package/lib/client-registry.js +60 -0
- package/lib/config.js +123 -9
- package/lib/daemon.js +175 -41
- package/lib/event-handler.js +209 -91
- package/lib/log-fmt.js +67 -0
- package/lib/log.js +83 -0
- package/lib/message-cleanup.js +48 -0
- package/lib/mode-bucket.js +62 -0
- package/lib/preflight.js +195 -0
- package/lib/push-service.js +338 -45
- package/lib/routes.js +1202 -144
- package/lib/server.js +149 -33
- package/lib/session-manager.js +164 -18
- package/lib/session-state.js +160 -0
- package/lib/sessions-anchor.js +28 -0
- package/lib/share/cleanup.js +45 -0
- package/lib/share/routes.js +972 -0
- package/lib/share/sanitize.js +179 -0
- package/lib/sse-manager.js +94 -8
- package/lib/sse-ticket.js +45 -0
- package/lib/startup-checks.js +94 -0
- package/lib/store.js +624 -30
- package/lib/title-service.js +42 -9
- package/lib/tokens.js +50 -0
- package/lib/types.js +23 -0
- package/package.json +38 -4
- package/dist/js/app.2562YGRO.js +0 -10
- package/dist/styles.008ve1hx.css +0 -669
- package/lib/shared/constants.js +0 -17
package/lib/routes.js
CHANGED
|
@@ -1,10 +1,22 @@
|
|
|
1
|
-
import { readFile,
|
|
1
|
+
import { readFile, mkdir, rename, unlink, realpath } from "node:fs/promises";
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
|
-
import { join, extname } from "node:path";
|
|
3
|
+
import { join, extname, basename } from "node:path";
|
|
4
4
|
import { gzipSync } from "node:zlib";
|
|
5
|
-
import
|
|
6
|
-
import { errorMessage } from "./types.js";
|
|
5
|
+
import busboy from "busboy";
|
|
6
|
+
import { errorMessage, MessageIngressSchema } from "./types.js";
|
|
7
7
|
import { interruptBashProc } from "./session-manager.js";
|
|
8
|
+
import { randomUUID } from "node:crypto";
|
|
9
|
+
import { createWriteStream } from "node:fs";
|
|
10
|
+
import { handleShareRoutes } from "./share/routes.js";
|
|
11
|
+
import { authenticate, isWhitelistedPath } from "./auth-middleware.js";
|
|
12
|
+
import { enrichStoredEventsForDisplay } from "./attachment-labels.js";
|
|
13
|
+
import { log } from "./log.js";
|
|
14
|
+
const rlog = log.scope("routes");
|
|
15
|
+
const plog = rlog.scope("prompt");
|
|
16
|
+
const slog = rlog.scope("session");
|
|
17
|
+
const mlog = rlog.scope("msg");
|
|
18
|
+
import { signAttachmentUrl, verifyAttachmentSig, reSignAttachmentUrlsInJson, } from "./auth.js";
|
|
19
|
+
import { buildContentDisposition, classifyKind, isInlineMime, mimeToExt, normalizeDisplayName, sniffMime, } from "./attachments.js";
|
|
8
20
|
const IS_WIN = process.platform === "win32";
|
|
9
21
|
const SAFE_ID = /^[a-zA-Z0-9_-]+$/;
|
|
10
22
|
const MIME = {
|
|
@@ -19,19 +31,91 @@ const MIME = {
|
|
|
19
31
|
".gif": "image/gif",
|
|
20
32
|
".webp": "image/webp",
|
|
21
33
|
};
|
|
34
|
+
/**
|
|
35
|
+
* HTML entrypoints served by this app. Any new HTML page MUST be registered
|
|
36
|
+
* here. Tests in `test/html-entrypoints.test.ts`, `test/csp.test.ts` and
|
|
37
|
+
* `test/inline-assets.test.ts` rely on this list to enforce security
|
|
38
|
+
* invariants (CSP header, no inline scripts/styles).
|
|
39
|
+
*/
|
|
40
|
+
export const HTML_ENTRYPOINTS = [
|
|
41
|
+
{ urlPath: "/", file: "index.html" },
|
|
42
|
+
{ urlPath: "/login", file: "login.html" },
|
|
43
|
+
// Share viewer — served by share's own dispatcher at /s/:token
|
|
44
|
+
// (see src/share/routes.ts → handleViewerHtml). The urlPath here is a
|
|
45
|
+
// pseudo-path used only by the test invariants (CSP/inline checks); the
|
|
46
|
+
// real route is /s/:token. Keep both views in sync if either changes.
|
|
47
|
+
{ urlPath: "/s", file: "share-viewer.html" },
|
|
48
|
+
];
|
|
49
|
+
/**
|
|
50
|
+
* Strict Content-Security-Policy for HTML responses.
|
|
51
|
+
*
|
|
52
|
+
* - default-src 'self': everything same-origin only
|
|
53
|
+
* - img-src adds data: + blob: for image-upload preview
|
|
54
|
+
* - script-src 'self' (no inline; theme bootstrap is /theme-init.js)
|
|
55
|
+
* - style-src 'self' (no inline; login styles live in /styles.css)
|
|
56
|
+
* - object-src 'none', frame-ancestors 'none', base-uri 'self', form-action 'self'
|
|
57
|
+
* - connect-src 'self' for fetch + EventSource
|
|
58
|
+
*/
|
|
59
|
+
export const CSP_POLICY = [
|
|
60
|
+
"default-src 'self'",
|
|
61
|
+
"img-src 'self' data: blob:",
|
|
62
|
+
"script-src 'self'",
|
|
63
|
+
"style-src 'self'",
|
|
64
|
+
"connect-src 'self'",
|
|
65
|
+
"object-src 'none'",
|
|
66
|
+
"frame-ancestors 'none'",
|
|
67
|
+
"base-uri 'self'",
|
|
68
|
+
"form-action 'self'",
|
|
69
|
+
].join("; ");
|
|
22
70
|
/** Read the full request body as a string. */
|
|
23
71
|
function readBody(req) {
|
|
24
72
|
return new Promise((resolve, reject) => {
|
|
25
73
|
const chunks = [];
|
|
26
74
|
req.on("data", (chunk) => chunks.push(chunk));
|
|
27
|
-
req.on("end", () =>
|
|
75
|
+
req.on("end", () => {
|
|
76
|
+
resolve(Buffer.concat(chunks).toString());
|
|
77
|
+
});
|
|
28
78
|
req.on("error", reject);
|
|
29
79
|
});
|
|
30
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* client-server-split M2: idempotency helpers for mutating REST.
|
|
83
|
+
* Frontend generates a UUID per user-initiated action and sends it as
|
|
84
|
+
* `X-Client-Op-Id`. Replays (after SSE/network reconnect) return the
|
|
85
|
+
* cached response instead of re-executing side effects. Missing header →
|
|
86
|
+
* non-idempotent path (back-compat for curl / older clients).
|
|
87
|
+
*/
|
|
88
|
+
function getClientOpId(req) {
|
|
89
|
+
const v = req.headers["x-client-op-id"];
|
|
90
|
+
if (typeof v === "string" && v.length > 0 && v.length <= 128)
|
|
91
|
+
return v;
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
function tryReplayClientOp(req, res, store, sessionId) {
|
|
95
|
+
const opId = getClientOpId(req);
|
|
96
|
+
if (!opId)
|
|
97
|
+
return { opId: null, replayed: false };
|
|
98
|
+
const cached = store.getClientOp(sessionId, opId);
|
|
99
|
+
if (cached &&
|
|
100
|
+
typeof cached === "object" &&
|
|
101
|
+
"status" in cached &&
|
|
102
|
+
"body" in cached) {
|
|
103
|
+
json(res, cached.status, cached.body, req);
|
|
104
|
+
return { opId, replayed: true };
|
|
105
|
+
}
|
|
106
|
+
return { opId, replayed: false };
|
|
107
|
+
}
|
|
108
|
+
function saveClientOpResult(store, opId, sessionId, status, body) {
|
|
109
|
+
if (!opId)
|
|
110
|
+
return;
|
|
111
|
+
store.saveClientOp(sessionId, opId, { status, body });
|
|
112
|
+
}
|
|
31
113
|
/** Send a JSON response, gzip-compressed when the client supports it. */
|
|
32
114
|
function json(res, status, data, req) {
|
|
33
115
|
const body = JSON.stringify(data);
|
|
34
|
-
if (req &&
|
|
116
|
+
if (req &&
|
|
117
|
+
body.length > 1024 &&
|
|
118
|
+
(req.headers["accept-encoding"] ?? "").includes("gzip")) {
|
|
35
119
|
const compressed = gzipSync(body);
|
|
36
120
|
res.writeHead(status, {
|
|
37
121
|
"Content-Type": "application/json",
|
|
@@ -45,10 +129,274 @@ function json(res, status, data, req) {
|
|
|
45
129
|
res.end(body);
|
|
46
130
|
}
|
|
47
131
|
}
|
|
132
|
+
/** Per-request principal storage. WeakMap keeps it tied to the request lifetime
|
|
133
|
+
* without monkey-patching IncomingMessage or relying on `any`. */
|
|
134
|
+
const principalByRequest = new WeakMap();
|
|
135
|
+
export function getPrincipal(req) {
|
|
136
|
+
return principalByRequest.get(req);
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Multipart upload handler. Streams the `file` field straight to disk under
|
|
140
|
+
* <data_dir>/sessions/<sid>/attachments/<uuid>.<ext>.tmp, atomic-renames on
|
|
141
|
+
* success, deletes on any failure path. Inserts an attachments row with the
|
|
142
|
+
* resolved realpath so the bridge / permission interceptor can match it
|
|
143
|
+
* later.
|
|
144
|
+
*
|
|
145
|
+
* Wire format expected:
|
|
146
|
+
* - Content-Type: multipart/form-data; boundary=...
|
|
147
|
+
* - One file field named `file` (additional file fields are rejected).
|
|
148
|
+
* - Optional text fields are ignored — displayName comes from the file
|
|
149
|
+
* part's filename header, classification comes from its content-type.
|
|
150
|
+
*/
|
|
151
|
+
async function handleAttachmentUpload(req, res, sessionId, deps) {
|
|
152
|
+
const { store, dataDir, limits, sessions } = deps;
|
|
153
|
+
const fileUploadLimit = limits.file_upload ?? 52_428_800;
|
|
154
|
+
if (!store.getSession(sessionId)) {
|
|
155
|
+
json(res, 404, { error: "Session not found" });
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const dir = join(dataDir, "sessions", sessionId, "attachments");
|
|
159
|
+
await mkdir(dir, { recursive: true });
|
|
160
|
+
const uploadId = randomUUID();
|
|
161
|
+
let tmpPath = null;
|
|
162
|
+
let finalPath = null;
|
|
163
|
+
let bytesWritten = 0;
|
|
164
|
+
let fileMime = "";
|
|
165
|
+
let fileExt = "bin";
|
|
166
|
+
let displayName = null;
|
|
167
|
+
let kind = "file";
|
|
168
|
+
let limit = Math.max(limits.image_upload, fileUploadLimit);
|
|
169
|
+
let limitExceeded = false;
|
|
170
|
+
let sawFile = false;
|
|
171
|
+
let aborted = false;
|
|
172
|
+
let writeError = null;
|
|
173
|
+
let resolved = false;
|
|
174
|
+
// Resolves when the write stream backing the file part has flushed and
|
|
175
|
+
// closed. busboy's `close` can fire before fs has finished writing the
|
|
176
|
+
// last chunk to disk, so we must await this before renaming the .tmp.
|
|
177
|
+
let writeDone = Promise.resolve();
|
|
178
|
+
// Single-shot response. We only ever respond to the request once even
|
|
179
|
+
// though multiple busboy callbacks could converge on the same outcome
|
|
180
|
+
// (e.g. file-too-large + close-after-finish).
|
|
181
|
+
const respond = (status, body) => {
|
|
182
|
+
if (resolved)
|
|
183
|
+
return;
|
|
184
|
+
resolved = true;
|
|
185
|
+
json(res, status, body);
|
|
186
|
+
};
|
|
187
|
+
const cleanupTmp = async () => {
|
|
188
|
+
if (tmpPath) {
|
|
189
|
+
await unlink(tmpPath).catch(() => { });
|
|
190
|
+
tmpPath = null;
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
return new Promise((resolveOuter) => {
|
|
194
|
+
const finish = async (status, body) => {
|
|
195
|
+
respond(status, body);
|
|
196
|
+
resolveOuter();
|
|
197
|
+
};
|
|
198
|
+
let bb;
|
|
199
|
+
try {
|
|
200
|
+
bb = busboy({
|
|
201
|
+
headers: req.headers,
|
|
202
|
+
defParamCharset: "utf8",
|
|
203
|
+
limits: {
|
|
204
|
+
// We enforce the size cap manually via per-file byte tracking
|
|
205
|
+
// (so we can pick the right cap based on classified kind and
|
|
206
|
+
// emit a 413 the moment we cross the line). Cap files = 1
|
|
207
|
+
// and field count = 16 as belt-and-suspenders.
|
|
208
|
+
files: 1,
|
|
209
|
+
fields: 16,
|
|
210
|
+
fieldNameSize: 100,
|
|
211
|
+
fieldSize: 1024,
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
void finish(400, { error: "Invalid multipart" });
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
bb.on("file", (fieldName, stream, info) => {
|
|
220
|
+
if (sawFile) {
|
|
221
|
+
// Extra file part — drain and ignore (busboy `files: 1` should
|
|
222
|
+
// already prevent this, but be defensive).
|
|
223
|
+
stream.resume();
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
sawFile = true;
|
|
227
|
+
if (fieldName !== "file") {
|
|
228
|
+
stream.resume();
|
|
229
|
+
void finish(400, { error: "Unexpected field name" });
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
fileMime = (info.mimeType || "application/octet-stream").toLowerCase();
|
|
233
|
+
kind = classifyKind(fileMime);
|
|
234
|
+
limit = kind === "image" ? limits.image_upload : fileUploadLimit;
|
|
235
|
+
fileExt = mimeToExt(fileMime);
|
|
236
|
+
displayName = info.filename ? normalizeDisplayName(info.filename) : null;
|
|
237
|
+
displayName ??= kind === "image" ? "image" : "file";
|
|
238
|
+
tmpPath = join(dir, `${uploadId}.${fileExt}.tmp`);
|
|
239
|
+
finalPath = join(dir, `${uploadId}.${fileExt}`);
|
|
240
|
+
const ws = createWriteStream(tmpPath);
|
|
241
|
+
writeDone = new Promise((resolveWs) => {
|
|
242
|
+
ws.on("close", () => {
|
|
243
|
+
resolveWs();
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
stream.on("data", (chunk) => {
|
|
247
|
+
bytesWritten += chunk.length;
|
|
248
|
+
if (bytesWritten > limit) {
|
|
249
|
+
limitExceeded = true;
|
|
250
|
+
stream.unpipe(ws);
|
|
251
|
+
ws.destroy();
|
|
252
|
+
stream.resume();
|
|
253
|
+
// Abort the whole busboy pipeline. We respond on `close`.
|
|
254
|
+
req.unpipe(bb);
|
|
255
|
+
req.resume();
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
stream.on("error", (err) => {
|
|
259
|
+
writeError = err;
|
|
260
|
+
});
|
|
261
|
+
ws.on("error", (err) => {
|
|
262
|
+
writeError = err;
|
|
263
|
+
});
|
|
264
|
+
stream.pipe(ws);
|
|
265
|
+
});
|
|
266
|
+
bb.on("error", (err) => {
|
|
267
|
+
writeError = err instanceof Error ? err : new Error(String(err));
|
|
268
|
+
});
|
|
269
|
+
req.on("aborted", () => {
|
|
270
|
+
aborted = true;
|
|
271
|
+
});
|
|
272
|
+
bb.on("close", () => {
|
|
273
|
+
void (async () => {
|
|
274
|
+
try {
|
|
275
|
+
await writeDone;
|
|
276
|
+
if (aborted) {
|
|
277
|
+
await cleanupTmp();
|
|
278
|
+
await finish(400, { error: "Upload aborted" });
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
if (limitExceeded) {
|
|
282
|
+
await cleanupTmp();
|
|
283
|
+
await finish(413, { error: "Upload too large" });
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
if (writeError) {
|
|
287
|
+
await cleanupTmp();
|
|
288
|
+
await finish(500, { error: "Upload failed" });
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
if (!sawFile || !tmpPath || !finalPath || !displayName) {
|
|
292
|
+
await cleanupTmp();
|
|
293
|
+
await finish(400, { error: "Missing file part" });
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
// Sniff the actual mime from file content (magic bytes + UTF-8
|
|
297
|
+
// text fallback). Clients lie about Content-Type — browsers send
|
|
298
|
+
// application/octet-stream for any extension the OS doesn't know
|
|
299
|
+
// (.clj, .lua, .rs, ...), and ACP agents (Copilot CLI) refuse to
|
|
300
|
+
// read attachments tagged octet-stream. The sniffed mime wins
|
|
301
|
+
// silently; we override fileMime / kind / extension / final path
|
|
302
|
+
// before insertAttachment so DB and disk reflect reality.
|
|
303
|
+
const head = await readFile(tmpPath).catch(() => Buffer.alloc(0));
|
|
304
|
+
const headSlice = head.subarray(0, 4096);
|
|
305
|
+
const sniffed = await sniffMime(headSlice);
|
|
306
|
+
if (sniffed !== fileMime) {
|
|
307
|
+
fileMime = sniffed;
|
|
308
|
+
kind = classifyKind(fileMime);
|
|
309
|
+
const newExt = mimeToExt(fileMime);
|
|
310
|
+
if (newExt !== fileExt) {
|
|
311
|
+
fileExt = newExt;
|
|
312
|
+
const newTmp = join(dir, `${uploadId}.${fileExt}.tmp`);
|
|
313
|
+
if (newTmp !== tmpPath) {
|
|
314
|
+
await rename(tmpPath, newTmp);
|
|
315
|
+
tmpPath = newTmp;
|
|
316
|
+
}
|
|
317
|
+
finalPath = join(dir, `${uploadId}.${fileExt}`);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
await rename(tmpPath, finalPath);
|
|
321
|
+
const rp = await realpath(finalPath);
|
|
322
|
+
const row = store.insertAttachment({
|
|
323
|
+
id: uploadId,
|
|
324
|
+
sessionId,
|
|
325
|
+
kind,
|
|
326
|
+
name: displayName,
|
|
327
|
+
mime: fileMime,
|
|
328
|
+
size: bytesWritten,
|
|
329
|
+
realpath: rp,
|
|
330
|
+
});
|
|
331
|
+
// Invalidate the per-session attachment label cache so the
|
|
332
|
+
// next egress (SSE broadcast or replay) sees this new row.
|
|
333
|
+
sessions?.invalidateLabelCache(sessionId);
|
|
334
|
+
const fileName = `${row.id}.${fileExt}`;
|
|
335
|
+
const basePath = `/api/v1/sessions/${sessionId}/attachments/${fileName}`;
|
|
336
|
+
// 1h signed URL — long enough that the browser holds the rendered
|
|
337
|
+
// image in <img> cache for the full session lifetime, short enough
|
|
338
|
+
// that a leaked URL (screenshot, link share) expires within the day.
|
|
339
|
+
const fileUrl = deps.attachmentSecret
|
|
340
|
+
? `${basePath}?${signAttachmentUrl(basePath, deps.attachmentSecret, 3600)}`
|
|
341
|
+
: basePath;
|
|
342
|
+
await finish(200, {
|
|
343
|
+
attachmentId: row.id,
|
|
344
|
+
displayName: row.name,
|
|
345
|
+
mimeType: row.mime,
|
|
346
|
+
size: row.size,
|
|
347
|
+
kind: row.kind,
|
|
348
|
+
path: `sessions/${sessionId}/attachments/${fileName}`,
|
|
349
|
+
url: fileUrl,
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
catch (err) {
|
|
353
|
+
await cleanupTmp();
|
|
354
|
+
await finish(500, { error: errorMessage(err) });
|
|
355
|
+
}
|
|
356
|
+
})();
|
|
357
|
+
});
|
|
358
|
+
req.pipe(bb);
|
|
359
|
+
});
|
|
360
|
+
}
|
|
48
361
|
export function createRequestHandler(deps) {
|
|
49
362
|
const { store, sessions, getBridge, sseManager, titleService } = deps;
|
|
363
|
+
// eslint-disable-next-line complexity -- TODO: refactor main route handler into smaller handlers
|
|
50
364
|
return async (req, res) => {
|
|
51
365
|
const url = req.url ?? "/";
|
|
366
|
+
// --- Auth gate: any /api/** outside whitelist requires Bearer ---
|
|
367
|
+
if (deps.authStore && url.startsWith("/api/")) {
|
|
368
|
+
const path = url.split("?")[0] ?? url;
|
|
369
|
+
const method = req.method ?? "GET";
|
|
370
|
+
if (!isWhitelistedPath(method, path)) {
|
|
371
|
+
const result = authenticate(req.headers, deps.authStore);
|
|
372
|
+
if (!result.ok) {
|
|
373
|
+
res.writeHead(401, {
|
|
374
|
+
"Content-Type": "application/json",
|
|
375
|
+
"WWW-Authenticate": "Bearer",
|
|
376
|
+
});
|
|
377
|
+
res.end(JSON.stringify({ error: "Unauthorized", reason: result.reason }));
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
principalByRequest.set(req, result.principal);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
// Share routes — early dispatch so /s/* and /api/v1/shares* claim
|
|
384
|
+
// their URL space before the generic /api/v1 branch. When
|
|
385
|
+
// `shareConfig.enabled === false` handleShareRoutes is a no-op.
|
|
386
|
+
// The auth gate above has already enforced Bearer on owner endpoints
|
|
387
|
+
// (/api/v1/sessions/:id/share*, /api/v1/shares); viewer endpoints
|
|
388
|
+
// (/s/:token, /api/v1/shared/:token/events) must be whitelisted in
|
|
389
|
+
// auth-middleware.ts so they remain public.
|
|
390
|
+
if (deps.shareConfig &&
|
|
391
|
+
(await handleShareRoutes(req, res, {
|
|
392
|
+
store,
|
|
393
|
+
sessions,
|
|
394
|
+
config: deps.shareConfig,
|
|
395
|
+
dataDir: deps.dataDir,
|
|
396
|
+
publicDir: deps.publicDir,
|
|
397
|
+
}))) {
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
52
400
|
// --- API routes ---
|
|
53
401
|
if (url === "/api/v1" || url.startsWith("/api/v1/")) {
|
|
54
402
|
res.setHeader("Content-Type", "application/json");
|
|
@@ -69,7 +417,9 @@ export function createRequestHandler(deps) {
|
|
|
69
417
|
return;
|
|
70
418
|
}
|
|
71
419
|
// GET /api/v1/sessions
|
|
72
|
-
if (url.startsWith("/api/v1/sessions") &&
|
|
420
|
+
if (url.startsWith("/api/v1/sessions") &&
|
|
421
|
+
!url.slice("/api/v1/sessions".length).match(/^\//) &&
|
|
422
|
+
req.method === "GET") {
|
|
73
423
|
const params = new URLSearchParams(url.split("?")[1] ?? "");
|
|
74
424
|
const source = params.get("source") ?? undefined;
|
|
75
425
|
res.end(JSON.stringify(store.listSessions(source ? { source } : undefined)));
|
|
@@ -105,6 +455,136 @@ export function createRequestHandler(deps) {
|
|
|
105
455
|
});
|
|
106
456
|
return;
|
|
107
457
|
}
|
|
458
|
+
// GET /api/v1/auth/verify — token validation probe
|
|
459
|
+
if (url === "/api/v1/auth/verify" && req.method === "GET") {
|
|
460
|
+
const principal = principalByRequest.get(req);
|
|
461
|
+
if (!principal) {
|
|
462
|
+
json(res, 401, { error: "Unauthorized" });
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
json(res, 200, {
|
|
466
|
+
ok: true,
|
|
467
|
+
name: principal.name,
|
|
468
|
+
scope: principal.scope,
|
|
469
|
+
});
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
// POST /api/v1/sse-ticket — mint short-lived ticket for EventSource
|
|
473
|
+
if (url === "/api/v1/sse-ticket" && req.method === "POST") {
|
|
474
|
+
const principal = principalByRequest.get(req);
|
|
475
|
+
if (!principal) {
|
|
476
|
+
json(res, 401, { error: "Unauthorized" });
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
if (!deps.ticketStore) {
|
|
480
|
+
json(res, 501, { error: "SSE not available" });
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
const ticket = deps.ticketStore.mint({
|
|
484
|
+
tokenName: principal.name,
|
|
485
|
+
scope: principal.scope,
|
|
486
|
+
});
|
|
487
|
+
json(res, 200, { ticket, expiresIn: 60 });
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
// --- Token management (admin scope) ---
|
|
491
|
+
// GET /api/v1/tokens — list tokens.
|
|
492
|
+
// admin: full list. api: own token only (self-row, so the slash
|
|
493
|
+
// menu can show the user's own metadata without leaking peers).
|
|
494
|
+
if (url === "/api/v1/tokens" && req.method === "GET") {
|
|
495
|
+
const principal = principalByRequest.get(req);
|
|
496
|
+
if (!deps.authStore || !principal) {
|
|
497
|
+
json(res, 401, { error: "Unauthorized" });
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
const all = deps.authStore.list();
|
|
501
|
+
const visible = principal.scope === "admin"
|
|
502
|
+
? all
|
|
503
|
+
: all.filter((t) => t.name === principal.name);
|
|
504
|
+
const list = visible.map((t) => ({
|
|
505
|
+
name: t.name,
|
|
506
|
+
scope: t.scope,
|
|
507
|
+
createdAt: t.createdAt,
|
|
508
|
+
lastUsedAt: t.lastUsedAt,
|
|
509
|
+
isSelf: t.name === principal.name,
|
|
510
|
+
}));
|
|
511
|
+
json(res, 200, list);
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
// POST /api/v1/tokens — create new api-scope token, return raw value once
|
|
515
|
+
if (url === "/api/v1/tokens" && req.method === "POST") {
|
|
516
|
+
const principal = principalByRequest.get(req);
|
|
517
|
+
if (!deps.authStore || !principal) {
|
|
518
|
+
json(res, 401, { error: "Unauthorized" });
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
if (principal.scope !== "admin") {
|
|
522
|
+
json(res, 403, { error: "Forbidden" });
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
let body;
|
|
526
|
+
try {
|
|
527
|
+
body = JSON.parse(await readBody(req));
|
|
528
|
+
}
|
|
529
|
+
catch {
|
|
530
|
+
json(res, 400, { error: "Invalid JSON" });
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
const name = typeof body.name === "string" ? body.name : "";
|
|
534
|
+
try {
|
|
535
|
+
const created = await deps.authStore.addToken(name, "api");
|
|
536
|
+
json(res, 201, {
|
|
537
|
+
token: created.token,
|
|
538
|
+
name: created.record.name,
|
|
539
|
+
scope: created.record.scope,
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
catch (err) {
|
|
543
|
+
const msg = errorMessage(err);
|
|
544
|
+
if (/already exists|duplicate/i.test(msg)) {
|
|
545
|
+
json(res, 409, { error: msg });
|
|
546
|
+
}
|
|
547
|
+
else {
|
|
548
|
+
json(res, 400, { error: msg });
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
// DELETE /api/v1/tokens/:name — revoke
|
|
554
|
+
const tokenDelMatch = url.match(/^\/api\/v1\/tokens\/([^/?]+)\/?$/);
|
|
555
|
+
if (tokenDelMatch && req.method === "DELETE") {
|
|
556
|
+
const principal = principalByRequest.get(req);
|
|
557
|
+
if (!deps.authStore || !principal) {
|
|
558
|
+
json(res, 401, { error: "Unauthorized" });
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
if (principal.scope !== "admin") {
|
|
562
|
+
json(res, 403, { error: "Forbidden" });
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
const name = decodeURIComponent(tokenDelMatch[1]);
|
|
566
|
+
if (!/^[A-Za-z0-9_-]{1,64}$/.test(name)) {
|
|
567
|
+
json(res, 400, { error: "Invalid token name" });
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
if (name === principal.name) {
|
|
571
|
+
json(res, 400, { error: "Cannot revoke the token you are using" });
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
try {
|
|
575
|
+
const ok = await deps.authStore.revokeToken(name);
|
|
576
|
+
if (!ok) {
|
|
577
|
+
json(res, 404, { error: "Token not found" });
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
res.writeHead(204);
|
|
581
|
+
res.end();
|
|
582
|
+
}
|
|
583
|
+
catch (err) {
|
|
584
|
+
json(res, 400, { error: errorMessage(err) });
|
|
585
|
+
}
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
108
588
|
// --- POST /api/v1/bridge/reload ---
|
|
109
589
|
if (url === "/api/v1/bridge/reload" && req.method === "POST") {
|
|
110
590
|
const bridge = getBridge?.();
|
|
@@ -139,6 +619,9 @@ export function createRequestHandler(deps) {
|
|
|
139
619
|
if (permActionMatch && req.method === "POST") {
|
|
140
620
|
const sessionId = decodeURIComponent(permActionMatch[1]);
|
|
141
621
|
const requestId = decodeURIComponent(permActionMatch[2]);
|
|
622
|
+
const { opId, replayed } = tryReplayClientOp(req, res, store, sessionId);
|
|
623
|
+
if (replayed)
|
|
624
|
+
return;
|
|
142
625
|
const perm = sessions?.pendingPermissions.get(requestId);
|
|
143
626
|
if (!perm) {
|
|
144
627
|
json(res, 404, { error: "Permission not found" });
|
|
@@ -165,25 +648,34 @@ export function createRequestHandler(deps) {
|
|
|
165
648
|
json(res, 400, { error: "Provide optionId or denied:true" });
|
|
166
649
|
return;
|
|
167
650
|
}
|
|
168
|
-
const denied =
|
|
651
|
+
const denied = Boolean(body.denied);
|
|
169
652
|
const optionId = body.optionId ?? "deny";
|
|
170
|
-
const optionName = perm.options.find(o => o.optionId === optionId)?.label ?? optionId;
|
|
653
|
+
const optionName = perm.options.find((o) => o.optionId === optionId)?.label ?? optionId;
|
|
171
654
|
if (denied) {
|
|
172
|
-
|
|
655
|
+
bridge.denyPermission(requestId);
|
|
173
656
|
}
|
|
174
657
|
else {
|
|
175
|
-
|
|
658
|
+
bridge.resolvePermission(requestId, optionId);
|
|
176
659
|
}
|
|
177
660
|
sessions.pendingPermissions.delete(requestId);
|
|
661
|
+
sessions.syncPendingPermissions(sessionId);
|
|
178
662
|
// Store event and broadcast (same type so SSE drops are recoverable via sync)
|
|
179
663
|
const permEventData = { requestId, optionName, denied };
|
|
180
|
-
store.saveEvent(perm.sessionId, "permission_response", { ...permEventData, optionId });
|
|
664
|
+
store.saveEvent(perm.sessionId, "permission_response", { ...permEventData, optionId }, { from_ref: "user" });
|
|
181
665
|
sseManager.broadcast({
|
|
182
666
|
type: "permission_response",
|
|
183
667
|
sessionId: perm.sessionId,
|
|
184
668
|
...permEventData,
|
|
185
669
|
});
|
|
186
|
-
|
|
670
|
+
// Cross-device banner recall: close the permission banner on
|
|
671
|
+
// every subscribed endpoint now that the permission has been
|
|
672
|
+
// handled by this client.
|
|
673
|
+
if (deps.pushService) {
|
|
674
|
+
void deps.pushService.sendClose(`sess-${perm.sessionId}-perm-${requestId}`);
|
|
675
|
+
}
|
|
676
|
+
const okBody = { ok: true };
|
|
677
|
+
saveClientOpResult(store, opId, sessionId, 200, okBody);
|
|
678
|
+
json(res, 200, okBody);
|
|
187
679
|
return;
|
|
188
680
|
}
|
|
189
681
|
// --- POST /api/v1/sessions/:id/cancel ---
|
|
@@ -200,6 +692,9 @@ export function createRequestHandler(deps) {
|
|
|
200
692
|
json(res, 503, { error: "Agent not ready yet" });
|
|
201
693
|
return;
|
|
202
694
|
}
|
|
695
|
+
const { opId, replayed } = tryReplayClientOp(req, res, store, sessionId);
|
|
696
|
+
if (replayed)
|
|
697
|
+
return;
|
|
203
698
|
// Kill running bash process if any
|
|
204
699
|
const proc = sessions?.runningBashProcs.get(sessionId);
|
|
205
700
|
if (proc) {
|
|
@@ -211,7 +706,16 @@ export function createRequestHandler(deps) {
|
|
|
211
706
|
await bridge.cancel(sessionId);
|
|
212
707
|
sessions.activePrompts.delete(sessionId);
|
|
213
708
|
}
|
|
214
|
-
|
|
709
|
+
// Arm backend safety net: if prompt_done doesn't arrive within the
|
|
710
|
+
// configured timeout, force-clear busy so the UI unstalls. Replaces
|
|
711
|
+
// the old frontend-side cancel timer.
|
|
712
|
+
const cancelTimeout = deps.limits.cancel_timeout ?? 0;
|
|
713
|
+
if (sessions && cancelTimeout > 0)
|
|
714
|
+
sessions.state.armCancelSafety(sessionId, cancelTimeout);
|
|
715
|
+
sessions?.syncBusy(sessionId);
|
|
716
|
+
const okBody = { ok: true };
|
|
717
|
+
saveClientOpResult(store, opId, sessionId, 200, okBody);
|
|
718
|
+
json(res, 200, okBody);
|
|
215
719
|
return;
|
|
216
720
|
}
|
|
217
721
|
// --- GET /api/v1/sessions/:id/status ---
|
|
@@ -232,6 +736,45 @@ export function createRequestHandler(deps) {
|
|
|
232
736
|
});
|
|
233
737
|
return;
|
|
234
738
|
}
|
|
739
|
+
// --- GET /api/v1/sessions/:id/snapshot ---
|
|
740
|
+
// client-server-split M1: single source of truth for "what state is
|
|
741
|
+
// this session in right now". Frontend calls this on connect / reconnect
|
|
742
|
+
// / after long backgrounding, then applies incremental `state_patch`
|
|
743
|
+
// SSE events.
|
|
744
|
+
const snapshotMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/snapshot\/?$/);
|
|
745
|
+
if (snapshotMatch && req.method === "GET") {
|
|
746
|
+
const sessionId = decodeURIComponent(snapshotMatch[1]);
|
|
747
|
+
const session = store.getSession(sessionId);
|
|
748
|
+
if (!session) {
|
|
749
|
+
json(res, 404, { error: "Session not found" });
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
if (!sessions) {
|
|
753
|
+
json(res, 503, { error: "Session manager not available" });
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
// Make sure runtime reflects the current activePrompts/bash state even
|
|
757
|
+
// if no patch has been emitted yet for this session.
|
|
758
|
+
sessions.syncBusy(sessionId);
|
|
759
|
+
sessions.syncPendingPermissions(sessionId);
|
|
760
|
+
const runtimeState = sessions.state.getState(sessionId);
|
|
761
|
+
const lastEventSeq = store.getLastEventSeq(sessionId);
|
|
762
|
+
json(res, 200, {
|
|
763
|
+
version: 1,
|
|
764
|
+
seq: runtimeState.seq,
|
|
765
|
+
session: {
|
|
766
|
+
id: session.id,
|
|
767
|
+
title: session.title,
|
|
768
|
+
cwd: session.cwd,
|
|
769
|
+
model: session.model,
|
|
770
|
+
mode: session.mode,
|
|
771
|
+
createdAt: session.created_at,
|
|
772
|
+
lastEventSeq,
|
|
773
|
+
},
|
|
774
|
+
runtime: runtimeState.runtime,
|
|
775
|
+
}, req);
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
235
778
|
// --- POST /api/v1/sessions/:id/prompt ---
|
|
236
779
|
const promptMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/prompt\/?(\?.*)?$/);
|
|
237
780
|
if (promptMatch && req.method === "POST") {
|
|
@@ -250,12 +793,17 @@ export function createRequestHandler(deps) {
|
|
|
250
793
|
json(res, 503, { error: "Session manager not available" });
|
|
251
794
|
return;
|
|
252
795
|
}
|
|
796
|
+
const { opId, replayed } = tryReplayClientOp(req, res, store, sessionId);
|
|
797
|
+
if (replayed)
|
|
798
|
+
return;
|
|
253
799
|
// Ensure session is live in ACP before prompting (awaits in-flight resume)
|
|
254
800
|
try {
|
|
255
801
|
await sessions.ensureResumed(bridge, sessionId);
|
|
256
802
|
}
|
|
257
803
|
catch (err) {
|
|
258
|
-
json(res, 500, {
|
|
804
|
+
json(res, 500, {
|
|
805
|
+
error: `Failed to resume session: ${err instanceof Error ? err.message : String(err)}`,
|
|
806
|
+
});
|
|
259
807
|
return;
|
|
260
808
|
}
|
|
261
809
|
// Check if session is busy
|
|
@@ -276,28 +824,101 @@ export function createRequestHandler(deps) {
|
|
|
276
824
|
json(res, 400, { error: "Missing required field: text" });
|
|
277
825
|
return;
|
|
278
826
|
}
|
|
279
|
-
//
|
|
280
|
-
|
|
281
|
-
|
|
827
|
+
// Validate attachment shape: client must NEVER supply uri/data/path,
|
|
828
|
+
// only the four canonical fields. Anything else → 400 immediately so
|
|
829
|
+
// we don't even hand it to the dispatcher fallback.
|
|
830
|
+
const attachments = body.attachments;
|
|
831
|
+
if (attachments) {
|
|
832
|
+
if (!Array.isArray(attachments)) {
|
|
833
|
+
json(res, 400, { error: "attachments must be an array" });
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
for (const raw of attachments) {
|
|
837
|
+
const att = raw;
|
|
838
|
+
if (!att ||
|
|
839
|
+
typeof att !== "object" ||
|
|
840
|
+
(att.kind !== "image" && att.kind !== "file") ||
|
|
841
|
+
typeof att.attachmentId !== "string" ||
|
|
842
|
+
typeof att.displayName !== "string" ||
|
|
843
|
+
typeof att.mimeType !== "string") {
|
|
844
|
+
json(res, 400, { error: "Invalid attachment entry" });
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
if (typeof att.uri === "string" ||
|
|
848
|
+
typeof att.data === "string" ||
|
|
849
|
+
typeof att.path === "string") {
|
|
850
|
+
json(res, 400, {
|
|
851
|
+
error: "Client must not supply uri/data/path",
|
|
852
|
+
});
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
// Stored shape mirrors the wire shape PLUS a server-derived `path`
|
|
858
|
+
// for renderers. The path is the unsigned base URL
|
|
859
|
+
// (`/api/v1/sessions/<sid>/attachments/<filename>`); reSign on
|
|
860
|
+
// egress (history GET + SSE broadcast) appends a fresh `?sig=&exp=`.
|
|
861
|
+
// Renderers use it to mount `<img>` (kind=image) or `<a>` (kind=file).
|
|
862
|
+
// Refs whose attachment row is missing are dropped (defense — the
|
|
863
|
+
// dispatcher's [attachment removed] fallback covers that case).
|
|
864
|
+
const storedAttachments = attachments?.flatMap((a) => {
|
|
865
|
+
const row = store.getAttachment(sessionId, a.attachmentId);
|
|
866
|
+
if (!row)
|
|
867
|
+
return [];
|
|
868
|
+
const fileName = basename(row.realpath);
|
|
869
|
+
return [
|
|
870
|
+
{
|
|
871
|
+
kind: a.kind,
|
|
872
|
+
attachmentId: a.attachmentId,
|
|
873
|
+
displayName: a.displayName,
|
|
874
|
+
mimeType: a.mimeType,
|
|
875
|
+
path: `/api/v1/sessions/${sessionId}/attachments/${fileName}`,
|
|
876
|
+
},
|
|
877
|
+
];
|
|
878
|
+
});
|
|
879
|
+
store.saveEvent(sessionId, "user_message", {
|
|
880
|
+
text: body.text,
|
|
881
|
+
...(storedAttachments?.length
|
|
882
|
+
? { attachments: storedAttachments }
|
|
883
|
+
: {}),
|
|
884
|
+
}, { from_ref: "user" });
|
|
282
885
|
store.updateSessionLastActive(sessionId);
|
|
283
886
|
store.touchRecentPath(session.cwd);
|
|
284
|
-
const userMsgEvent = {
|
|
887
|
+
const userMsgEvent = {
|
|
888
|
+
type: "user_message",
|
|
889
|
+
sessionId,
|
|
890
|
+
text: body.text,
|
|
891
|
+
attachments: storedAttachments,
|
|
892
|
+
};
|
|
285
893
|
sseManager.broadcast(userMsgEvent);
|
|
286
894
|
// Generate title (fire-and-forget)
|
|
287
|
-
if (titleService &&
|
|
895
|
+
if (titleService &&
|
|
896
|
+
sessions && // eslint-disable-line @typescript-eslint/no-unnecessary-condition -- optional dep
|
|
897
|
+
!sessions.sessionHasTitle.has(sessionId)) {
|
|
288
898
|
titleService.generate(bridge, body.text, sessionId, (title) => {
|
|
289
|
-
const titleEvent = {
|
|
899
|
+
const titleEvent = {
|
|
900
|
+
type: "session_title_updated",
|
|
901
|
+
sessionId,
|
|
902
|
+
title,
|
|
903
|
+
};
|
|
290
904
|
sseManager.broadcast(titleEvent);
|
|
291
905
|
});
|
|
292
906
|
}
|
|
293
907
|
// Fire prompt asynchronously (don't await — response is 202)
|
|
294
908
|
sessions.activePrompts.add(sessionId);
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
909
|
+
sessions.syncBusy(sessionId);
|
|
910
|
+
bridge
|
|
911
|
+
.prompt(sessionId, body.text, attachments)
|
|
912
|
+
.catch((err) => {
|
|
913
|
+
plog.error("error", { sessionId, error: err });
|
|
914
|
+
})
|
|
915
|
+
.finally(() => {
|
|
298
916
|
sessions.activePrompts.delete(sessionId);
|
|
917
|
+
sessions.syncBusy(sessionId);
|
|
299
918
|
});
|
|
300
|
-
|
|
919
|
+
const acceptedBody = { status: "accepted" };
|
|
920
|
+
saveClientOpResult(store, opId, sessionId, 202, acceptedBody);
|
|
921
|
+
json(res, 202, acceptedBody);
|
|
301
922
|
return;
|
|
302
923
|
}
|
|
303
924
|
// --- POST /api/v1/sessions/:id/bash ---
|
|
@@ -314,7 +935,9 @@ export function createRequestHandler(deps) {
|
|
|
314
935
|
return;
|
|
315
936
|
}
|
|
316
937
|
if (sessions.runningBashProcs.has(sessionId)) {
|
|
317
|
-
json(res, 409, {
|
|
938
|
+
json(res, 409, {
|
|
939
|
+
error: "A bash command is already running in this session",
|
|
940
|
+
});
|
|
318
941
|
return;
|
|
319
942
|
}
|
|
320
943
|
let body;
|
|
@@ -330,11 +953,19 @@ export function createRequestHandler(deps) {
|
|
|
330
953
|
return;
|
|
331
954
|
}
|
|
332
955
|
const cwd = sessions.getSessionCwd(sessionId);
|
|
333
|
-
store.saveEvent(sessionId, "bash_command", { command: body.command });
|
|
334
|
-
const bashCmdEvent = {
|
|
956
|
+
store.saveEvent(sessionId, "bash_command", { command: body.command }, { from_ref: "user" });
|
|
957
|
+
const bashCmdEvent = {
|
|
958
|
+
type: "bash_command",
|
|
959
|
+
sessionId,
|
|
960
|
+
command: body.command,
|
|
961
|
+
};
|
|
335
962
|
sseManager.broadcast(bashCmdEvent);
|
|
336
|
-
const shell = IS_WIN
|
|
337
|
-
|
|
963
|
+
const shell = IS_WIN
|
|
964
|
+
? (process.env.COMSPEC ?? "cmd.exe")
|
|
965
|
+
: (process.env.SHELL ?? "bash");
|
|
966
|
+
const shellArgs = IS_WIN
|
|
967
|
+
? ["/s", "/c", body.command]
|
|
968
|
+
: ["-c", body.command];
|
|
338
969
|
const child = spawn(shell, shellArgs, {
|
|
339
970
|
cwd,
|
|
340
971
|
detached: !IS_WIN,
|
|
@@ -342,6 +973,7 @@ export function createRequestHandler(deps) {
|
|
|
342
973
|
stdio: ["ignore", "pipe", "pipe"],
|
|
343
974
|
});
|
|
344
975
|
sessions.runningBashProcs.set(sessionId, child);
|
|
976
|
+
sessions.syncBusy(sessionId);
|
|
345
977
|
let output = "";
|
|
346
978
|
let outputTruncated = false;
|
|
347
979
|
const limit = deps.limits.bash_output;
|
|
@@ -357,23 +989,41 @@ export function createRequestHandler(deps) {
|
|
|
357
989
|
else {
|
|
358
990
|
output = (output + text).slice(-limit);
|
|
359
991
|
}
|
|
360
|
-
const bashOutEvent = {
|
|
992
|
+
const bashOutEvent = {
|
|
993
|
+
type: "bash_output",
|
|
994
|
+
sessionId,
|
|
995
|
+
text,
|
|
996
|
+
stream,
|
|
997
|
+
};
|
|
361
998
|
sseManager.broadcast(bashOutEvent);
|
|
362
999
|
};
|
|
363
1000
|
child.stdout.on("data", onData("stdout"));
|
|
364
1001
|
child.stderr.on("data", onData("stderr"));
|
|
365
1002
|
child.on("close", (code, signal) => {
|
|
366
1003
|
sessions.runningBashProcs.delete(sessionId);
|
|
1004
|
+
sessions.syncBusy(sessionId);
|
|
367
1005
|
const stored = outputTruncated ? "[truncated]\n" + output : output;
|
|
368
|
-
store.saveEvent(sessionId, "bash_result", { output: stored, code, signal });
|
|
369
|
-
const bashDoneEvent = {
|
|
1006
|
+
store.saveEvent(sessionId, "bash_result", { output: stored, code, signal }, { from_ref: "system" });
|
|
1007
|
+
const bashDoneEvent = {
|
|
1008
|
+
type: "bash_done",
|
|
1009
|
+
sessionId,
|
|
1010
|
+
code,
|
|
1011
|
+
signal,
|
|
1012
|
+
};
|
|
370
1013
|
sseManager.broadcast(bashDoneEvent);
|
|
371
1014
|
});
|
|
372
1015
|
child.on("error", (err) => {
|
|
373
1016
|
sessions.runningBashProcs.delete(sessionId);
|
|
1017
|
+
sessions.syncBusy(sessionId);
|
|
374
1018
|
const errMsg = errorMessage(err);
|
|
375
|
-
store.saveEvent(sessionId, "bash_result", { output: errMsg, code: -1, signal: null });
|
|
376
|
-
const bashErrEvent = {
|
|
1019
|
+
store.saveEvent(sessionId, "bash_result", { output: errMsg, code: -1, signal: null }, { from_ref: "system" });
|
|
1020
|
+
const bashErrEvent = {
|
|
1021
|
+
type: "bash_done",
|
|
1022
|
+
sessionId,
|
|
1023
|
+
code: -1,
|
|
1024
|
+
signal: null,
|
|
1025
|
+
error: errMsg,
|
|
1026
|
+
};
|
|
377
1027
|
sseManager.broadcast(bashErrEvent);
|
|
378
1028
|
});
|
|
379
1029
|
json(res, 202, { status: "accepted" });
|
|
@@ -425,12 +1075,23 @@ export function createRequestHandler(deps) {
|
|
|
425
1075
|
for (const opt of configOptions) {
|
|
426
1076
|
store.updateSessionConfig(sessionId, opt.id, opt.currentValue);
|
|
427
1077
|
}
|
|
428
|
-
sseManager.broadcast({
|
|
429
|
-
|
|
1078
|
+
sseManager.broadcast({
|
|
1079
|
+
type: "config_option_update",
|
|
1080
|
+
sessionId,
|
|
1081
|
+
configOptions,
|
|
1082
|
+
});
|
|
1083
|
+
sseManager.broadcast({
|
|
1084
|
+
type: "config_set",
|
|
1085
|
+
sessionId,
|
|
1086
|
+
configId,
|
|
1087
|
+
value: body.value,
|
|
1088
|
+
});
|
|
430
1089
|
json(res, 200, { configOptions });
|
|
431
1090
|
}
|
|
432
1091
|
catch (err) {
|
|
433
|
-
json(res, 500, {
|
|
1092
|
+
json(res, 500, {
|
|
1093
|
+
error: `Failed to set ${configId}: ${err instanceof Error ? err.message : String(err)}`,
|
|
1094
|
+
});
|
|
434
1095
|
}
|
|
435
1096
|
return;
|
|
436
1097
|
}
|
|
@@ -460,8 +1121,12 @@ export function createRequestHandler(deps) {
|
|
|
460
1121
|
sessions.sessionHasTitle.add(sessionId);
|
|
461
1122
|
const bridge = getBridge?.();
|
|
462
1123
|
if (titleService && bridge)
|
|
463
|
-
titleService.cancel(sessionId, bridge);
|
|
464
|
-
const titleEvent = {
|
|
1124
|
+
void titleService.cancel(sessionId, bridge);
|
|
1125
|
+
const titleEvent = {
|
|
1126
|
+
type: "session_title_updated",
|
|
1127
|
+
sessionId,
|
|
1128
|
+
title: body.value,
|
|
1129
|
+
};
|
|
465
1130
|
sseManager.broadcast(titleEvent);
|
|
466
1131
|
json(res, 200, { title: body.value });
|
|
467
1132
|
return;
|
|
@@ -479,53 +1144,112 @@ export function createRequestHandler(deps) {
|
|
|
479
1144
|
json(res, 404, { error: "Session not found" });
|
|
480
1145
|
return;
|
|
481
1146
|
}
|
|
482
|
-
// Start resume in background (non-blocking) so the client gets metadata fast
|
|
1147
|
+
// Start resume in background (non-blocking) so the client gets metadata fast.
|
|
1148
|
+
// BUT: if cache is cold (cold-restart case), block on resume up to 8s so
|
|
1149
|
+
// configOptions returns inline. This eliminates the race where the
|
|
1150
|
+
// post-resume `config_option_update` broadcast can arrive before the
|
|
1151
|
+
// client's SSE is fully wired up — a flake observed on slow CI runners.
|
|
483
1152
|
const wasLive = sessions?.liveSessions.has(sessionId) ?? true;
|
|
1153
|
+
let resumePromise = null;
|
|
484
1154
|
if (sessions && getBridge && !wasLive) {
|
|
485
1155
|
const bridge = getBridge();
|
|
486
1156
|
if (bridge) {
|
|
487
|
-
|
|
1157
|
+
resumePromise = sessions.ensureResumed(bridge, sessionId);
|
|
1158
|
+
// Broadcast config_option_update too, so any OTHER client viewing
|
|
1159
|
+
// the same session (whose own GET may have raced and returned cold)
|
|
1160
|
+
// also picks up the warm values.
|
|
1161
|
+
resumePromise
|
|
1162
|
+
.then(() => {
|
|
1163
|
+
const cur = store.getSession(sessionId);
|
|
1164
|
+
if (!cur || !sessions.cachedConfigOptions.length)
|
|
1165
|
+
return;
|
|
1166
|
+
const opts = sessions.cachedConfigOptions.map((opt) => {
|
|
1167
|
+
const stored = {
|
|
1168
|
+
model: cur.model,
|
|
1169
|
+
mode: cur.mode,
|
|
1170
|
+
reasoning_effort: cur.reasoning_effort,
|
|
1171
|
+
};
|
|
1172
|
+
const override = stored[opt.id];
|
|
1173
|
+
return override ? { ...opt, currentValue: override } : opt;
|
|
1174
|
+
});
|
|
1175
|
+
sseManager.broadcast({
|
|
1176
|
+
type: "config_option_update",
|
|
1177
|
+
sessionId,
|
|
1178
|
+
configOptions: opts,
|
|
1179
|
+
});
|
|
1180
|
+
})
|
|
1181
|
+
.catch(() => { });
|
|
488
1182
|
// Auto-retry if the last turn was interrupted (must wait for resume)
|
|
489
1183
|
const hasInterrupted = store.hasInterruptedTurn(sessionId);
|
|
490
1184
|
if (hasInterrupted) {
|
|
491
1185
|
// Optimistically mark busy so concurrent POST sees the session as active
|
|
492
1186
|
sessions.activePrompts.add(sessionId);
|
|
493
|
-
|
|
1187
|
+
sessions.syncBusy(sessionId);
|
|
1188
|
+
void resumePromise
|
|
1189
|
+
.then(() => {
|
|
494
1190
|
if (!sessions.autoRetryIfNeeded(bridge, sessionId)) {
|
|
495
1191
|
// Retry not needed after all — release the optimistic lock
|
|
496
1192
|
sessions.activePrompts.delete(sessionId);
|
|
1193
|
+
sessions.syncBusy(sessionId);
|
|
497
1194
|
}
|
|
498
|
-
})
|
|
1195
|
+
})
|
|
1196
|
+
.catch(() => {
|
|
499
1197
|
sessions.activePrompts.delete(sessionId);
|
|
1198
|
+
sessions.syncBusy(sessionId);
|
|
500
1199
|
});
|
|
501
1200
|
}
|
|
502
1201
|
else {
|
|
503
1202
|
resumePromise.catch((err) => {
|
|
504
|
-
|
|
1203
|
+
slog.error("background resume failed", {
|
|
1204
|
+
sessionId: sessionId.slice(0, 8) + "…",
|
|
1205
|
+
error: err,
|
|
1206
|
+
});
|
|
505
1207
|
});
|
|
506
1208
|
}
|
|
507
1209
|
}
|
|
508
1210
|
}
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
1211
|
+
// If cache is cold and we kicked off a resume, wait briefly so the
|
|
1212
|
+
// GET response includes warm configOptions. Bounded to 8s — past that
|
|
1213
|
+
// we fall back to returning empty configOptions and rely on the
|
|
1214
|
+
// broadcast above. The frontend retries (re-fetches) on broadcast.
|
|
1215
|
+
if (resumePromise && !sessions?.cachedConfigOptions.length) {
|
|
1216
|
+
let timer = null;
|
|
1217
|
+
const timeoutPromise = new Promise((resolve) => {
|
|
1218
|
+
timer = setTimeout(resolve, 8000);
|
|
515
1219
|
});
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
1220
|
+
await Promise.race([
|
|
1221
|
+
resumePromise.finally(() => {
|
|
1222
|
+
if (timer)
|
|
1223
|
+
clearTimeout(timer);
|
|
1224
|
+
}),
|
|
1225
|
+
timeoutPromise,
|
|
1226
|
+
]).catch(() => { });
|
|
1227
|
+
}
|
|
1228
|
+
// Re-read session in case resume mutated stored config
|
|
1229
|
+
const freshSession = store.getSession(sessionId) ?? session;
|
|
1230
|
+
const configOptions = sessions
|
|
1231
|
+
? (() => {
|
|
1232
|
+
// Build configOptions from cached + stored overrides
|
|
1233
|
+
const opts = sessions.cachedConfigOptions.map((opt) => {
|
|
1234
|
+
const stored = {
|
|
1235
|
+
model: freshSession.model,
|
|
1236
|
+
mode: freshSession.mode,
|
|
1237
|
+
reasoning_effort: freshSession.reasoning_effort,
|
|
1238
|
+
};
|
|
1239
|
+
const override = stored[opt.id];
|
|
1240
|
+
return override ? { ...opt, currentValue: override } : opt;
|
|
1241
|
+
});
|
|
1242
|
+
return opts;
|
|
1243
|
+
})()
|
|
1244
|
+
: [];
|
|
519
1245
|
json(res, 200, {
|
|
520
|
-
id:
|
|
521
|
-
cwd:
|
|
522
|
-
title:
|
|
523
|
-
source:
|
|
524
|
-
model:
|
|
525
|
-
mode:
|
|
1246
|
+
id: freshSession.id,
|
|
1247
|
+
cwd: freshSession.cwd,
|
|
1248
|
+
title: freshSession.title,
|
|
1249
|
+
source: freshSession.source,
|
|
1250
|
+
model: freshSession.model,
|
|
1251
|
+
mode: freshSession.mode,
|
|
526
1252
|
configOptions,
|
|
527
|
-
busy: busyKind != null,
|
|
528
|
-
busyKind,
|
|
529
1253
|
}, req);
|
|
530
1254
|
return;
|
|
531
1255
|
}
|
|
@@ -582,7 +1306,11 @@ export function createRequestHandler(deps) {
|
|
|
582
1306
|
// ACP's session_created event fires before inheritance runs, so
|
|
583
1307
|
// broadcast final configOptions so SSE clients get the inherited values.
|
|
584
1308
|
if (configOptions.length) {
|
|
585
|
-
sseManager.broadcast({
|
|
1309
|
+
sseManager.broadcast({
|
|
1310
|
+
type: "config_option_update",
|
|
1311
|
+
sessionId,
|
|
1312
|
+
configOptions,
|
|
1313
|
+
});
|
|
586
1314
|
}
|
|
587
1315
|
json(res, 201, {
|
|
588
1316
|
id: sessionId,
|
|
@@ -607,14 +1335,17 @@ export function createRequestHandler(deps) {
|
|
|
607
1335
|
const eventsMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/events(\?.*)?$/);
|
|
608
1336
|
if (eventsMatch && req.method === "GET") {
|
|
609
1337
|
const sessionId = decodeURIComponent(eventsMatch[1]);
|
|
610
|
-
const
|
|
1338
|
+
const queryPart = eventsMatch[2];
|
|
1339
|
+
const params = new URLSearchParams(queryPart ? queryPart.slice(1) : "");
|
|
611
1340
|
const excludeThinking = params.get("thinking") === "0";
|
|
612
1341
|
const afterRaw = params.get("after");
|
|
613
1342
|
const afterSeq = afterRaw != null ? Number(afterRaw) : undefined;
|
|
614
1343
|
const beforeRaw = params.get("before");
|
|
615
1344
|
const beforeSeq = beforeRaw != null ? Number(beforeRaw) : undefined;
|
|
616
1345
|
const limitRaw = params.get("limit");
|
|
617
|
-
const limit = limitRaw != null
|
|
1346
|
+
const limit = limitRaw != null
|
|
1347
|
+
? Math.max(1, Math.min(10000, Number(limitRaw)))
|
|
1348
|
+
: undefined;
|
|
618
1349
|
const session = store.getSession(sessionId);
|
|
619
1350
|
if (!session) {
|
|
620
1351
|
json(res, 404, { error: "Session not found" });
|
|
@@ -635,15 +1366,45 @@ export function createRequestHandler(deps) {
|
|
|
635
1366
|
sessions.flushAssistantBuffer(sessionId);
|
|
636
1367
|
}
|
|
637
1368
|
}
|
|
638
|
-
const events = store.getEvents(sessionId, {
|
|
1369
|
+
const events = store.getEvents(sessionId, {
|
|
1370
|
+
excludeThinking,
|
|
1371
|
+
afterSeq,
|
|
1372
|
+
beforeSeq,
|
|
1373
|
+
limit,
|
|
1374
|
+
});
|
|
1375
|
+
// Replace internal uuid attachment paths with `<name> [#<id4>]`
|
|
1376
|
+
// labels at egress (CLAUDE.md "Attachment label egress
|
|
1377
|
+
// rewrite"). DB rows still hold raw paths.
|
|
1378
|
+
if (sessions) {
|
|
1379
|
+
enrichStoredEventsForDisplay(events, sessions.getLabelMap(sessionId));
|
|
1380
|
+
}
|
|
1381
|
+
// Re-sign image URLs at egress so 1h-old stored URLs become valid
|
|
1382
|
+
// again — the user can reload history days later and images still
|
|
1383
|
+
// resolve. Mutates `data` in-place; safe because store.getEvents
|
|
1384
|
+
// returns fresh records.
|
|
1385
|
+
if (deps.attachmentSecret) {
|
|
1386
|
+
for (const ev of events) {
|
|
1387
|
+
if (typeof ev.data === "string" &&
|
|
1388
|
+
ev.data.includes("/attachments/")) {
|
|
1389
|
+
ev.data = reSignAttachmentUrlsInJson(ev.data, deps.attachmentSecret);
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
639
1393
|
const envelope = {
|
|
640
1394
|
events,
|
|
641
|
-
streaming: {
|
|
1395
|
+
streaming: {
|
|
1396
|
+
thinking: streamingThinking,
|
|
1397
|
+
assistant: streamingAssistant,
|
|
1398
|
+
},
|
|
642
1399
|
};
|
|
643
1400
|
if (limit != null) {
|
|
644
1401
|
const total = store.getEventCount(sessionId, { excludeThinking });
|
|
645
1402
|
const hasMore = events.length > 0
|
|
646
|
-
?
|
|
1403
|
+
? store.getEvents(sessionId, {
|
|
1404
|
+
excludeThinking,
|
|
1405
|
+
beforeSeq: events[0].seq,
|
|
1406
|
+
limit: 1,
|
|
1407
|
+
}).length > 0
|
|
647
1408
|
: false;
|
|
648
1409
|
envelope.total = total;
|
|
649
1410
|
envelope.hasMore = hasMore;
|
|
@@ -654,32 +1415,54 @@ export function createRequestHandler(deps) {
|
|
|
654
1415
|
// --- SSE stream endpoints ---
|
|
655
1416
|
// GET /api/v1/events/stream — global SSE stream
|
|
656
1417
|
if (url.startsWith("/api/v1/events/stream") && req.method === "GET") {
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
1418
|
+
// Ticket-based auth (EventSource cannot send Authorization header).
|
|
1419
|
+
// When authStore is configured, every SSE connection MUST present a
|
|
1420
|
+
// valid single-use ticket from POST /api/v1/sse-ticket.
|
|
1421
|
+
let tokenName;
|
|
1422
|
+
if (deps.authStore) {
|
|
1423
|
+
const ticket = new URLSearchParams(url.split("?")[1] ?? "").get("ticket") ?? "";
|
|
1424
|
+
const principal = deps.ticketStore?.consume(ticket);
|
|
1425
|
+
if (!principal) {
|
|
1426
|
+
json(res, 401, { error: "Invalid or expired ticket" });
|
|
1427
|
+
return;
|
|
1428
|
+
}
|
|
1429
|
+
tokenName = principal.tokenName;
|
|
660
1430
|
}
|
|
661
|
-
const sseManager = deps.sseManager;
|
|
662
1431
|
const clientId = sseManager.generateClientId();
|
|
663
1432
|
res.writeHead(200, {
|
|
664
1433
|
"Content-Type": "text/event-stream",
|
|
665
1434
|
"Cache-Control": "no-cache",
|
|
666
|
-
|
|
1435
|
+
Connection: "keep-alive",
|
|
667
1436
|
});
|
|
668
|
-
const client = {
|
|
1437
|
+
const client = {
|
|
1438
|
+
id: clientId,
|
|
1439
|
+
res,
|
|
1440
|
+
tokenName,
|
|
1441
|
+
};
|
|
669
1442
|
sseManager.add(client);
|
|
670
1443
|
// Send connected event
|
|
671
|
-
sseManager.sendEvent(client, {
|
|
1444
|
+
sseManager.sendEvent(client, {
|
|
1445
|
+
type: "connected",
|
|
1446
|
+
clientId,
|
|
1447
|
+
debugLevel: deps.debugLevel ?? "off",
|
|
1448
|
+
});
|
|
1449
|
+
sseManager.writeHeartbeat(client);
|
|
672
1450
|
return;
|
|
673
1451
|
}
|
|
674
1452
|
// GET /api/v1/sessions/:id/events/stream — per-session SSE stream
|
|
675
1453
|
const sseSessionMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/events\/stream(\?.*)?$/);
|
|
676
1454
|
if (sseSessionMatch && req.method === "GET") {
|
|
677
|
-
if (!deps.sseManager) {
|
|
678
|
-
json(res, 501, { error: "SSE not available" });
|
|
679
|
-
return;
|
|
680
|
-
}
|
|
681
|
-
const sseManager = deps.sseManager;
|
|
682
1455
|
const sessionId = decodeURIComponent(sseSessionMatch[1]);
|
|
1456
|
+
let tokenName;
|
|
1457
|
+
if (deps.authStore) {
|
|
1458
|
+
const ticket = new URLSearchParams(url.split("?")[1] ?? "").get("ticket") ?? "";
|
|
1459
|
+
const principal = deps.ticketStore?.consume(ticket);
|
|
1460
|
+
if (!principal) {
|
|
1461
|
+
json(res, 401, { error: "Invalid or expired ticket" });
|
|
1462
|
+
return;
|
|
1463
|
+
}
|
|
1464
|
+
tokenName = principal.tokenName;
|
|
1465
|
+
}
|
|
683
1466
|
const session = store.getSession(sessionId);
|
|
684
1467
|
if (!session) {
|
|
685
1468
|
json(res, 404, { error: "Session not found" });
|
|
@@ -689,12 +1472,22 @@ export function createRequestHandler(deps) {
|
|
|
689
1472
|
res.writeHead(200, {
|
|
690
1473
|
"Content-Type": "text/event-stream",
|
|
691
1474
|
"Cache-Control": "no-cache",
|
|
692
|
-
|
|
1475
|
+
Connection: "keep-alive",
|
|
693
1476
|
});
|
|
694
|
-
const client = {
|
|
1477
|
+
const client = {
|
|
1478
|
+
id: clientId,
|
|
1479
|
+
res,
|
|
1480
|
+
sessionId,
|
|
1481
|
+
tokenName,
|
|
1482
|
+
};
|
|
695
1483
|
sseManager.add(client);
|
|
696
1484
|
// Send connected event
|
|
697
|
-
sseManager.sendEvent(client, {
|
|
1485
|
+
sseManager.sendEvent(client, {
|
|
1486
|
+
type: "connected",
|
|
1487
|
+
clientId,
|
|
1488
|
+
debugLevel: deps.debugLevel ?? "off",
|
|
1489
|
+
});
|
|
1490
|
+
sseManager.writeHeartbeat(client);
|
|
698
1491
|
// Replay events from Last-Event-ID if provided
|
|
699
1492
|
const lastEventId = req.headers["last-event-id"];
|
|
700
1493
|
if (lastEventId) {
|
|
@@ -703,7 +1496,10 @@ export function createRequestHandler(deps) {
|
|
|
703
1496
|
const events = store.getEvents(sessionId, { afterSeq });
|
|
704
1497
|
for (const evt of events) {
|
|
705
1498
|
try {
|
|
706
|
-
sseManager.sendEvent(client, {
|
|
1499
|
+
sseManager.sendEvent(client, {
|
|
1500
|
+
type: evt.type,
|
|
1501
|
+
...JSON.parse(evt.data),
|
|
1502
|
+
}, evt.seq);
|
|
707
1503
|
}
|
|
708
1504
|
catch {
|
|
709
1505
|
// Skip malformed event data
|
|
@@ -713,69 +1509,75 @@ export function createRequestHandler(deps) {
|
|
|
713
1509
|
}
|
|
714
1510
|
return;
|
|
715
1511
|
}
|
|
716
|
-
// ---
|
|
717
|
-
// POST /api/v1/sessions/:id/
|
|
718
|
-
|
|
1512
|
+
// --- Attachments (session-scoped) ---
|
|
1513
|
+
// POST /api/v1/sessions/:id/attachments — multipart/form-data upload.
|
|
1514
|
+
//
|
|
1515
|
+
// Wire format: a single `file` field. busboy streams chunks straight
|
|
1516
|
+
// to <data_dir>/sessions/<sid>/attachments/<uuid>.<ext>.tmp; on close
|
|
1517
|
+
// we atomic-rename to the final name and insert an attachments row.
|
|
1518
|
+
// Aborts / mid-stream errors / oversize / wrong field name all leave
|
|
1519
|
+
// the .tmp removed before responding.
|
|
1520
|
+
const imgUploadMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/attachments\/?$/);
|
|
719
1521
|
if (imgUploadMatch && req.method === "POST") {
|
|
720
1522
|
const sessionId = decodeURIComponent(imgUploadMatch[1]);
|
|
721
1523
|
if (!SAFE_ID.test(sessionId)) {
|
|
722
1524
|
json(res, 400, { error: "Invalid session ID" });
|
|
723
1525
|
return;
|
|
724
1526
|
}
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
json(res, 413, { error: "Upload too large" });
|
|
1527
|
+
const ctype = req.headers["content-type"] ?? "";
|
|
1528
|
+
if (!ctype.toLowerCase().startsWith("multipart/form-data")) {
|
|
1529
|
+
json(res, 400, { error: "Expected multipart/form-data" });
|
|
729
1530
|
return;
|
|
730
1531
|
}
|
|
731
|
-
|
|
732
|
-
let totalSize = 0;
|
|
733
|
-
for await (const chunk of req) {
|
|
734
|
-
totalSize += chunk.length;
|
|
735
|
-
if (totalSize > deps.limits.image_upload) {
|
|
736
|
-
json(res, 413, { error: "Upload too large" });
|
|
737
|
-
return;
|
|
738
|
-
}
|
|
739
|
-
chunks.push(chunk);
|
|
740
|
-
}
|
|
741
|
-
let body;
|
|
742
|
-
try {
|
|
743
|
-
body = JSON.parse(Buffer.concat(chunks).toString());
|
|
744
|
-
}
|
|
745
|
-
catch {
|
|
746
|
-
json(res, 400, { error: "Invalid JSON" });
|
|
747
|
-
return;
|
|
748
|
-
}
|
|
749
|
-
const { data, mimeType } = body;
|
|
750
|
-
const ext = mimeType.split("/")[1]?.replace("jpeg", "jpg") ?? "png";
|
|
751
|
-
const seq = Date.now();
|
|
752
|
-
const fileName = `${seq}.${ext}`;
|
|
753
|
-
const relPath = `images/${sessionId}/${fileName}`;
|
|
754
|
-
const absPath = join(deps.dataDir, relPath);
|
|
755
|
-
await mkdir(join(deps.dataDir, "images", sessionId), { recursive: true });
|
|
756
|
-
await writeFile(absPath, Buffer.from(data, "base64"));
|
|
757
|
-
const imgUrl = `/api/v1/sessions/${sessionId}/images/${fileName}`;
|
|
758
|
-
json(res, 200, { path: relPath, url: imgUrl });
|
|
1532
|
+
await handleAttachmentUpload(req, res, sessionId, deps);
|
|
759
1533
|
return;
|
|
760
1534
|
}
|
|
761
|
-
// GET /api/v1/sessions/:id/
|
|
762
|
-
|
|
1535
|
+
// GET /api/v1/sessions/:id/attachments/:file — serve a previously
|
|
1536
|
+
// uploaded attachment. Mime + displayName are looked up from the
|
|
1537
|
+
// attachments table so the response carries the original filename
|
|
1538
|
+
// (RFC 5987) and the per-mime inline/attachment disposition.
|
|
1539
|
+
const imgGetMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/attachments\/([^/?]+)(\?.*)?$/);
|
|
763
1540
|
if (imgGetMatch && req.method === "GET") {
|
|
764
1541
|
const sessionId = decodeURIComponent(imgGetMatch[1]);
|
|
765
1542
|
const file = decodeURIComponent(imgGetMatch[2]);
|
|
766
|
-
|
|
767
|
-
|
|
1543
|
+
// When secret is configured, GET requires sig+exp in query — there
|
|
1544
|
+
// is no Bearer fallback because <img src=...> / <a href=...> can't
|
|
1545
|
+
// carry headers. Verify before any disk I/O.
|
|
1546
|
+
if (deps.attachmentSecret) {
|
|
1547
|
+
const params = new URLSearchParams(url.split("?")[1] ?? "");
|
|
1548
|
+
const sig = params.get("sig") ?? "";
|
|
1549
|
+
const exp = params.get("exp") ?? "";
|
|
1550
|
+
const basePath = `/api/v1/sessions/${sessionId}/attachments/${file}`;
|
|
1551
|
+
if (!verifyAttachmentSig(basePath, exp, sig, deps.attachmentSecret)) {
|
|
1552
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
1553
|
+
res.end(JSON.stringify({ error: "Unauthorized" }));
|
|
1554
|
+
return;
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
const filePath = join(deps.dataDir, "sessions", sessionId, "attachments", file);
|
|
1558
|
+
if (!filePath.startsWith(join(deps.dataDir, "sessions"))) {
|
|
768
1559
|
res.writeHead(403);
|
|
769
1560
|
res.end("Forbidden");
|
|
770
1561
|
return;
|
|
771
1562
|
}
|
|
1563
|
+
// Look up the row to recover the original mime + display name.
|
|
1564
|
+
// Pre-attachments-table uploads (none in v0.4+) would miss here;
|
|
1565
|
+
// we degrade gracefully to extension-based mime.
|
|
1566
|
+
const row = store.getAttachmentByFile(sessionId, file);
|
|
772
1567
|
try {
|
|
773
1568
|
const fileData = await readFile(filePath);
|
|
774
|
-
const
|
|
775
|
-
|
|
776
|
-
|
|
1569
|
+
const mime = row?.mime ??
|
|
1570
|
+
(MIME[extname(filePath)] || "application/octet-stream");
|
|
1571
|
+
const headers = {
|
|
1572
|
+
"Content-Type": mime,
|
|
777
1573
|
"Cache-Control": "public, max-age=31536000, immutable",
|
|
778
|
-
|
|
1574
|
+
"X-Content-Type-Options": "nosniff",
|
|
1575
|
+
};
|
|
1576
|
+
if (row) {
|
|
1577
|
+
const disposition = isInlineMime(mime) ? "inline" : "attachment";
|
|
1578
|
+
headers["Content-Disposition"] = buildContentDisposition(disposition, row.name);
|
|
1579
|
+
}
|
|
1580
|
+
res.writeHead(200, headers);
|
|
779
1581
|
res.end(fileData);
|
|
780
1582
|
}
|
|
781
1583
|
catch {
|
|
@@ -784,6 +1586,203 @@ export function createRequestHandler(deps) {
|
|
|
784
1586
|
}
|
|
785
1587
|
return;
|
|
786
1588
|
}
|
|
1589
|
+
// --- Inbox messages (Stage B primitive) ---
|
|
1590
|
+
// POST /api/v1/messages — create ingress message
|
|
1591
|
+
if (url === "/api/v1/messages" && req.method === "POST") {
|
|
1592
|
+
// client-server-split M2: idempotency for the ingress message
|
|
1593
|
+
// creator. /messages has no real session id, so we scope the
|
|
1594
|
+
// cache under the synthetic key "__ingress__".
|
|
1595
|
+
const { opId, replayed } = tryReplayClientOp(req, res, store, "__ingress__");
|
|
1596
|
+
if (replayed)
|
|
1597
|
+
return;
|
|
1598
|
+
let raw;
|
|
1599
|
+
try {
|
|
1600
|
+
raw = await readBody(req);
|
|
1601
|
+
}
|
|
1602
|
+
catch {
|
|
1603
|
+
json(res, 400, { error: "Failed to read body" });
|
|
1604
|
+
return;
|
|
1605
|
+
}
|
|
1606
|
+
let parsed;
|
|
1607
|
+
try {
|
|
1608
|
+
parsed = JSON.parse(raw);
|
|
1609
|
+
}
|
|
1610
|
+
catch {
|
|
1611
|
+
json(res, 400, { error: "Invalid JSON" });
|
|
1612
|
+
return;
|
|
1613
|
+
}
|
|
1614
|
+
const validation = MessageIngressSchema.safeParse(parsed);
|
|
1615
|
+
if (!validation.success) {
|
|
1616
|
+
json(res, 400, {
|
|
1617
|
+
error: "Invalid body",
|
|
1618
|
+
issues: validation.error.issues,
|
|
1619
|
+
});
|
|
1620
|
+
return;
|
|
1621
|
+
}
|
|
1622
|
+
const input = validation.data;
|
|
1623
|
+
const id = `msg-${randomUUID().replace(/-/g, "").slice(0, 16)}`;
|
|
1624
|
+
if (input.to.startsWith("session:")) {
|
|
1625
|
+
const targetSid = input.to.slice("session:".length);
|
|
1626
|
+
const session = store.getSession(targetSid);
|
|
1627
|
+
if (!session) {
|
|
1628
|
+
json(res, 400, { error: "session_not_found" });
|
|
1629
|
+
return;
|
|
1630
|
+
}
|
|
1631
|
+
sessions?.flushBuffers(targetSid);
|
|
1632
|
+
const data = {
|
|
1633
|
+
message_id: id,
|
|
1634
|
+
from_ref: input.from_ref,
|
|
1635
|
+
from_label: input.from_label ?? null,
|
|
1636
|
+
title: input.title,
|
|
1637
|
+
body: input.body,
|
|
1638
|
+
cwd: input.cwd ?? null,
|
|
1639
|
+
};
|
|
1640
|
+
store.saveEvent(targetSid, "message", data, {
|
|
1641
|
+
from_ref: input.from_ref,
|
|
1642
|
+
});
|
|
1643
|
+
sseManager.broadcast({
|
|
1644
|
+
type: "message",
|
|
1645
|
+
sessionId: targetSid,
|
|
1646
|
+
...data,
|
|
1647
|
+
});
|
|
1648
|
+
if (deps.pushService) {
|
|
1649
|
+
void deps.pushService.sendForMessage({
|
|
1650
|
+
id,
|
|
1651
|
+
to: input.to,
|
|
1652
|
+
body: input.body,
|
|
1653
|
+
from_label: input.from_label,
|
|
1654
|
+
from_ref: input.from_ref,
|
|
1655
|
+
deliver: input.deliver,
|
|
1656
|
+
dedup_key: input.dedup_key ?? null,
|
|
1657
|
+
});
|
|
1658
|
+
}
|
|
1659
|
+
mlog.info("ingress bound", {
|
|
1660
|
+
msg_id: id,
|
|
1661
|
+
sess_id: targetSid.slice(0, 8),
|
|
1662
|
+
});
|
|
1663
|
+
const boundBody = { id, delivered: "session" };
|
|
1664
|
+
saveClientOpResult(store, opId, "__ingress__", 200, boundBody);
|
|
1665
|
+
json(res, 200, boundBody);
|
|
1666
|
+
return;
|
|
1667
|
+
}
|
|
1668
|
+
// Unbound: to=user → rows in `messages` table
|
|
1669
|
+
const dedupKey = input.dedup_key ?? null;
|
|
1670
|
+
if (dedupKey) {
|
|
1671
|
+
const prior = store.findBySupersede(input.to, dedupKey);
|
|
1672
|
+
if (prior) {
|
|
1673
|
+
store.deleteMessage(prior.id);
|
|
1674
|
+
mlog.info("dedup_key supersede", {
|
|
1675
|
+
to: input.to,
|
|
1676
|
+
dedup_key: dedupKey,
|
|
1677
|
+
old_msg_id: prior.id,
|
|
1678
|
+
new_msg_id: id,
|
|
1679
|
+
});
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
store.createMessage({
|
|
1683
|
+
id,
|
|
1684
|
+
from_ref: input.from_ref,
|
|
1685
|
+
from_label: input.from_label ?? null,
|
|
1686
|
+
to_ref: input.to,
|
|
1687
|
+
deliver: input.deliver,
|
|
1688
|
+
dedup_key: dedupKey,
|
|
1689
|
+
title: input.title,
|
|
1690
|
+
body: input.body,
|
|
1691
|
+
cwd: input.cwd ?? null,
|
|
1692
|
+
created_at: Date.now(),
|
|
1693
|
+
});
|
|
1694
|
+
sseManager.broadcast({ type: "message_created", messageId: id });
|
|
1695
|
+
if (deps.pushService) {
|
|
1696
|
+
void deps.pushService.sendForMessage({
|
|
1697
|
+
id,
|
|
1698
|
+
to: input.to,
|
|
1699
|
+
body: input.body,
|
|
1700
|
+
from_label: input.from_label,
|
|
1701
|
+
from_ref: input.from_ref,
|
|
1702
|
+
deliver: input.deliver,
|
|
1703
|
+
dedup_key: dedupKey,
|
|
1704
|
+
});
|
|
1705
|
+
}
|
|
1706
|
+
mlog.info("ingress unbound", {
|
|
1707
|
+
msg_id: id,
|
|
1708
|
+
from_ref: input.from_ref,
|
|
1709
|
+
});
|
|
1710
|
+
const unboundBody = { id, delivered: "pending" };
|
|
1711
|
+
saveClientOpResult(store, opId, "__ingress__", 200, unboundBody);
|
|
1712
|
+
json(res, 200, unboundBody);
|
|
1713
|
+
return;
|
|
1714
|
+
}
|
|
1715
|
+
// GET /api/v1/messages — list unprocessed
|
|
1716
|
+
if (url === "/api/v1/messages" && req.method === "GET") {
|
|
1717
|
+
json(res, 200, { messages: store.listUnprocessed() });
|
|
1718
|
+
return;
|
|
1719
|
+
}
|
|
1720
|
+
// /api/v1/messages/:id... — GET single, POST :id/consume, POST :id/ack, DELETE :id
|
|
1721
|
+
if (url.startsWith("/api/v1/messages/")) {
|
|
1722
|
+
const tail = url.slice("/api/v1/messages/".length);
|
|
1723
|
+
const consumeMatch = tail.match(/^([^/?]+)\/consume\/?$/);
|
|
1724
|
+
if (consumeMatch && req.method === "POST") {
|
|
1725
|
+
const id = decodeURIComponent(consumeMatch[1]);
|
|
1726
|
+
const newSid = randomUUID();
|
|
1727
|
+
let out;
|
|
1728
|
+
try {
|
|
1729
|
+
out = store.consumeMessageTx(id, { sessionId: newSid });
|
|
1730
|
+
}
|
|
1731
|
+
catch (err) {
|
|
1732
|
+
if (/message not found/.test(errorMessage(err))) {
|
|
1733
|
+
json(res, 404, { error: "Message not found" });
|
|
1734
|
+
return;
|
|
1735
|
+
}
|
|
1736
|
+
throw err;
|
|
1737
|
+
}
|
|
1738
|
+
sseManager.broadcast({
|
|
1739
|
+
type: "message_consumed",
|
|
1740
|
+
messageId: id,
|
|
1741
|
+
sessionId: out.sessionId,
|
|
1742
|
+
});
|
|
1743
|
+
if (!out.alreadyConsumed && deps.pushService) {
|
|
1744
|
+
void deps.pushService.sendClose(id);
|
|
1745
|
+
}
|
|
1746
|
+
mlog.info("consume", {
|
|
1747
|
+
msg_id: id,
|
|
1748
|
+
sess_id: out.sessionId.slice(0, 8),
|
|
1749
|
+
already_consumed: out.alreadyConsumed,
|
|
1750
|
+
});
|
|
1751
|
+
json(res, 200, {
|
|
1752
|
+
sessionId: out.sessionId,
|
|
1753
|
+
alreadyConsumed: out.alreadyConsumed,
|
|
1754
|
+
});
|
|
1755
|
+
return;
|
|
1756
|
+
}
|
|
1757
|
+
const ackPost = tail.match(/^([^/?]+)\/ack\/?$/);
|
|
1758
|
+
const idOnly = tail.match(/^([^/?]+)\/?$/);
|
|
1759
|
+
const isAck = (ackPost !== null && req.method === "POST") ||
|
|
1760
|
+
(idOnly !== null && req.method === "DELETE");
|
|
1761
|
+
if (isAck) {
|
|
1762
|
+
const id = decodeURIComponent((ackPost ?? idOnly)[1]);
|
|
1763
|
+
const changes = store.deleteMessage(id);
|
|
1764
|
+
if (changes === 0) {
|
|
1765
|
+
json(res, 404, { error: "Message not found" });
|
|
1766
|
+
return;
|
|
1767
|
+
}
|
|
1768
|
+
sseManager.broadcast({ type: "message_acked", messageId: id });
|
|
1769
|
+
if (deps.pushService)
|
|
1770
|
+
void deps.pushService.sendClose(id);
|
|
1771
|
+
mlog.info("ack", { msg_id: id });
|
|
1772
|
+
json(res, 200, { ok: true });
|
|
1773
|
+
return;
|
|
1774
|
+
}
|
|
1775
|
+
if (idOnly && req.method === "GET") {
|
|
1776
|
+
const id = decodeURIComponent(idOnly[1]);
|
|
1777
|
+
const row = store.getMessage(id);
|
|
1778
|
+
if (!row) {
|
|
1779
|
+
json(res, 404, { error: "Message not found" });
|
|
1780
|
+
return;
|
|
1781
|
+
}
|
|
1782
|
+
json(res, 200, row);
|
|
1783
|
+
return;
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
787
1786
|
json(res, 404, { error: "Not found" });
|
|
788
1787
|
return;
|
|
789
1788
|
}
|
|
@@ -814,34 +1813,45 @@ export function createRequestHandler(deps) {
|
|
|
814
1813
|
json(res, 400, { error: "Missing required field: text" });
|
|
815
1814
|
return;
|
|
816
1815
|
}
|
|
817
|
-
const cwd = body.cwd
|
|
1816
|
+
const cwd = typeof body.cwd === "string" ? body.cwd : undefined;
|
|
818
1817
|
const { sessionId } = await sessions.createSession(bridge, cwd, undefined, "auto");
|
|
819
1818
|
const streamUrl = `/api/v1/sessions/${sessionId}/events/stream`;
|
|
820
1819
|
json(res, 202, { sessionId, streamUrl });
|
|
821
1820
|
// Fire-and-forget: send the prompt asynchronously, tracking busy state
|
|
822
1821
|
sessions.activePrompts.add(sessionId);
|
|
1822
|
+
sessions.syncBusy(sessionId);
|
|
823
1823
|
// Generate title (fire-and-forget)
|
|
824
1824
|
if (titleService && !sessions.sessionHasTitle.has(sessionId)) {
|
|
825
1825
|
titleService.generate(bridge, text, sessionId, (title) => {
|
|
826
|
-
const titleEvent = {
|
|
1826
|
+
const titleEvent = {
|
|
1827
|
+
type: "session_title_updated",
|
|
1828
|
+
sessionId,
|
|
1829
|
+
title,
|
|
1830
|
+
};
|
|
827
1831
|
sseManager.broadcast(titleEvent);
|
|
828
1832
|
});
|
|
829
1833
|
}
|
|
830
|
-
bridge
|
|
1834
|
+
bridge
|
|
1835
|
+
.prompt(sessionId, text)
|
|
831
1836
|
.catch(() => { })
|
|
832
|
-
.finally(() =>
|
|
1837
|
+
.finally(() => {
|
|
1838
|
+
sessions.activePrompts.delete(sessionId);
|
|
1839
|
+
sessions.syncBusy(sessionId);
|
|
1840
|
+
});
|
|
833
1841
|
return;
|
|
834
1842
|
}
|
|
835
1843
|
// POST /api/beta/clients/:clientId/visibility
|
|
836
1844
|
const visMatch = url.match(/^\/api\/beta\/clients\/([^/]+)\/visibility$/);
|
|
837
1845
|
if (visMatch && req.method === "POST") {
|
|
838
|
-
if (!deps.sseManager) {
|
|
839
|
-
json(res, 501, { error: "SSE not available" });
|
|
840
|
-
return;
|
|
841
|
-
}
|
|
842
|
-
const sseManager = deps.sseManager;
|
|
843
1846
|
const clientId = decodeURIComponent(visMatch[1]);
|
|
844
|
-
if (
|
|
1847
|
+
// Trust boundary: accept if the client is (a) currently connected
|
|
1848
|
+
// via SSE, or (b) known to the ClientRegistry (populated on /hello,
|
|
1849
|
+
// persists across SSE disconnect). Registry check fixes the
|
|
1850
|
+
// pagehide-beacon race where iOS PWA suspension drops the SSE TCP
|
|
1851
|
+
// connection before the beacon egresses.
|
|
1852
|
+
const clientKnown = sseManager.clients.has(clientId) ||
|
|
1853
|
+
deps.clientRegistry?.get(clientId) !== undefined;
|
|
1854
|
+
if (!clientKnown) {
|
|
845
1855
|
json(res, 404, { error: "Client not found" });
|
|
846
1856
|
return;
|
|
847
1857
|
}
|
|
@@ -857,11 +1867,42 @@ export function createRequestHandler(deps) {
|
|
|
857
1867
|
json(res, 400, { error: "Missing or invalid 'visible' field" });
|
|
858
1868
|
return;
|
|
859
1869
|
}
|
|
860
|
-
//
|
|
1870
|
+
// sessionId patch semantics: absent = preserve, null = clear,
|
|
1871
|
+
// string = replace. Zod can't distinguish omitted from explicit
|
|
1872
|
+
// null after parse, so branch on raw body key.
|
|
1873
|
+
const hasSessionIdKey = Object.prototype.hasOwnProperty.call(body, "sessionId");
|
|
1874
|
+
let sessionIdPatch;
|
|
1875
|
+
if (!hasSessionIdKey) {
|
|
1876
|
+
sessionIdPatch = undefined;
|
|
1877
|
+
}
|
|
1878
|
+
else if (body.sessionId === null) {
|
|
1879
|
+
sessionIdPatch = null;
|
|
1880
|
+
}
|
|
1881
|
+
else if (typeof body.sessionId === "string" &&
|
|
1882
|
+
body.sessionId.length > 0) {
|
|
1883
|
+
sessionIdPatch = body.sessionId;
|
|
1884
|
+
}
|
|
1885
|
+
else {
|
|
1886
|
+
sessionIdPatch = null;
|
|
1887
|
+
}
|
|
861
1888
|
if (deps.pushService) {
|
|
862
|
-
deps.pushService.
|
|
863
|
-
|
|
864
|
-
|
|
1889
|
+
const { becameVisibleForSession } = deps.pushService.updateClient(clientId, {
|
|
1890
|
+
visible: body.visible,
|
|
1891
|
+
sessionId: sessionIdPatch,
|
|
1892
|
+
});
|
|
1893
|
+
// Edge-triggered only: heartbeat refreshes repeat the same
|
|
1894
|
+
// (visible:true, sessionId:X) POST every 15s — firing sendClose
|
|
1895
|
+
// on each would hammer banner recall. Only the first such
|
|
1896
|
+
// transition after a change should recall stale banners.
|
|
1897
|
+
if (becameVisibleForSession) {
|
|
1898
|
+
void deps.pushService.sendClose(`sess-${becameVisibleForSession}-done`);
|
|
1899
|
+
if (sessions) {
|
|
1900
|
+
for (const perm of sessions.pendingPermissions.values()) {
|
|
1901
|
+
if (perm.sessionId === becameVisibleForSession) {
|
|
1902
|
+
void deps.pushService.sendClose(`sess-${becameVisibleForSession}-perm-${perm.requestId}`);
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
}
|
|
865
1906
|
}
|
|
866
1907
|
}
|
|
867
1908
|
json(res, 200, { ok: true });
|
|
@@ -894,11 +1935,12 @@ export function createRequestHandler(deps) {
|
|
|
894
1935
|
json(res, 400, { error: "Invalid JSON" });
|
|
895
1936
|
return;
|
|
896
1937
|
}
|
|
897
|
-
if (!body.endpoint || !body.keys?.auth || !body.keys
|
|
1938
|
+
if (!body.endpoint || !body.keys?.auth || !body.keys.p256dh) {
|
|
898
1939
|
json(res, 400, { error: "Missing endpoint or keys (auth, p256dh)" });
|
|
899
1940
|
return;
|
|
900
1941
|
}
|
|
901
1942
|
store.saveSubscription(body.endpoint, body.keys.auth, body.keys.p256dh);
|
|
1943
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- defensive
|
|
902
1944
|
if (body.clientId && deps.pushService) {
|
|
903
1945
|
deps.pushService.registerClient(body.clientId, body.endpoint);
|
|
904
1946
|
}
|
|
@@ -957,7 +1999,11 @@ export function createRequestHandler(deps) {
|
|
|
957
1999
|
return;
|
|
958
2000
|
}
|
|
959
2001
|
// --- Static files ---
|
|
960
|
-
|
|
2002
|
+
let staticPath = url;
|
|
2003
|
+
const htmlEntry = HTML_ENTRYPOINTS.find((e) => e.urlPath === staticPath);
|
|
2004
|
+
if (htmlEntry)
|
|
2005
|
+
staticPath = "/" + htmlEntry.file;
|
|
2006
|
+
const filePath = join(deps.publicDir, staticPath);
|
|
961
2007
|
if (!filePath.startsWith(deps.publicDir)) {
|
|
962
2008
|
res.writeHead(403);
|
|
963
2009
|
res.end("Forbidden");
|
|
@@ -966,7 +2012,19 @@ export function createRequestHandler(deps) {
|
|
|
966
2012
|
try {
|
|
967
2013
|
const data = await readFile(filePath);
|
|
968
2014
|
const ext = extname(filePath);
|
|
969
|
-
|
|
2015
|
+
const base = filePath.slice(filePath.lastIndexOf("/") + 1);
|
|
2016
|
+
const isHashedAsset = /\.[A-Za-z0-9_-]{8,}\.(js|css)$/.test(base);
|
|
2017
|
+
const cacheControl = isHashedAsset
|
|
2018
|
+
? "public, max-age=31536000, immutable"
|
|
2019
|
+
: "no-cache";
|
|
2020
|
+
const headers = {
|
|
2021
|
+
"Content-Type": MIME[ext] ?? "application/octet-stream",
|
|
2022
|
+
"Cache-Control": cacheControl,
|
|
2023
|
+
};
|
|
2024
|
+
// CSP applies to HTML entrypoints (where script/style execute).
|
|
2025
|
+
if (htmlEntry)
|
|
2026
|
+
headers["Content-Security-Policy"] = CSP_POLICY;
|
|
2027
|
+
res.writeHead(200, headers);
|
|
970
2028
|
res.end(data);
|
|
971
2029
|
}
|
|
972
2030
|
catch {
|