@lelouchhe/webagent 0.3.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -23
- package/bin/webagent.mjs +119 -8
- package/config.toml +105 -3
- package/dist/fonts/temml/Temml.woff2 +0 -0
- package/dist/index.html +64 -41
- package/dist/js/app.3OEVQXHK.js +4 -0
- package/dist/js/chunk.AJZBJBMO.js +1 -0
- package/dist/js/chunk.D4ZYHJAM.js +1 -0
- package/dist/js/chunk.IJM5DBCO.js +173 -0
- package/dist/js/chunk.VZXGXFNN.js +5 -0
- package/dist/js/login.PYIK52HN.js +1 -0
- package/dist/js/viewer.FCSSTUVY.js +1 -0
- package/dist/login.html +49 -0
- package/dist/share-viewer.00gubshk.css +114 -0
- package/dist/share-viewer.html +54 -0
- package/dist/styles.00xfh3e6.css +1848 -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 +105 -0
- package/lib/auth-store.js +269 -0
- package/lib/auth.js +89 -0
- package/lib/bootstrap.js +70 -0
- package/lib/bridge-event-config.js +29 -0
- package/lib/bridge.js +244 -93
- package/lib/client-registry.js +149 -0
- package/lib/config.js +130 -9
- package/lib/daemon.js +175 -41
- package/lib/event-handler.js +209 -91
- package/lib/image-dimensions.js +64 -0
- 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/model-picker.js +17 -0
- package/lib/preflight.js +214 -0
- package/lib/push-service.js +297 -52
- package/lib/routes.js +1315 -145
- package/lib/server.js +149 -37
- 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 +95 -0
- package/lib/store.js +636 -30
- package/lib/title-service.js +26 -9
- package/lib/tokens.js +50 -0
- package/lib/types.js +23 -0
- package/package.json +39 -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,23 @@
|
|
|
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";
|
|
20
|
+
import { readImageDimensions } from "./image-dimensions.js";
|
|
8
21
|
const IS_WIN = process.platform === "win32";
|
|
9
22
|
const SAFE_ID = /^[a-zA-Z0-9_-]+$/;
|
|
10
23
|
const MIME = {
|
|
@@ -18,20 +31,111 @@ const MIME = {
|
|
|
18
31
|
".jpeg": "image/jpeg",
|
|
19
32
|
".gif": "image/gif",
|
|
20
33
|
".webp": "image/webp",
|
|
34
|
+
".woff2": "font/woff2",
|
|
35
|
+
".wasm": "application/wasm",
|
|
21
36
|
};
|
|
37
|
+
/**
|
|
38
|
+
* HTML entrypoints served by this app. Any new HTML page MUST be registered
|
|
39
|
+
* here. Tests in `test/html-entrypoints.test.ts`, `test/csp.test.ts` and
|
|
40
|
+
* `test/inline-assets.test.ts` rely on this list to enforce security
|
|
41
|
+
* invariants (CSP header, no inline scripts/styles).
|
|
42
|
+
*/
|
|
43
|
+
export const HTML_ENTRYPOINTS = [
|
|
44
|
+
{ urlPath: "/", file: "index.html" },
|
|
45
|
+
{ urlPath: "/login", file: "login.html" },
|
|
46
|
+
// Share viewer — served by share's own dispatcher at /s/:token
|
|
47
|
+
// (see src/share/routes.ts → handleViewerHtml). The urlPath here is a
|
|
48
|
+
// pseudo-path used only by the test invariants (CSP/inline checks); the
|
|
49
|
+
// real route is /s/:token. Keep both views in sync if either changes.
|
|
50
|
+
{ urlPath: "/s", file: "share-viewer.html" },
|
|
51
|
+
];
|
|
52
|
+
/**
|
|
53
|
+
* Strict Content-Security-Policy for HTML responses.
|
|
54
|
+
*
|
|
55
|
+
* - default-src 'self': everything same-origin only
|
|
56
|
+
* - img-src adds data: + blob: for image-upload preview
|
|
57
|
+
* - script-src 'self' 'wasm-unsafe-eval' (no inline; theme bootstrap is
|
|
58
|
+
* /theme-init.js. 'wasm-unsafe-eval' is the narrow CSP3 token that
|
|
59
|
+
* permits WebAssembly.instantiate() WITHOUT re-enabling eval()/Function;
|
|
60
|
+
* needed by any wasm consumer.)
|
|
61
|
+
* - style-src 'self' (no inline; login styles live in /styles.css)
|
|
62
|
+
* - object-src 'none', frame-ancestors 'none', base-uri 'self', form-action 'self'
|
|
63
|
+
* - connect-src 'self' for fetch + EventSource
|
|
64
|
+
*/
|
|
65
|
+
export const CSP_POLICY = [
|
|
66
|
+
"default-src 'self'",
|
|
67
|
+
"img-src 'self' data: blob:",
|
|
68
|
+
"script-src 'self' 'wasm-unsafe-eval'",
|
|
69
|
+
"style-src 'self'",
|
|
70
|
+
"connect-src 'self'",
|
|
71
|
+
"object-src 'none'",
|
|
72
|
+
"frame-ancestors 'none'",
|
|
73
|
+
"base-uri 'self'",
|
|
74
|
+
"form-action 'self'",
|
|
75
|
+
].join("; ");
|
|
22
76
|
/** Read the full request body as a string. */
|
|
23
77
|
function readBody(req) {
|
|
24
78
|
return new Promise((resolve, reject) => {
|
|
25
79
|
const chunks = [];
|
|
26
80
|
req.on("data", (chunk) => chunks.push(chunk));
|
|
27
|
-
req.on("end", () =>
|
|
81
|
+
req.on("end", () => {
|
|
82
|
+
resolve(Buffer.concat(chunks).toString());
|
|
83
|
+
});
|
|
28
84
|
req.on("error", reject);
|
|
29
85
|
});
|
|
30
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* client-server-split M2: idempotency helpers for mutating REST.
|
|
89
|
+
* Frontend generates a UUID per user-initiated action and sends it as
|
|
90
|
+
* `X-Client-Op-Id`. Replays (after SSE/network reconnect) return the
|
|
91
|
+
* cached response instead of re-executing side effects. Missing header →
|
|
92
|
+
* non-idempotent path (back-compat for curl / older clients).
|
|
93
|
+
*/
|
|
94
|
+
function getClientOpId(req) {
|
|
95
|
+
const v = req.headers["x-client-op-id"];
|
|
96
|
+
if (typeof v === "string" && v.length > 0 && v.length <= 128)
|
|
97
|
+
return v;
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
function logPromptRejectBeforeSave(fields) {
|
|
101
|
+
plog.warn("rejected before save", {
|
|
102
|
+
sessionId: fields.sessionId.slice(0, 8),
|
|
103
|
+
status: fields.status,
|
|
104
|
+
reason: fields.reason,
|
|
105
|
+
...(fields.opId ? { opId: fields.opId } : {}),
|
|
106
|
+
...(fields.textLength != null ? { textLength: fields.textLength } : {}),
|
|
107
|
+
...(fields.attachmentCount != null
|
|
108
|
+
? { attachmentCount: fields.attachmentCount }
|
|
109
|
+
: {}),
|
|
110
|
+
...(fields.busyKind ? { busyKind: fields.busyKind } : {}),
|
|
111
|
+
...(fields.error ? { error: fields.error } : {}),
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
function tryReplayClientOp(req, res, store, sessionId) {
|
|
115
|
+
const opId = getClientOpId(req);
|
|
116
|
+
if (!opId)
|
|
117
|
+
return { opId: null, replayed: false };
|
|
118
|
+
const cached = store.getClientOp(sessionId, opId);
|
|
119
|
+
if (cached &&
|
|
120
|
+
typeof cached === "object" &&
|
|
121
|
+
"status" in cached &&
|
|
122
|
+
"body" in cached) {
|
|
123
|
+
json(res, cached.status, cached.body, req);
|
|
124
|
+
return { opId, replayed: true };
|
|
125
|
+
}
|
|
126
|
+
return { opId, replayed: false };
|
|
127
|
+
}
|
|
128
|
+
function saveClientOpResult(store, opId, sessionId, status, body) {
|
|
129
|
+
if (!opId)
|
|
130
|
+
return;
|
|
131
|
+
store.saveClientOp(sessionId, opId, { status, body });
|
|
132
|
+
}
|
|
31
133
|
/** Send a JSON response, gzip-compressed when the client supports it. */
|
|
32
134
|
function json(res, status, data, req) {
|
|
33
135
|
const body = JSON.stringify(data);
|
|
34
|
-
if (req &&
|
|
136
|
+
if (req &&
|
|
137
|
+
body.length > 1024 &&
|
|
138
|
+
(req.headers["accept-encoding"] ?? "").includes("gzip")) {
|
|
35
139
|
const compressed = gzipSync(body);
|
|
36
140
|
res.writeHead(status, {
|
|
37
141
|
"Content-Type": "application/json",
|
|
@@ -45,10 +149,279 @@ function json(res, status, data, req) {
|
|
|
45
149
|
res.end(body);
|
|
46
150
|
}
|
|
47
151
|
}
|
|
152
|
+
/** Per-request principal storage. WeakMap keeps it tied to the request lifetime
|
|
153
|
+
* without monkey-patching IncomingMessage or relying on `any`. */
|
|
154
|
+
const principalByRequest = new WeakMap();
|
|
155
|
+
export function getPrincipal(req) {
|
|
156
|
+
return principalByRequest.get(req);
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Multipart upload handler. Streams the `file` field straight to disk under
|
|
160
|
+
* <data_dir>/sessions/<sid>/attachments/<uuid>.<ext>.tmp, atomic-renames on
|
|
161
|
+
* success, deletes on any failure path. Inserts an attachments row with the
|
|
162
|
+
* resolved realpath so the bridge / permission interceptor can match it
|
|
163
|
+
* later.
|
|
164
|
+
*
|
|
165
|
+
* Wire format expected:
|
|
166
|
+
* - Content-Type: multipart/form-data; boundary=...
|
|
167
|
+
* - One file field named `file` (additional file fields are rejected).
|
|
168
|
+
* - Optional text fields are ignored — displayName comes from the file
|
|
169
|
+
* part's filename header, classification comes from its content-type.
|
|
170
|
+
*/
|
|
171
|
+
async function handleAttachmentUpload(req, res, sessionId, deps) {
|
|
172
|
+
const { store, dataDir, limits, sessions } = deps;
|
|
173
|
+
const fileUploadLimit = limits.file_upload ?? 52_428_800;
|
|
174
|
+
if (!store.getSession(sessionId)) {
|
|
175
|
+
json(res, 404, { error: "Session not found" });
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const dir = join(dataDir, "sessions", sessionId, "attachments");
|
|
179
|
+
await mkdir(dir, { recursive: true });
|
|
180
|
+
const uploadId = randomUUID();
|
|
181
|
+
let tmpPath = null;
|
|
182
|
+
let finalPath = null;
|
|
183
|
+
let bytesWritten = 0;
|
|
184
|
+
let fileMime = "";
|
|
185
|
+
let fileExt = "bin";
|
|
186
|
+
let displayName = null;
|
|
187
|
+
let kind = "file";
|
|
188
|
+
let limit = Math.max(limits.image_upload, fileUploadLimit);
|
|
189
|
+
let limitExceeded = false;
|
|
190
|
+
let sawFile = false;
|
|
191
|
+
let aborted = false;
|
|
192
|
+
let writeError = null;
|
|
193
|
+
let resolved = false;
|
|
194
|
+
// Resolves when the write stream backing the file part has flushed and
|
|
195
|
+
// closed. busboy's `close` can fire before fs has finished writing the
|
|
196
|
+
// last chunk to disk, so we must await this before renaming the .tmp.
|
|
197
|
+
let writeDone = Promise.resolve();
|
|
198
|
+
// Single-shot response. We only ever respond to the request once even
|
|
199
|
+
// though multiple busboy callbacks could converge on the same outcome
|
|
200
|
+
// (e.g. file-too-large + close-after-finish).
|
|
201
|
+
const respond = (status, body) => {
|
|
202
|
+
if (resolved)
|
|
203
|
+
return;
|
|
204
|
+
resolved = true;
|
|
205
|
+
json(res, status, body);
|
|
206
|
+
};
|
|
207
|
+
const cleanupTmp = async () => {
|
|
208
|
+
if (tmpPath) {
|
|
209
|
+
await unlink(tmpPath).catch(() => { });
|
|
210
|
+
tmpPath = null;
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
return new Promise((resolveOuter) => {
|
|
214
|
+
const finish = async (status, body) => {
|
|
215
|
+
respond(status, body);
|
|
216
|
+
resolveOuter();
|
|
217
|
+
};
|
|
218
|
+
let bb;
|
|
219
|
+
try {
|
|
220
|
+
bb = busboy({
|
|
221
|
+
headers: req.headers,
|
|
222
|
+
defParamCharset: "utf8",
|
|
223
|
+
limits: {
|
|
224
|
+
// We enforce the size cap manually via per-file byte tracking
|
|
225
|
+
// (so we can pick the right cap based on classified kind and
|
|
226
|
+
// emit a 413 the moment we cross the line). Cap files = 1
|
|
227
|
+
// and field count = 16 as belt-and-suspenders.
|
|
228
|
+
files: 1,
|
|
229
|
+
fields: 16,
|
|
230
|
+
fieldNameSize: 100,
|
|
231
|
+
fieldSize: 1024,
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
void finish(400, { error: "Invalid multipart" });
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
bb.on("file", (fieldName, stream, info) => {
|
|
240
|
+
if (sawFile) {
|
|
241
|
+
// Extra file part — drain and ignore (busboy `files: 1` should
|
|
242
|
+
// already prevent this, but be defensive).
|
|
243
|
+
stream.resume();
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
sawFile = true;
|
|
247
|
+
if (fieldName !== "file") {
|
|
248
|
+
stream.resume();
|
|
249
|
+
void finish(400, { error: "Unexpected field name" });
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
fileMime = (info.mimeType || "application/octet-stream").toLowerCase();
|
|
253
|
+
kind = classifyKind(fileMime);
|
|
254
|
+
limit = kind === "image" ? limits.image_upload : fileUploadLimit;
|
|
255
|
+
fileExt = mimeToExt(fileMime);
|
|
256
|
+
displayName = info.filename ? normalizeDisplayName(info.filename) : null;
|
|
257
|
+
displayName ??= kind === "image" ? "image" : "file";
|
|
258
|
+
tmpPath = join(dir, `${uploadId}.${fileExt}.tmp`);
|
|
259
|
+
finalPath = join(dir, `${uploadId}.${fileExt}`);
|
|
260
|
+
const ws = createWriteStream(tmpPath);
|
|
261
|
+
writeDone = new Promise((resolveWs) => {
|
|
262
|
+
ws.on("close", () => {
|
|
263
|
+
resolveWs();
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
stream.on("data", (chunk) => {
|
|
267
|
+
bytesWritten += chunk.length;
|
|
268
|
+
if (bytesWritten > limit) {
|
|
269
|
+
limitExceeded = true;
|
|
270
|
+
stream.unpipe(ws);
|
|
271
|
+
ws.destroy();
|
|
272
|
+
stream.resume();
|
|
273
|
+
// Abort the whole busboy pipeline. We respond on `close`.
|
|
274
|
+
req.unpipe(bb);
|
|
275
|
+
req.resume();
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
stream.on("error", (err) => {
|
|
279
|
+
writeError = err;
|
|
280
|
+
});
|
|
281
|
+
ws.on("error", (err) => {
|
|
282
|
+
writeError = err;
|
|
283
|
+
});
|
|
284
|
+
stream.pipe(ws);
|
|
285
|
+
});
|
|
286
|
+
bb.on("error", (err) => {
|
|
287
|
+
writeError = err instanceof Error ? err : new Error(String(err));
|
|
288
|
+
});
|
|
289
|
+
req.on("aborted", () => {
|
|
290
|
+
aborted = true;
|
|
291
|
+
});
|
|
292
|
+
bb.on("close", () => {
|
|
293
|
+
void (async () => {
|
|
294
|
+
try {
|
|
295
|
+
await writeDone;
|
|
296
|
+
if (aborted) {
|
|
297
|
+
await cleanupTmp();
|
|
298
|
+
await finish(400, { error: "Upload aborted" });
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
if (limitExceeded) {
|
|
302
|
+
await cleanupTmp();
|
|
303
|
+
await finish(413, { error: "Upload too large" });
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
if (writeError) {
|
|
307
|
+
await cleanupTmp();
|
|
308
|
+
await finish(500, { error: "Upload failed" });
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
if (!sawFile || !tmpPath || !finalPath || !displayName) {
|
|
312
|
+
await cleanupTmp();
|
|
313
|
+
await finish(400, { error: "Missing file part" });
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
// Sniff the actual mime from file content (magic bytes + UTF-8
|
|
317
|
+
// text fallback). Clients lie about Content-Type — browsers send
|
|
318
|
+
// application/octet-stream for any extension the OS doesn't know
|
|
319
|
+
// (.clj, .lua, .rs, ...), and ACP agents (Copilot CLI) refuse to
|
|
320
|
+
// read attachments tagged octet-stream. The sniffed mime wins
|
|
321
|
+
// silently; we override fileMime / kind / extension / final path
|
|
322
|
+
// before insertAttachment so DB and disk reflect reality.
|
|
323
|
+
const head = await readFile(tmpPath).catch(() => Buffer.alloc(0));
|
|
324
|
+
const headSlice = head.subarray(0, 4096);
|
|
325
|
+
const sniffed = await sniffMime(headSlice);
|
|
326
|
+
if (sniffed !== fileMime) {
|
|
327
|
+
fileMime = sniffed;
|
|
328
|
+
kind = classifyKind(fileMime);
|
|
329
|
+
const newExt = mimeToExt(fileMime);
|
|
330
|
+
if (newExt !== fileExt) {
|
|
331
|
+
fileExt = newExt;
|
|
332
|
+
const newTmp = join(dir, `${uploadId}.${fileExt}.tmp`);
|
|
333
|
+
if (newTmp !== tmpPath) {
|
|
334
|
+
await rename(tmpPath, newTmp);
|
|
335
|
+
tmpPath = newTmp;
|
|
336
|
+
}
|
|
337
|
+
finalPath = join(dir, `${uploadId}.${fileExt}`);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
const imageDimensions = kind === "image" ? readImageDimensions(head) : null;
|
|
341
|
+
await rename(tmpPath, finalPath);
|
|
342
|
+
const rp = await realpath(finalPath);
|
|
343
|
+
const row = store.insertAttachment({
|
|
344
|
+
id: uploadId,
|
|
345
|
+
sessionId,
|
|
346
|
+
kind,
|
|
347
|
+
name: displayName,
|
|
348
|
+
mime: fileMime,
|
|
349
|
+
size: bytesWritten,
|
|
350
|
+
realpath: rp,
|
|
351
|
+
width: imageDimensions?.width ?? null,
|
|
352
|
+
height: imageDimensions?.height ?? null,
|
|
353
|
+
});
|
|
354
|
+
// Invalidate the per-session attachment label cache so the
|
|
355
|
+
// next egress (SSE broadcast or replay) sees this new row.
|
|
356
|
+
sessions?.invalidateLabelCache(sessionId);
|
|
357
|
+
const fileName = `${row.id}.${fileExt}`;
|
|
358
|
+
const basePath = `/api/v1/sessions/${sessionId}/attachments/${fileName}`;
|
|
359
|
+
// 1h signed URL — long enough that the browser holds the rendered
|
|
360
|
+
// image in <img> cache for the full session lifetime, short enough
|
|
361
|
+
// that a leaked URL (screenshot, link share) expires within the day.
|
|
362
|
+
const fileUrl = deps.attachmentSecret
|
|
363
|
+
? `${basePath}?${signAttachmentUrl(basePath, deps.attachmentSecret, 3600)}`
|
|
364
|
+
: basePath;
|
|
365
|
+
await finish(200, {
|
|
366
|
+
attachmentId: row.id,
|
|
367
|
+
displayName: row.name,
|
|
368
|
+
mimeType: row.mime,
|
|
369
|
+
size: row.size,
|
|
370
|
+
width: row.width,
|
|
371
|
+
height: row.height,
|
|
372
|
+
kind: row.kind,
|
|
373
|
+
path: `sessions/${sessionId}/attachments/${fileName}`,
|
|
374
|
+
url: fileUrl,
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
catch (err) {
|
|
378
|
+
await cleanupTmp();
|
|
379
|
+
await finish(500, { error: errorMessage(err) });
|
|
380
|
+
}
|
|
381
|
+
})();
|
|
382
|
+
});
|
|
383
|
+
req.pipe(bb);
|
|
384
|
+
});
|
|
385
|
+
}
|
|
48
386
|
export function createRequestHandler(deps) {
|
|
49
387
|
const { store, sessions, getBridge, sseManager, titleService } = deps;
|
|
388
|
+
// eslint-disable-next-line complexity -- TODO: refactor main route handler into smaller handlers
|
|
50
389
|
return async (req, res) => {
|
|
51
390
|
const url = req.url ?? "/";
|
|
391
|
+
// --- Auth gate: any /api/** outside whitelist requires Bearer ---
|
|
392
|
+
if (deps.authStore && url.startsWith("/api/")) {
|
|
393
|
+
const path = url.split("?")[0] ?? url;
|
|
394
|
+
const method = req.method ?? "GET";
|
|
395
|
+
if (!isWhitelistedPath(method, path)) {
|
|
396
|
+
const result = authenticate(req.headers, deps.authStore);
|
|
397
|
+
if (!result.ok) {
|
|
398
|
+
res.writeHead(401, {
|
|
399
|
+
"Content-Type": "application/json",
|
|
400
|
+
"WWW-Authenticate": "Bearer",
|
|
401
|
+
});
|
|
402
|
+
res.end(JSON.stringify({ error: "Unauthorized", reason: result.reason }));
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
principalByRequest.set(req, result.principal);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
// Share routes — early dispatch so /s/* and /api/v1/shares* claim
|
|
409
|
+
// their URL space before the generic /api/v1 branch. When
|
|
410
|
+
// `shareConfig.enabled === false` handleShareRoutes is a no-op.
|
|
411
|
+
// The auth gate above has already enforced Bearer on owner endpoints
|
|
412
|
+
// (/api/v1/sessions/:id/share*, /api/v1/shares); viewer endpoints
|
|
413
|
+
// (/s/:token, /api/v1/shared/:token/events) must be whitelisted in
|
|
414
|
+
// auth-middleware.ts so they remain public.
|
|
415
|
+
if (deps.shareConfig &&
|
|
416
|
+
(await handleShareRoutes(req, res, {
|
|
417
|
+
store,
|
|
418
|
+
sessions,
|
|
419
|
+
config: deps.shareConfig,
|
|
420
|
+
dataDir: deps.dataDir,
|
|
421
|
+
publicDir: deps.publicDir,
|
|
422
|
+
}))) {
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
52
425
|
// --- API routes ---
|
|
53
426
|
if (url === "/api/v1" || url.startsWith("/api/v1/")) {
|
|
54
427
|
res.setHeader("Content-Type", "application/json");
|
|
@@ -69,7 +442,9 @@ export function createRequestHandler(deps) {
|
|
|
69
442
|
return;
|
|
70
443
|
}
|
|
71
444
|
// GET /api/v1/sessions
|
|
72
|
-
if (url.startsWith("/api/v1/sessions") &&
|
|
445
|
+
if (url.startsWith("/api/v1/sessions") &&
|
|
446
|
+
!url.slice("/api/v1/sessions".length).match(/^\//) &&
|
|
447
|
+
req.method === "GET") {
|
|
73
448
|
const params = new URLSearchParams(url.split("?")[1] ?? "");
|
|
74
449
|
const source = params.get("source") ?? undefined;
|
|
75
450
|
res.end(JSON.stringify(store.listSessions(source ? { source } : undefined)));
|
|
@@ -105,6 +480,136 @@ export function createRequestHandler(deps) {
|
|
|
105
480
|
});
|
|
106
481
|
return;
|
|
107
482
|
}
|
|
483
|
+
// GET /api/v1/auth/verify — token validation probe
|
|
484
|
+
if (url === "/api/v1/auth/verify" && req.method === "GET") {
|
|
485
|
+
const principal = principalByRequest.get(req);
|
|
486
|
+
if (!principal) {
|
|
487
|
+
json(res, 401, { error: "Unauthorized" });
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
json(res, 200, {
|
|
491
|
+
ok: true,
|
|
492
|
+
name: principal.name,
|
|
493
|
+
scope: principal.scope,
|
|
494
|
+
});
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
// POST /api/v1/sse-ticket — mint short-lived ticket for EventSource
|
|
498
|
+
if (url === "/api/v1/sse-ticket" && req.method === "POST") {
|
|
499
|
+
const principal = principalByRequest.get(req);
|
|
500
|
+
if (!principal) {
|
|
501
|
+
json(res, 401, { error: "Unauthorized" });
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
if (!deps.ticketStore) {
|
|
505
|
+
json(res, 501, { error: "SSE not available" });
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
const ticket = deps.ticketStore.mint({
|
|
509
|
+
tokenName: principal.name,
|
|
510
|
+
scope: principal.scope,
|
|
511
|
+
});
|
|
512
|
+
json(res, 200, { ticket, expiresIn: 60 });
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
// --- Token management (admin scope) ---
|
|
516
|
+
// GET /api/v1/tokens — list tokens.
|
|
517
|
+
// admin: full list. api: own token only (self-row, so the slash
|
|
518
|
+
// menu can show the user's own metadata without leaking peers).
|
|
519
|
+
if (url === "/api/v1/tokens" && req.method === "GET") {
|
|
520
|
+
const principal = principalByRequest.get(req);
|
|
521
|
+
if (!deps.authStore || !principal) {
|
|
522
|
+
json(res, 401, { error: "Unauthorized" });
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
const all = deps.authStore.list();
|
|
526
|
+
const visible = principal.scope === "admin"
|
|
527
|
+
? all
|
|
528
|
+
: all.filter((t) => t.name === principal.name);
|
|
529
|
+
const list = visible.map((t) => ({
|
|
530
|
+
name: t.name,
|
|
531
|
+
scope: t.scope,
|
|
532
|
+
createdAt: t.createdAt,
|
|
533
|
+
lastUsedAt: t.lastUsedAt,
|
|
534
|
+
isSelf: t.name === principal.name,
|
|
535
|
+
}));
|
|
536
|
+
json(res, 200, list);
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
// POST /api/v1/tokens — create new api-scope token, return raw value once
|
|
540
|
+
if (url === "/api/v1/tokens" && req.method === "POST") {
|
|
541
|
+
const principal = principalByRequest.get(req);
|
|
542
|
+
if (!deps.authStore || !principal) {
|
|
543
|
+
json(res, 401, { error: "Unauthorized" });
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
if (principal.scope !== "admin") {
|
|
547
|
+
json(res, 403, { error: "Forbidden" });
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
let body;
|
|
551
|
+
try {
|
|
552
|
+
body = JSON.parse(await readBody(req));
|
|
553
|
+
}
|
|
554
|
+
catch {
|
|
555
|
+
json(res, 400, { error: "Invalid JSON" });
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
const name = typeof body.name === "string" ? body.name : "";
|
|
559
|
+
try {
|
|
560
|
+
const created = await deps.authStore.addToken(name, "api");
|
|
561
|
+
json(res, 201, {
|
|
562
|
+
token: created.token,
|
|
563
|
+
name: created.record.name,
|
|
564
|
+
scope: created.record.scope,
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
catch (err) {
|
|
568
|
+
const msg = errorMessage(err);
|
|
569
|
+
if (/already exists|duplicate/i.test(msg)) {
|
|
570
|
+
json(res, 409, { error: msg });
|
|
571
|
+
}
|
|
572
|
+
else {
|
|
573
|
+
json(res, 400, { error: msg });
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
// DELETE /api/v1/tokens/:name — revoke
|
|
579
|
+
const tokenDelMatch = url.match(/^\/api\/v1\/tokens\/([^/?]+)\/?$/);
|
|
580
|
+
if (tokenDelMatch && req.method === "DELETE") {
|
|
581
|
+
const principal = principalByRequest.get(req);
|
|
582
|
+
if (!deps.authStore || !principal) {
|
|
583
|
+
json(res, 401, { error: "Unauthorized" });
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
if (principal.scope !== "admin") {
|
|
587
|
+
json(res, 403, { error: "Forbidden" });
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
const name = decodeURIComponent(tokenDelMatch[1]);
|
|
591
|
+
if (!/^[A-Za-z0-9_-]{1,64}$/.test(name)) {
|
|
592
|
+
json(res, 400, { error: "Invalid token name" });
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
if (name === principal.name) {
|
|
596
|
+
json(res, 400, { error: "Cannot revoke the token you are using" });
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
try {
|
|
600
|
+
const ok = await deps.authStore.revokeToken(name);
|
|
601
|
+
if (!ok) {
|
|
602
|
+
json(res, 404, { error: "Token not found" });
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
res.writeHead(204);
|
|
606
|
+
res.end();
|
|
607
|
+
}
|
|
608
|
+
catch (err) {
|
|
609
|
+
json(res, 400, { error: errorMessage(err) });
|
|
610
|
+
}
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
108
613
|
// --- POST /api/v1/bridge/reload ---
|
|
109
614
|
if (url === "/api/v1/bridge/reload" && req.method === "POST") {
|
|
110
615
|
const bridge = getBridge?.();
|
|
@@ -139,6 +644,9 @@ export function createRequestHandler(deps) {
|
|
|
139
644
|
if (permActionMatch && req.method === "POST") {
|
|
140
645
|
const sessionId = decodeURIComponent(permActionMatch[1]);
|
|
141
646
|
const requestId = decodeURIComponent(permActionMatch[2]);
|
|
647
|
+
const { opId, replayed } = tryReplayClientOp(req, res, store, sessionId);
|
|
648
|
+
if (replayed)
|
|
649
|
+
return;
|
|
142
650
|
const perm = sessions?.pendingPermissions.get(requestId);
|
|
143
651
|
if (!perm) {
|
|
144
652
|
json(res, 404, { error: "Permission not found" });
|
|
@@ -165,25 +673,34 @@ export function createRequestHandler(deps) {
|
|
|
165
673
|
json(res, 400, { error: "Provide optionId or denied:true" });
|
|
166
674
|
return;
|
|
167
675
|
}
|
|
168
|
-
const denied =
|
|
676
|
+
const denied = Boolean(body.denied);
|
|
169
677
|
const optionId = body.optionId ?? "deny";
|
|
170
|
-
const optionName = perm.options.find(o => o.optionId === optionId)?.label ?? optionId;
|
|
678
|
+
const optionName = perm.options.find((o) => o.optionId === optionId)?.label ?? optionId;
|
|
171
679
|
if (denied) {
|
|
172
|
-
|
|
680
|
+
bridge.denyPermission(requestId);
|
|
173
681
|
}
|
|
174
682
|
else {
|
|
175
|
-
|
|
683
|
+
bridge.resolvePermission(requestId, optionId);
|
|
176
684
|
}
|
|
177
685
|
sessions.pendingPermissions.delete(requestId);
|
|
686
|
+
sessions.syncPendingPermissions(sessionId);
|
|
178
687
|
// Store event and broadcast (same type so SSE drops are recoverable via sync)
|
|
179
688
|
const permEventData = { requestId, optionName, denied };
|
|
180
|
-
store.saveEvent(perm.sessionId, "permission_response", { ...permEventData, optionId });
|
|
689
|
+
store.saveEvent(perm.sessionId, "permission_response", { ...permEventData, optionId }, { from_ref: "user" });
|
|
181
690
|
sseManager.broadcast({
|
|
182
691
|
type: "permission_response",
|
|
183
692
|
sessionId: perm.sessionId,
|
|
184
693
|
...permEventData,
|
|
185
694
|
});
|
|
186
|
-
|
|
695
|
+
// Cross-device banner recall: close the permission banner on
|
|
696
|
+
// every subscribed endpoint now that the permission has been
|
|
697
|
+
// handled by this client.
|
|
698
|
+
if (deps.pushService) {
|
|
699
|
+
void deps.pushService.sendClose(`sess-${perm.sessionId}-perm-${requestId}`);
|
|
700
|
+
}
|
|
701
|
+
const okBody = { ok: true };
|
|
702
|
+
saveClientOpResult(store, opId, sessionId, 200, okBody);
|
|
703
|
+
json(res, 200, okBody);
|
|
187
704
|
return;
|
|
188
705
|
}
|
|
189
706
|
// --- POST /api/v1/sessions/:id/cancel ---
|
|
@@ -200,6 +717,9 @@ export function createRequestHandler(deps) {
|
|
|
200
717
|
json(res, 503, { error: "Agent not ready yet" });
|
|
201
718
|
return;
|
|
202
719
|
}
|
|
720
|
+
const { opId, replayed } = tryReplayClientOp(req, res, store, sessionId);
|
|
721
|
+
if (replayed)
|
|
722
|
+
return;
|
|
203
723
|
// Kill running bash process if any
|
|
204
724
|
const proc = sessions?.runningBashProcs.get(sessionId);
|
|
205
725
|
if (proc) {
|
|
@@ -211,7 +731,16 @@ export function createRequestHandler(deps) {
|
|
|
211
731
|
await bridge.cancel(sessionId);
|
|
212
732
|
sessions.activePrompts.delete(sessionId);
|
|
213
733
|
}
|
|
214
|
-
|
|
734
|
+
// Arm backend safety net: if prompt_done doesn't arrive within the
|
|
735
|
+
// configured timeout, force-clear busy so the UI unstalls. Replaces
|
|
736
|
+
// the old frontend-side cancel timer.
|
|
737
|
+
const cancelTimeout = deps.limits.cancel_timeout ?? 0;
|
|
738
|
+
if (sessions && cancelTimeout > 0)
|
|
739
|
+
sessions.state.armCancelSafety(sessionId, cancelTimeout);
|
|
740
|
+
sessions?.syncBusy(sessionId);
|
|
741
|
+
const okBody = { ok: true };
|
|
742
|
+
saveClientOpResult(store, opId, sessionId, 200, okBody);
|
|
743
|
+
json(res, 200, okBody);
|
|
215
744
|
return;
|
|
216
745
|
}
|
|
217
746
|
// --- GET /api/v1/sessions/:id/status ---
|
|
@@ -232,35 +761,112 @@ export function createRequestHandler(deps) {
|
|
|
232
761
|
});
|
|
233
762
|
return;
|
|
234
763
|
}
|
|
764
|
+
// --- GET /api/v1/sessions/:id/snapshot ---
|
|
765
|
+
// client-server-split M1: single source of truth for "what state is
|
|
766
|
+
// this session in right now". Frontend calls this on connect / reconnect
|
|
767
|
+
// / after long backgrounding, then applies incremental `state_patch`
|
|
768
|
+
// SSE events.
|
|
769
|
+
const snapshotMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/snapshot\/?$/);
|
|
770
|
+
if (snapshotMatch && req.method === "GET") {
|
|
771
|
+
const sessionId = decodeURIComponent(snapshotMatch[1]);
|
|
772
|
+
const session = store.getSession(sessionId);
|
|
773
|
+
if (!session) {
|
|
774
|
+
json(res, 404, { error: "Session not found" });
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
if (!sessions) {
|
|
778
|
+
json(res, 503, { error: "Session manager not available" });
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
// Make sure runtime reflects the current activePrompts/bash state even
|
|
782
|
+
// if no patch has been emitted yet for this session.
|
|
783
|
+
sessions.syncBusy(sessionId);
|
|
784
|
+
sessions.syncPendingPermissions(sessionId);
|
|
785
|
+
const runtimeState = sessions.state.getState(sessionId);
|
|
786
|
+
const lastEventSeq = store.getLastEventSeq(sessionId);
|
|
787
|
+
json(res, 200, {
|
|
788
|
+
version: 1,
|
|
789
|
+
seq: runtimeState.seq,
|
|
790
|
+
session: {
|
|
791
|
+
id: session.id,
|
|
792
|
+
title: session.title,
|
|
793
|
+
cwd: session.cwd,
|
|
794
|
+
model: session.model,
|
|
795
|
+
mode: session.mode,
|
|
796
|
+
createdAt: session.created_at,
|
|
797
|
+
lastEventSeq,
|
|
798
|
+
},
|
|
799
|
+
runtime: runtimeState.runtime,
|
|
800
|
+
}, req);
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
235
803
|
// --- POST /api/v1/sessions/:id/prompt ---
|
|
236
804
|
const promptMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/prompt\/?(\?.*)?$/);
|
|
237
805
|
if (promptMatch && req.method === "POST") {
|
|
238
806
|
const sessionId = decodeURIComponent(promptMatch[1]);
|
|
807
|
+
const requestOpId = getClientOpId(req);
|
|
239
808
|
const session = store.getSession(sessionId);
|
|
240
809
|
if (!session) {
|
|
810
|
+
logPromptRejectBeforeSave({
|
|
811
|
+
sessionId,
|
|
812
|
+
status: 404,
|
|
813
|
+
reason: "session_not_found",
|
|
814
|
+
opId: requestOpId,
|
|
815
|
+
});
|
|
241
816
|
json(res, 404, { error: "Session not found" });
|
|
242
817
|
return;
|
|
243
818
|
}
|
|
244
819
|
const bridge = getBridge?.();
|
|
245
820
|
if (!bridge) {
|
|
821
|
+
logPromptRejectBeforeSave({
|
|
822
|
+
sessionId,
|
|
823
|
+
status: 503,
|
|
824
|
+
reason: "agent_not_ready",
|
|
825
|
+
opId: requestOpId,
|
|
826
|
+
});
|
|
246
827
|
json(res, 503, { error: "Agent not ready yet" });
|
|
247
828
|
return;
|
|
248
829
|
}
|
|
249
830
|
if (!sessions) {
|
|
831
|
+
logPromptRejectBeforeSave({
|
|
832
|
+
sessionId,
|
|
833
|
+
status: 503,
|
|
834
|
+
reason: "session_manager_unavailable",
|
|
835
|
+
opId: requestOpId,
|
|
836
|
+
});
|
|
250
837
|
json(res, 503, { error: "Session manager not available" });
|
|
251
838
|
return;
|
|
252
839
|
}
|
|
840
|
+
const { opId, replayed } = tryReplayClientOp(req, res, store, sessionId);
|
|
841
|
+
if (replayed)
|
|
842
|
+
return;
|
|
253
843
|
// Ensure session is live in ACP before prompting (awaits in-flight resume)
|
|
254
844
|
try {
|
|
255
845
|
await sessions.ensureResumed(bridge, sessionId);
|
|
256
846
|
}
|
|
257
847
|
catch (err) {
|
|
258
|
-
|
|
848
|
+
logPromptRejectBeforeSave({
|
|
849
|
+
sessionId,
|
|
850
|
+
status: 500,
|
|
851
|
+
reason: "resume_failed",
|
|
852
|
+
opId,
|
|
853
|
+
error: errorMessage(err),
|
|
854
|
+
});
|
|
855
|
+
json(res, 500, {
|
|
856
|
+
error: `Failed to resume session: ${err instanceof Error ? err.message : String(err)}`,
|
|
857
|
+
});
|
|
259
858
|
return;
|
|
260
859
|
}
|
|
261
860
|
// Check if session is busy
|
|
262
861
|
const busyKind = sessions.getBusyKind(sessionId);
|
|
263
862
|
if (busyKind) {
|
|
863
|
+
logPromptRejectBeforeSave({
|
|
864
|
+
sessionId,
|
|
865
|
+
status: 409,
|
|
866
|
+
reason: "session_busy",
|
|
867
|
+
opId,
|
|
868
|
+
busyKind,
|
|
869
|
+
});
|
|
264
870
|
json(res, 409, { error: "Session is busy", busyKind });
|
|
265
871
|
return;
|
|
266
872
|
}
|
|
@@ -269,35 +875,152 @@ export function createRequestHandler(deps) {
|
|
|
269
875
|
body = JSON.parse(await readBody(req));
|
|
270
876
|
}
|
|
271
877
|
catch {
|
|
878
|
+
logPromptRejectBeforeSave({
|
|
879
|
+
sessionId,
|
|
880
|
+
status: 400,
|
|
881
|
+
reason: "invalid_json",
|
|
882
|
+
opId,
|
|
883
|
+
});
|
|
272
884
|
json(res, 400, { error: "Invalid JSON" });
|
|
273
885
|
return;
|
|
274
886
|
}
|
|
275
887
|
if (!body.text) {
|
|
888
|
+
logPromptRejectBeforeSave({
|
|
889
|
+
sessionId,
|
|
890
|
+
status: 400,
|
|
891
|
+
reason: "missing_text",
|
|
892
|
+
opId,
|
|
893
|
+
textLength: 0,
|
|
894
|
+
attachmentCount: Array.isArray(body.attachments)
|
|
895
|
+
? body.attachments.length
|
|
896
|
+
: undefined,
|
|
897
|
+
});
|
|
276
898
|
json(res, 400, { error: "Missing required field: text" });
|
|
277
899
|
return;
|
|
278
900
|
}
|
|
279
|
-
//
|
|
280
|
-
|
|
281
|
-
|
|
901
|
+
// Validate attachment shape: client must NEVER supply uri/data/path,
|
|
902
|
+
// only the four canonical fields. Anything else → 400 immediately so
|
|
903
|
+
// we don't even hand it to the dispatcher fallback.
|
|
904
|
+
const attachments = body.attachments;
|
|
905
|
+
if (attachments) {
|
|
906
|
+
if (!Array.isArray(attachments)) {
|
|
907
|
+
logPromptRejectBeforeSave({
|
|
908
|
+
sessionId,
|
|
909
|
+
status: 400,
|
|
910
|
+
reason: "attachments_not_array",
|
|
911
|
+
opId,
|
|
912
|
+
textLength: body.text.length,
|
|
913
|
+
});
|
|
914
|
+
json(res, 400, { error: "attachments must be an array" });
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
for (const raw of attachments) {
|
|
918
|
+
const att = raw;
|
|
919
|
+
if (!att ||
|
|
920
|
+
typeof att !== "object" ||
|
|
921
|
+
(att.kind !== "image" && att.kind !== "file") ||
|
|
922
|
+
typeof att.attachmentId !== "string" ||
|
|
923
|
+
typeof att.displayName !== "string" ||
|
|
924
|
+
typeof att.mimeType !== "string") {
|
|
925
|
+
logPromptRejectBeforeSave({
|
|
926
|
+
sessionId,
|
|
927
|
+
status: 400,
|
|
928
|
+
reason: "invalid_attachment_entry",
|
|
929
|
+
opId,
|
|
930
|
+
textLength: body.text.length,
|
|
931
|
+
attachmentCount: attachments.length,
|
|
932
|
+
});
|
|
933
|
+
json(res, 400, { error: "Invalid attachment entry" });
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
936
|
+
if (typeof att.uri === "string" ||
|
|
937
|
+
typeof att.data === "string" ||
|
|
938
|
+
typeof att.path === "string" ||
|
|
939
|
+
typeof att.width === "number" ||
|
|
940
|
+
typeof att.height === "number") {
|
|
941
|
+
logPromptRejectBeforeSave({
|
|
942
|
+
sessionId,
|
|
943
|
+
status: 400,
|
|
944
|
+
reason: "client_supplied_attachment_data",
|
|
945
|
+
opId,
|
|
946
|
+
textLength: body.text.length,
|
|
947
|
+
attachmentCount: attachments.length,
|
|
948
|
+
});
|
|
949
|
+
json(res, 400, {
|
|
950
|
+
error: "Client must not supply uri/data/path/width/height",
|
|
951
|
+
});
|
|
952
|
+
return;
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
// Stored shape mirrors the wire shape PLUS a server-derived `path`
|
|
957
|
+
// for renderers. The path is the unsigned base URL
|
|
958
|
+
// (`/api/v1/sessions/<sid>/attachments/<filename>`); reSign on
|
|
959
|
+
// egress (history GET + SSE broadcast) appends a fresh `?sig=&exp=`.
|
|
960
|
+
// Renderers use it to mount `<img>` (kind=image) or `<a>` (kind=file).
|
|
961
|
+
// Refs whose attachment row is missing are dropped (defense — the
|
|
962
|
+
// dispatcher's [attachment removed] fallback covers that case).
|
|
963
|
+
const storedAttachments = attachments?.flatMap((a) => {
|
|
964
|
+
const row = store.getAttachment(sessionId, a.attachmentId);
|
|
965
|
+
if (!row)
|
|
966
|
+
return [];
|
|
967
|
+
const fileName = basename(row.realpath);
|
|
968
|
+
return [
|
|
969
|
+
{
|
|
970
|
+
kind: a.kind,
|
|
971
|
+
attachmentId: a.attachmentId,
|
|
972
|
+
displayName: a.displayName,
|
|
973
|
+
mimeType: a.mimeType,
|
|
974
|
+
path: `/api/v1/sessions/${sessionId}/attachments/${fileName}`,
|
|
975
|
+
...(row.width != null && row.height != null
|
|
976
|
+
? { width: row.width, height: row.height }
|
|
977
|
+
: {}),
|
|
978
|
+
},
|
|
979
|
+
];
|
|
980
|
+
});
|
|
981
|
+
store.saveEvent(sessionId, "user_message", {
|
|
982
|
+
text: body.text,
|
|
983
|
+
...(storedAttachments?.length
|
|
984
|
+
? { attachments: storedAttachments }
|
|
985
|
+
: {}),
|
|
986
|
+
}, { from_ref: "user" });
|
|
282
987
|
store.updateSessionLastActive(sessionId);
|
|
283
988
|
store.touchRecentPath(session.cwd);
|
|
284
|
-
const userMsgEvent = {
|
|
989
|
+
const userMsgEvent = {
|
|
990
|
+
type: "user_message",
|
|
991
|
+
sessionId,
|
|
992
|
+
text: body.text,
|
|
993
|
+
attachments: storedAttachments,
|
|
994
|
+
};
|
|
285
995
|
sseManager.broadcast(userMsgEvent);
|
|
286
996
|
// Generate title (fire-and-forget)
|
|
287
|
-
if (titleService &&
|
|
997
|
+
if (titleService &&
|
|
998
|
+
sessions && // eslint-disable-line @typescript-eslint/no-unnecessary-condition -- optional dep
|
|
999
|
+
!sessions.sessionHasTitle.has(sessionId)) {
|
|
288
1000
|
titleService.generate(bridge, body.text, sessionId, (title) => {
|
|
289
|
-
const titleEvent = {
|
|
1001
|
+
const titleEvent = {
|
|
1002
|
+
type: "session_title_updated",
|
|
1003
|
+
sessionId,
|
|
1004
|
+
title,
|
|
1005
|
+
};
|
|
290
1006
|
sseManager.broadcast(titleEvent);
|
|
291
1007
|
});
|
|
292
1008
|
}
|
|
293
1009
|
// Fire prompt asynchronously (don't await — response is 202)
|
|
294
1010
|
sessions.activePrompts.add(sessionId);
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
1011
|
+
sessions.syncBusy(sessionId);
|
|
1012
|
+
bridge
|
|
1013
|
+
.prompt(sessionId, body.text, attachments)
|
|
1014
|
+
.catch((err) => {
|
|
1015
|
+
plog.error("error", { sessionId, error: err });
|
|
1016
|
+
})
|
|
1017
|
+
.finally(() => {
|
|
298
1018
|
sessions.activePrompts.delete(sessionId);
|
|
1019
|
+
sessions.syncBusy(sessionId);
|
|
299
1020
|
});
|
|
300
|
-
|
|
1021
|
+
const acceptedBody = { status: "accepted" };
|
|
1022
|
+
saveClientOpResult(store, opId, sessionId, 202, acceptedBody);
|
|
1023
|
+
json(res, 202, acceptedBody);
|
|
301
1024
|
return;
|
|
302
1025
|
}
|
|
303
1026
|
// --- POST /api/v1/sessions/:id/bash ---
|
|
@@ -314,7 +1037,9 @@ export function createRequestHandler(deps) {
|
|
|
314
1037
|
return;
|
|
315
1038
|
}
|
|
316
1039
|
if (sessions.runningBashProcs.has(sessionId)) {
|
|
317
|
-
json(res, 409, {
|
|
1040
|
+
json(res, 409, {
|
|
1041
|
+
error: "A bash command is already running in this session",
|
|
1042
|
+
});
|
|
318
1043
|
return;
|
|
319
1044
|
}
|
|
320
1045
|
let body;
|
|
@@ -330,11 +1055,19 @@ export function createRequestHandler(deps) {
|
|
|
330
1055
|
return;
|
|
331
1056
|
}
|
|
332
1057
|
const cwd = sessions.getSessionCwd(sessionId);
|
|
333
|
-
store.saveEvent(sessionId, "bash_command", { command: body.command });
|
|
334
|
-
const bashCmdEvent = {
|
|
1058
|
+
store.saveEvent(sessionId, "bash_command", { command: body.command }, { from_ref: "user" });
|
|
1059
|
+
const bashCmdEvent = {
|
|
1060
|
+
type: "bash_command",
|
|
1061
|
+
sessionId,
|
|
1062
|
+
command: body.command,
|
|
1063
|
+
};
|
|
335
1064
|
sseManager.broadcast(bashCmdEvent);
|
|
336
|
-
const shell = IS_WIN
|
|
337
|
-
|
|
1065
|
+
const shell = IS_WIN
|
|
1066
|
+
? (process.env.COMSPEC ?? "cmd.exe")
|
|
1067
|
+
: (process.env.SHELL ?? "bash");
|
|
1068
|
+
const shellArgs = IS_WIN
|
|
1069
|
+
? ["/s", "/c", body.command]
|
|
1070
|
+
: ["-c", body.command];
|
|
338
1071
|
const child = spawn(shell, shellArgs, {
|
|
339
1072
|
cwd,
|
|
340
1073
|
detached: !IS_WIN,
|
|
@@ -342,6 +1075,7 @@ export function createRequestHandler(deps) {
|
|
|
342
1075
|
stdio: ["ignore", "pipe", "pipe"],
|
|
343
1076
|
});
|
|
344
1077
|
sessions.runningBashProcs.set(sessionId, child);
|
|
1078
|
+
sessions.syncBusy(sessionId);
|
|
345
1079
|
let output = "";
|
|
346
1080
|
let outputTruncated = false;
|
|
347
1081
|
const limit = deps.limits.bash_output;
|
|
@@ -357,23 +1091,41 @@ export function createRequestHandler(deps) {
|
|
|
357
1091
|
else {
|
|
358
1092
|
output = (output + text).slice(-limit);
|
|
359
1093
|
}
|
|
360
|
-
const bashOutEvent = {
|
|
1094
|
+
const bashOutEvent = {
|
|
1095
|
+
type: "bash_output",
|
|
1096
|
+
sessionId,
|
|
1097
|
+
text,
|
|
1098
|
+
stream,
|
|
1099
|
+
};
|
|
361
1100
|
sseManager.broadcast(bashOutEvent);
|
|
362
1101
|
};
|
|
363
1102
|
child.stdout.on("data", onData("stdout"));
|
|
364
1103
|
child.stderr.on("data", onData("stderr"));
|
|
365
1104
|
child.on("close", (code, signal) => {
|
|
366
1105
|
sessions.runningBashProcs.delete(sessionId);
|
|
1106
|
+
sessions.syncBusy(sessionId);
|
|
367
1107
|
const stored = outputTruncated ? "[truncated]\n" + output : output;
|
|
368
|
-
store.saveEvent(sessionId, "bash_result", { output: stored, code, signal });
|
|
369
|
-
const bashDoneEvent = {
|
|
1108
|
+
store.saveEvent(sessionId, "bash_result", { output: stored, code, signal }, { from_ref: "system" });
|
|
1109
|
+
const bashDoneEvent = {
|
|
1110
|
+
type: "bash_done",
|
|
1111
|
+
sessionId,
|
|
1112
|
+
code,
|
|
1113
|
+
signal,
|
|
1114
|
+
};
|
|
370
1115
|
sseManager.broadcast(bashDoneEvent);
|
|
371
1116
|
});
|
|
372
1117
|
child.on("error", (err) => {
|
|
373
1118
|
sessions.runningBashProcs.delete(sessionId);
|
|
1119
|
+
sessions.syncBusy(sessionId);
|
|
374
1120
|
const errMsg = errorMessage(err);
|
|
375
|
-
store.saveEvent(sessionId, "bash_result", { output: errMsg, code: -1, signal: null });
|
|
376
|
-
const bashErrEvent = {
|
|
1121
|
+
store.saveEvent(sessionId, "bash_result", { output: errMsg, code: -1, signal: null }, { from_ref: "system" });
|
|
1122
|
+
const bashErrEvent = {
|
|
1123
|
+
type: "bash_done",
|
|
1124
|
+
sessionId,
|
|
1125
|
+
code: -1,
|
|
1126
|
+
signal: null,
|
|
1127
|
+
error: errMsg,
|
|
1128
|
+
};
|
|
377
1129
|
sseManager.broadcast(bashErrEvent);
|
|
378
1130
|
});
|
|
379
1131
|
json(res, 202, { status: "accepted" });
|
|
@@ -425,12 +1177,23 @@ export function createRequestHandler(deps) {
|
|
|
425
1177
|
for (const opt of configOptions) {
|
|
426
1178
|
store.updateSessionConfig(sessionId, opt.id, opt.currentValue);
|
|
427
1179
|
}
|
|
428
|
-
sseManager.broadcast({
|
|
429
|
-
|
|
1180
|
+
sseManager.broadcast({
|
|
1181
|
+
type: "config_option_update",
|
|
1182
|
+
sessionId,
|
|
1183
|
+
configOptions,
|
|
1184
|
+
});
|
|
1185
|
+
sseManager.broadcast({
|
|
1186
|
+
type: "config_set",
|
|
1187
|
+
sessionId,
|
|
1188
|
+
configId,
|
|
1189
|
+
value: body.value,
|
|
1190
|
+
});
|
|
430
1191
|
json(res, 200, { configOptions });
|
|
431
1192
|
}
|
|
432
1193
|
catch (err) {
|
|
433
|
-
json(res, 500, {
|
|
1194
|
+
json(res, 500, {
|
|
1195
|
+
error: `Failed to set ${configId}: ${err instanceof Error ? err.message : String(err)}`,
|
|
1196
|
+
});
|
|
434
1197
|
}
|
|
435
1198
|
return;
|
|
436
1199
|
}
|
|
@@ -460,8 +1223,12 @@ export function createRequestHandler(deps) {
|
|
|
460
1223
|
sessions.sessionHasTitle.add(sessionId);
|
|
461
1224
|
const bridge = getBridge?.();
|
|
462
1225
|
if (titleService && bridge)
|
|
463
|
-
titleService.cancel(sessionId, bridge);
|
|
464
|
-
const titleEvent = {
|
|
1226
|
+
void titleService.cancel(sessionId, bridge);
|
|
1227
|
+
const titleEvent = {
|
|
1228
|
+
type: "session_title_updated",
|
|
1229
|
+
sessionId,
|
|
1230
|
+
title: body.value,
|
|
1231
|
+
};
|
|
465
1232
|
sseManager.broadcast(titleEvent);
|
|
466
1233
|
json(res, 200, { title: body.value });
|
|
467
1234
|
return;
|
|
@@ -479,53 +1246,112 @@ export function createRequestHandler(deps) {
|
|
|
479
1246
|
json(res, 404, { error: "Session not found" });
|
|
480
1247
|
return;
|
|
481
1248
|
}
|
|
482
|
-
// Start resume in background (non-blocking) so the client gets metadata fast
|
|
1249
|
+
// Start resume in background (non-blocking) so the client gets metadata fast.
|
|
1250
|
+
// BUT: if cache is cold (cold-restart case), block on resume up to 8s so
|
|
1251
|
+
// configOptions returns inline. This eliminates the race where the
|
|
1252
|
+
// post-resume `config_option_update` broadcast can arrive before the
|
|
1253
|
+
// client's SSE is fully wired up — a flake observed on slow CI runners.
|
|
483
1254
|
const wasLive = sessions?.liveSessions.has(sessionId) ?? true;
|
|
1255
|
+
let resumePromise = null;
|
|
484
1256
|
if (sessions && getBridge && !wasLive) {
|
|
485
1257
|
const bridge = getBridge();
|
|
486
1258
|
if (bridge) {
|
|
487
|
-
|
|
1259
|
+
resumePromise = sessions.ensureResumed(bridge, sessionId);
|
|
1260
|
+
// Broadcast config_option_update too, so any OTHER client viewing
|
|
1261
|
+
// the same session (whose own GET may have raced and returned cold)
|
|
1262
|
+
// also picks up the warm values.
|
|
1263
|
+
resumePromise
|
|
1264
|
+
.then(() => {
|
|
1265
|
+
const cur = store.getSession(sessionId);
|
|
1266
|
+
if (!cur || !sessions.cachedConfigOptions.length)
|
|
1267
|
+
return;
|
|
1268
|
+
const opts = sessions.cachedConfigOptions.map((opt) => {
|
|
1269
|
+
const stored = {
|
|
1270
|
+
model: cur.model,
|
|
1271
|
+
mode: cur.mode,
|
|
1272
|
+
reasoning_effort: cur.reasoning_effort,
|
|
1273
|
+
};
|
|
1274
|
+
const override = stored[opt.id];
|
|
1275
|
+
return override ? { ...opt, currentValue: override } : opt;
|
|
1276
|
+
});
|
|
1277
|
+
sseManager.broadcast({
|
|
1278
|
+
type: "config_option_update",
|
|
1279
|
+
sessionId,
|
|
1280
|
+
configOptions: opts,
|
|
1281
|
+
});
|
|
1282
|
+
})
|
|
1283
|
+
.catch(() => { });
|
|
488
1284
|
// Auto-retry if the last turn was interrupted (must wait for resume)
|
|
489
1285
|
const hasInterrupted = store.hasInterruptedTurn(sessionId);
|
|
490
1286
|
if (hasInterrupted) {
|
|
491
1287
|
// Optimistically mark busy so concurrent POST sees the session as active
|
|
492
1288
|
sessions.activePrompts.add(sessionId);
|
|
493
|
-
|
|
1289
|
+
sessions.syncBusy(sessionId);
|
|
1290
|
+
void resumePromise
|
|
1291
|
+
.then(() => {
|
|
494
1292
|
if (!sessions.autoRetryIfNeeded(bridge, sessionId)) {
|
|
495
1293
|
// Retry not needed after all — release the optimistic lock
|
|
496
1294
|
sessions.activePrompts.delete(sessionId);
|
|
1295
|
+
sessions.syncBusy(sessionId);
|
|
497
1296
|
}
|
|
498
|
-
})
|
|
1297
|
+
})
|
|
1298
|
+
.catch(() => {
|
|
499
1299
|
sessions.activePrompts.delete(sessionId);
|
|
1300
|
+
sessions.syncBusy(sessionId);
|
|
500
1301
|
});
|
|
501
1302
|
}
|
|
502
1303
|
else {
|
|
503
1304
|
resumePromise.catch((err) => {
|
|
504
|
-
|
|
1305
|
+
slog.error("background resume failed", {
|
|
1306
|
+
sessionId: sessionId.slice(0, 8) + "…",
|
|
1307
|
+
error: err,
|
|
1308
|
+
});
|
|
505
1309
|
});
|
|
506
1310
|
}
|
|
507
1311
|
}
|
|
508
1312
|
}
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
1313
|
+
// If cache is cold and we kicked off a resume, wait briefly so the
|
|
1314
|
+
// GET response includes warm configOptions. Bounded to 8s — past that
|
|
1315
|
+
// we fall back to returning empty configOptions and rely on the
|
|
1316
|
+
// broadcast above. The frontend retries (re-fetches) on broadcast.
|
|
1317
|
+
if (resumePromise && !sessions?.cachedConfigOptions.length) {
|
|
1318
|
+
let timer = null;
|
|
1319
|
+
const timeoutPromise = new Promise((resolve) => {
|
|
1320
|
+
timer = setTimeout(resolve, 8000);
|
|
515
1321
|
});
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
1322
|
+
await Promise.race([
|
|
1323
|
+
resumePromise.finally(() => {
|
|
1324
|
+
if (timer)
|
|
1325
|
+
clearTimeout(timer);
|
|
1326
|
+
}),
|
|
1327
|
+
timeoutPromise,
|
|
1328
|
+
]).catch(() => { });
|
|
1329
|
+
}
|
|
1330
|
+
// Re-read session in case resume mutated stored config
|
|
1331
|
+
const freshSession = store.getSession(sessionId) ?? session;
|
|
1332
|
+
const configOptions = sessions
|
|
1333
|
+
? (() => {
|
|
1334
|
+
// Build configOptions from cached + stored overrides
|
|
1335
|
+
const opts = sessions.cachedConfigOptions.map((opt) => {
|
|
1336
|
+
const stored = {
|
|
1337
|
+
model: freshSession.model,
|
|
1338
|
+
mode: freshSession.mode,
|
|
1339
|
+
reasoning_effort: freshSession.reasoning_effort,
|
|
1340
|
+
};
|
|
1341
|
+
const override = stored[opt.id];
|
|
1342
|
+
return override ? { ...opt, currentValue: override } : opt;
|
|
1343
|
+
});
|
|
1344
|
+
return opts;
|
|
1345
|
+
})()
|
|
1346
|
+
: [];
|
|
519
1347
|
json(res, 200, {
|
|
520
|
-
id:
|
|
521
|
-
cwd:
|
|
522
|
-
title:
|
|
523
|
-
source:
|
|
524
|
-
model:
|
|
525
|
-
mode:
|
|
1348
|
+
id: freshSession.id,
|
|
1349
|
+
cwd: freshSession.cwd,
|
|
1350
|
+
title: freshSession.title,
|
|
1351
|
+
source: freshSession.source,
|
|
1352
|
+
model: freshSession.model,
|
|
1353
|
+
mode: freshSession.mode,
|
|
526
1354
|
configOptions,
|
|
527
|
-
busy: busyKind != null,
|
|
528
|
-
busyKind,
|
|
529
1355
|
}, req);
|
|
530
1356
|
return;
|
|
531
1357
|
}
|
|
@@ -582,7 +1408,11 @@ export function createRequestHandler(deps) {
|
|
|
582
1408
|
// ACP's session_created event fires before inheritance runs, so
|
|
583
1409
|
// broadcast final configOptions so SSE clients get the inherited values.
|
|
584
1410
|
if (configOptions.length) {
|
|
585
|
-
sseManager.broadcast({
|
|
1411
|
+
sseManager.broadcast({
|
|
1412
|
+
type: "config_option_update",
|
|
1413
|
+
sessionId,
|
|
1414
|
+
configOptions,
|
|
1415
|
+
});
|
|
586
1416
|
}
|
|
587
1417
|
json(res, 201, {
|
|
588
1418
|
id: sessionId,
|
|
@@ -607,14 +1437,17 @@ export function createRequestHandler(deps) {
|
|
|
607
1437
|
const eventsMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/events(\?.*)?$/);
|
|
608
1438
|
if (eventsMatch && req.method === "GET") {
|
|
609
1439
|
const sessionId = decodeURIComponent(eventsMatch[1]);
|
|
610
|
-
const
|
|
1440
|
+
const queryPart = eventsMatch[2];
|
|
1441
|
+
const params = new URLSearchParams(queryPart ? queryPart.slice(1) : "");
|
|
611
1442
|
const excludeThinking = params.get("thinking") === "0";
|
|
612
1443
|
const afterRaw = params.get("after");
|
|
613
1444
|
const afterSeq = afterRaw != null ? Number(afterRaw) : undefined;
|
|
614
1445
|
const beforeRaw = params.get("before");
|
|
615
1446
|
const beforeSeq = beforeRaw != null ? Number(beforeRaw) : undefined;
|
|
616
1447
|
const limitRaw = params.get("limit");
|
|
617
|
-
const limit = limitRaw != null
|
|
1448
|
+
const limit = limitRaw != null
|
|
1449
|
+
? Math.max(1, Math.min(10000, Number(limitRaw)))
|
|
1450
|
+
: undefined;
|
|
618
1451
|
const session = store.getSession(sessionId);
|
|
619
1452
|
if (!session) {
|
|
620
1453
|
json(res, 404, { error: "Session not found" });
|
|
@@ -635,15 +1468,45 @@ export function createRequestHandler(deps) {
|
|
|
635
1468
|
sessions.flushAssistantBuffer(sessionId);
|
|
636
1469
|
}
|
|
637
1470
|
}
|
|
638
|
-
const events = store.getEvents(sessionId, {
|
|
1471
|
+
const events = store.getEvents(sessionId, {
|
|
1472
|
+
excludeThinking,
|
|
1473
|
+
afterSeq,
|
|
1474
|
+
beforeSeq,
|
|
1475
|
+
limit,
|
|
1476
|
+
});
|
|
1477
|
+
// Replace internal uuid attachment paths with `<name> [#<id4>]`
|
|
1478
|
+
// labels at egress (CLAUDE.md "Attachment label egress
|
|
1479
|
+
// rewrite"). DB rows still hold raw paths.
|
|
1480
|
+
if (sessions) {
|
|
1481
|
+
enrichStoredEventsForDisplay(events, sessions.getLabelMap(sessionId));
|
|
1482
|
+
}
|
|
1483
|
+
// Re-sign image URLs at egress so 1h-old stored URLs become valid
|
|
1484
|
+
// again — the user can reload history days later and images still
|
|
1485
|
+
// resolve. Mutates `data` in-place; safe because store.getEvents
|
|
1486
|
+
// returns fresh records.
|
|
1487
|
+
if (deps.attachmentSecret) {
|
|
1488
|
+
for (const ev of events) {
|
|
1489
|
+
if (typeof ev.data === "string" &&
|
|
1490
|
+
ev.data.includes("/attachments/")) {
|
|
1491
|
+
ev.data = reSignAttachmentUrlsInJson(ev.data, deps.attachmentSecret);
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
639
1495
|
const envelope = {
|
|
640
1496
|
events,
|
|
641
|
-
streaming: {
|
|
1497
|
+
streaming: {
|
|
1498
|
+
thinking: streamingThinking,
|
|
1499
|
+
assistant: streamingAssistant,
|
|
1500
|
+
},
|
|
642
1501
|
};
|
|
643
1502
|
if (limit != null) {
|
|
644
1503
|
const total = store.getEventCount(sessionId, { excludeThinking });
|
|
645
1504
|
const hasMore = events.length > 0
|
|
646
|
-
?
|
|
1505
|
+
? store.getEvents(sessionId, {
|
|
1506
|
+
excludeThinking,
|
|
1507
|
+
beforeSeq: events[0].seq,
|
|
1508
|
+
limit: 1,
|
|
1509
|
+
}).length > 0
|
|
647
1510
|
: false;
|
|
648
1511
|
envelope.total = total;
|
|
649
1512
|
envelope.hasMore = hasMore;
|
|
@@ -654,32 +1517,54 @@ export function createRequestHandler(deps) {
|
|
|
654
1517
|
// --- SSE stream endpoints ---
|
|
655
1518
|
// GET /api/v1/events/stream — global SSE stream
|
|
656
1519
|
if (url.startsWith("/api/v1/events/stream") && req.method === "GET") {
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
1520
|
+
// Ticket-based auth (EventSource cannot send Authorization header).
|
|
1521
|
+
// When authStore is configured, every SSE connection MUST present a
|
|
1522
|
+
// valid single-use ticket from POST /api/v1/sse-ticket.
|
|
1523
|
+
let tokenName;
|
|
1524
|
+
if (deps.authStore) {
|
|
1525
|
+
const ticket = new URLSearchParams(url.split("?")[1] ?? "").get("ticket") ?? "";
|
|
1526
|
+
const principal = deps.ticketStore?.consume(ticket);
|
|
1527
|
+
if (!principal) {
|
|
1528
|
+
json(res, 401, { error: "Invalid or expired ticket" });
|
|
1529
|
+
return;
|
|
1530
|
+
}
|
|
1531
|
+
tokenName = principal.tokenName;
|
|
660
1532
|
}
|
|
661
|
-
const sseManager = deps.sseManager;
|
|
662
1533
|
const clientId = sseManager.generateClientId();
|
|
663
1534
|
res.writeHead(200, {
|
|
664
1535
|
"Content-Type": "text/event-stream",
|
|
665
1536
|
"Cache-Control": "no-cache",
|
|
666
|
-
|
|
1537
|
+
Connection: "keep-alive",
|
|
667
1538
|
});
|
|
668
|
-
const client = {
|
|
1539
|
+
const client = {
|
|
1540
|
+
id: clientId,
|
|
1541
|
+
res,
|
|
1542
|
+
tokenName,
|
|
1543
|
+
};
|
|
669
1544
|
sseManager.add(client);
|
|
670
1545
|
// Send connected event
|
|
671
|
-
sseManager.sendEvent(client, {
|
|
1546
|
+
sseManager.sendEvent(client, {
|
|
1547
|
+
type: "connected",
|
|
1548
|
+
clientId,
|
|
1549
|
+
debugLevel: deps.debugLevel ?? "off",
|
|
1550
|
+
});
|
|
1551
|
+
sseManager.writeHeartbeat(client);
|
|
672
1552
|
return;
|
|
673
1553
|
}
|
|
674
1554
|
// GET /api/v1/sessions/:id/events/stream — per-session SSE stream
|
|
675
1555
|
const sseSessionMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/events\/stream(\?.*)?$/);
|
|
676
1556
|
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
1557
|
const sessionId = decodeURIComponent(sseSessionMatch[1]);
|
|
1558
|
+
let tokenName;
|
|
1559
|
+
if (deps.authStore) {
|
|
1560
|
+
const ticket = new URLSearchParams(url.split("?")[1] ?? "").get("ticket") ?? "";
|
|
1561
|
+
const principal = deps.ticketStore?.consume(ticket);
|
|
1562
|
+
if (!principal) {
|
|
1563
|
+
json(res, 401, { error: "Invalid or expired ticket" });
|
|
1564
|
+
return;
|
|
1565
|
+
}
|
|
1566
|
+
tokenName = principal.tokenName;
|
|
1567
|
+
}
|
|
683
1568
|
const session = store.getSession(sessionId);
|
|
684
1569
|
if (!session) {
|
|
685
1570
|
json(res, 404, { error: "Session not found" });
|
|
@@ -689,12 +1574,22 @@ export function createRequestHandler(deps) {
|
|
|
689
1574
|
res.writeHead(200, {
|
|
690
1575
|
"Content-Type": "text/event-stream",
|
|
691
1576
|
"Cache-Control": "no-cache",
|
|
692
|
-
|
|
1577
|
+
Connection: "keep-alive",
|
|
693
1578
|
});
|
|
694
|
-
const client = {
|
|
1579
|
+
const client = {
|
|
1580
|
+
id: clientId,
|
|
1581
|
+
res,
|
|
1582
|
+
sessionId,
|
|
1583
|
+
tokenName,
|
|
1584
|
+
};
|
|
695
1585
|
sseManager.add(client);
|
|
696
1586
|
// Send connected event
|
|
697
|
-
sseManager.sendEvent(client, {
|
|
1587
|
+
sseManager.sendEvent(client, {
|
|
1588
|
+
type: "connected",
|
|
1589
|
+
clientId,
|
|
1590
|
+
debugLevel: deps.debugLevel ?? "off",
|
|
1591
|
+
});
|
|
1592
|
+
sseManager.writeHeartbeat(client);
|
|
698
1593
|
// Replay events from Last-Event-ID if provided
|
|
699
1594
|
const lastEventId = req.headers["last-event-id"];
|
|
700
1595
|
if (lastEventId) {
|
|
@@ -703,7 +1598,10 @@ export function createRequestHandler(deps) {
|
|
|
703
1598
|
const events = store.getEvents(sessionId, { afterSeq });
|
|
704
1599
|
for (const evt of events) {
|
|
705
1600
|
try {
|
|
706
|
-
sseManager.sendEvent(client, {
|
|
1601
|
+
sseManager.sendEvent(client, {
|
|
1602
|
+
type: evt.type,
|
|
1603
|
+
...JSON.parse(evt.data),
|
|
1604
|
+
}, evt.seq);
|
|
707
1605
|
}
|
|
708
1606
|
catch {
|
|
709
1607
|
// Skip malformed event data
|
|
@@ -713,69 +1611,75 @@ export function createRequestHandler(deps) {
|
|
|
713
1611
|
}
|
|
714
1612
|
return;
|
|
715
1613
|
}
|
|
716
|
-
// ---
|
|
717
|
-
// POST /api/v1/sessions/:id/
|
|
718
|
-
|
|
1614
|
+
// --- Attachments (session-scoped) ---
|
|
1615
|
+
// POST /api/v1/sessions/:id/attachments — multipart/form-data upload.
|
|
1616
|
+
//
|
|
1617
|
+
// Wire format: a single `file` field. busboy streams chunks straight
|
|
1618
|
+
// to <data_dir>/sessions/<sid>/attachments/<uuid>.<ext>.tmp; on close
|
|
1619
|
+
// we atomic-rename to the final name and insert an attachments row.
|
|
1620
|
+
// Aborts / mid-stream errors / oversize / wrong field name all leave
|
|
1621
|
+
// the .tmp removed before responding.
|
|
1622
|
+
const imgUploadMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/attachments\/?$/);
|
|
719
1623
|
if (imgUploadMatch && req.method === "POST") {
|
|
720
1624
|
const sessionId = decodeURIComponent(imgUploadMatch[1]);
|
|
721
1625
|
if (!SAFE_ID.test(sessionId)) {
|
|
722
1626
|
json(res, 400, { error: "Invalid session ID" });
|
|
723
1627
|
return;
|
|
724
1628
|
}
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
json(res, 413, { error: "Upload too large" });
|
|
729
|
-
return;
|
|
730
|
-
}
|
|
731
|
-
const chunks = [];
|
|
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" });
|
|
1629
|
+
const ctype = req.headers["content-type"] ?? "";
|
|
1630
|
+
if (!ctype.toLowerCase().startsWith("multipart/form-data")) {
|
|
1631
|
+
json(res, 400, { error: "Expected multipart/form-data" });
|
|
747
1632
|
return;
|
|
748
1633
|
}
|
|
749
|
-
|
|
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 });
|
|
1634
|
+
await handleAttachmentUpload(req, res, sessionId, deps);
|
|
759
1635
|
return;
|
|
760
1636
|
}
|
|
761
|
-
// GET /api/v1/sessions/:id/
|
|
762
|
-
|
|
1637
|
+
// GET /api/v1/sessions/:id/attachments/:file — serve a previously
|
|
1638
|
+
// uploaded attachment. Mime + displayName are looked up from the
|
|
1639
|
+
// attachments table so the response carries the original filename
|
|
1640
|
+
// (RFC 5987) and the per-mime inline/attachment disposition.
|
|
1641
|
+
const imgGetMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/attachments\/([^/?]+)(\?.*)?$/);
|
|
763
1642
|
if (imgGetMatch && req.method === "GET") {
|
|
764
1643
|
const sessionId = decodeURIComponent(imgGetMatch[1]);
|
|
765
1644
|
const file = decodeURIComponent(imgGetMatch[2]);
|
|
766
|
-
|
|
767
|
-
|
|
1645
|
+
// When secret is configured, GET requires sig+exp in query — there
|
|
1646
|
+
// is no Bearer fallback because <img src=...> / <a href=...> can't
|
|
1647
|
+
// carry headers. Verify before any disk I/O.
|
|
1648
|
+
if (deps.attachmentSecret) {
|
|
1649
|
+
const params = new URLSearchParams(url.split("?")[1] ?? "");
|
|
1650
|
+
const sig = params.get("sig") ?? "";
|
|
1651
|
+
const exp = params.get("exp") ?? "";
|
|
1652
|
+
const basePath = `/api/v1/sessions/${sessionId}/attachments/${file}`;
|
|
1653
|
+
if (!verifyAttachmentSig(basePath, exp, sig, deps.attachmentSecret)) {
|
|
1654
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
1655
|
+
res.end(JSON.stringify({ error: "Unauthorized" }));
|
|
1656
|
+
return;
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
const filePath = join(deps.dataDir, "sessions", sessionId, "attachments", file);
|
|
1660
|
+
if (!filePath.startsWith(join(deps.dataDir, "sessions"))) {
|
|
768
1661
|
res.writeHead(403);
|
|
769
1662
|
res.end("Forbidden");
|
|
770
1663
|
return;
|
|
771
1664
|
}
|
|
1665
|
+
// Look up the row to recover the original mime + display name.
|
|
1666
|
+
// Pre-attachments-table uploads (none in v0.4+) would miss here;
|
|
1667
|
+
// we degrade gracefully to extension-based mime.
|
|
1668
|
+
const row = store.getAttachmentByFile(sessionId, file);
|
|
772
1669
|
try {
|
|
773
1670
|
const fileData = await readFile(filePath);
|
|
774
|
-
const
|
|
775
|
-
|
|
776
|
-
|
|
1671
|
+
const mime = row?.mime ??
|
|
1672
|
+
(MIME[extname(filePath)] || "application/octet-stream");
|
|
1673
|
+
const headers = {
|
|
1674
|
+
"Content-Type": mime,
|
|
777
1675
|
"Cache-Control": "public, max-age=31536000, immutable",
|
|
778
|
-
|
|
1676
|
+
"X-Content-Type-Options": "nosniff",
|
|
1677
|
+
};
|
|
1678
|
+
if (row) {
|
|
1679
|
+
const disposition = isInlineMime(mime) ? "inline" : "attachment";
|
|
1680
|
+
headers["Content-Disposition"] = buildContentDisposition(disposition, row.name);
|
|
1681
|
+
}
|
|
1682
|
+
res.writeHead(200, headers);
|
|
779
1683
|
res.end(fileData);
|
|
780
1684
|
}
|
|
781
1685
|
catch {
|
|
@@ -784,6 +1688,203 @@ export function createRequestHandler(deps) {
|
|
|
784
1688
|
}
|
|
785
1689
|
return;
|
|
786
1690
|
}
|
|
1691
|
+
// --- Inbox messages (Stage B primitive) ---
|
|
1692
|
+
// POST /api/v1/messages — create ingress message
|
|
1693
|
+
if (url === "/api/v1/messages" && req.method === "POST") {
|
|
1694
|
+
// client-server-split M2: idempotency for the ingress message
|
|
1695
|
+
// creator. /messages has no real session id, so we scope the
|
|
1696
|
+
// cache under the synthetic key "__ingress__".
|
|
1697
|
+
const { opId, replayed } = tryReplayClientOp(req, res, store, "__ingress__");
|
|
1698
|
+
if (replayed)
|
|
1699
|
+
return;
|
|
1700
|
+
let raw;
|
|
1701
|
+
try {
|
|
1702
|
+
raw = await readBody(req);
|
|
1703
|
+
}
|
|
1704
|
+
catch {
|
|
1705
|
+
json(res, 400, { error: "Failed to read body" });
|
|
1706
|
+
return;
|
|
1707
|
+
}
|
|
1708
|
+
let parsed;
|
|
1709
|
+
try {
|
|
1710
|
+
parsed = JSON.parse(raw);
|
|
1711
|
+
}
|
|
1712
|
+
catch {
|
|
1713
|
+
json(res, 400, { error: "Invalid JSON" });
|
|
1714
|
+
return;
|
|
1715
|
+
}
|
|
1716
|
+
const validation = MessageIngressSchema.safeParse(parsed);
|
|
1717
|
+
if (!validation.success) {
|
|
1718
|
+
json(res, 400, {
|
|
1719
|
+
error: "Invalid body",
|
|
1720
|
+
issues: validation.error.issues,
|
|
1721
|
+
});
|
|
1722
|
+
return;
|
|
1723
|
+
}
|
|
1724
|
+
const input = validation.data;
|
|
1725
|
+
const id = `msg-${randomUUID().replace(/-/g, "").slice(0, 16)}`;
|
|
1726
|
+
if (input.to.startsWith("session:")) {
|
|
1727
|
+
const targetSid = input.to.slice("session:".length);
|
|
1728
|
+
const session = store.getSession(targetSid);
|
|
1729
|
+
if (!session) {
|
|
1730
|
+
json(res, 400, { error: "session_not_found" });
|
|
1731
|
+
return;
|
|
1732
|
+
}
|
|
1733
|
+
sessions?.flushBuffers(targetSid);
|
|
1734
|
+
const data = {
|
|
1735
|
+
message_id: id,
|
|
1736
|
+
from_ref: input.from_ref,
|
|
1737
|
+
from_label: input.from_label ?? null,
|
|
1738
|
+
title: input.title,
|
|
1739
|
+
body: input.body,
|
|
1740
|
+
cwd: input.cwd ?? null,
|
|
1741
|
+
};
|
|
1742
|
+
store.saveEvent(targetSid, "message", data, {
|
|
1743
|
+
from_ref: input.from_ref,
|
|
1744
|
+
});
|
|
1745
|
+
sseManager.broadcast({
|
|
1746
|
+
type: "message",
|
|
1747
|
+
sessionId: targetSid,
|
|
1748
|
+
...data,
|
|
1749
|
+
});
|
|
1750
|
+
if (deps.pushService) {
|
|
1751
|
+
void deps.pushService.sendForMessage({
|
|
1752
|
+
id,
|
|
1753
|
+
to: input.to,
|
|
1754
|
+
body: input.body,
|
|
1755
|
+
from_label: input.from_label,
|
|
1756
|
+
from_ref: input.from_ref,
|
|
1757
|
+
deliver: input.deliver,
|
|
1758
|
+
dedup_key: input.dedup_key ?? null,
|
|
1759
|
+
});
|
|
1760
|
+
}
|
|
1761
|
+
mlog.info("ingress bound", {
|
|
1762
|
+
msg_id: id,
|
|
1763
|
+
sess_id: targetSid.slice(0, 8),
|
|
1764
|
+
});
|
|
1765
|
+
const boundBody = { id, delivered: "session" };
|
|
1766
|
+
saveClientOpResult(store, opId, "__ingress__", 200, boundBody);
|
|
1767
|
+
json(res, 200, boundBody);
|
|
1768
|
+
return;
|
|
1769
|
+
}
|
|
1770
|
+
// Unbound: to=user → rows in `messages` table
|
|
1771
|
+
const dedupKey = input.dedup_key ?? null;
|
|
1772
|
+
if (dedupKey) {
|
|
1773
|
+
const prior = store.findBySupersede(input.to, dedupKey);
|
|
1774
|
+
if (prior) {
|
|
1775
|
+
store.deleteMessage(prior.id);
|
|
1776
|
+
mlog.info("dedup_key supersede", {
|
|
1777
|
+
to: input.to,
|
|
1778
|
+
dedup_key: dedupKey,
|
|
1779
|
+
old_msg_id: prior.id,
|
|
1780
|
+
new_msg_id: id,
|
|
1781
|
+
});
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
store.createMessage({
|
|
1785
|
+
id,
|
|
1786
|
+
from_ref: input.from_ref,
|
|
1787
|
+
from_label: input.from_label ?? null,
|
|
1788
|
+
to_ref: input.to,
|
|
1789
|
+
deliver: input.deliver,
|
|
1790
|
+
dedup_key: dedupKey,
|
|
1791
|
+
title: input.title,
|
|
1792
|
+
body: input.body,
|
|
1793
|
+
cwd: input.cwd ?? null,
|
|
1794
|
+
created_at: Date.now(),
|
|
1795
|
+
});
|
|
1796
|
+
sseManager.broadcast({ type: "message_created", messageId: id });
|
|
1797
|
+
if (deps.pushService) {
|
|
1798
|
+
void deps.pushService.sendForMessage({
|
|
1799
|
+
id,
|
|
1800
|
+
to: input.to,
|
|
1801
|
+
body: input.body,
|
|
1802
|
+
from_label: input.from_label,
|
|
1803
|
+
from_ref: input.from_ref,
|
|
1804
|
+
deliver: input.deliver,
|
|
1805
|
+
dedup_key: dedupKey,
|
|
1806
|
+
});
|
|
1807
|
+
}
|
|
1808
|
+
mlog.info("ingress unbound", {
|
|
1809
|
+
msg_id: id,
|
|
1810
|
+
from_ref: input.from_ref,
|
|
1811
|
+
});
|
|
1812
|
+
const unboundBody = { id, delivered: "pending" };
|
|
1813
|
+
saveClientOpResult(store, opId, "__ingress__", 200, unboundBody);
|
|
1814
|
+
json(res, 200, unboundBody);
|
|
1815
|
+
return;
|
|
1816
|
+
}
|
|
1817
|
+
// GET /api/v1/messages — list unprocessed
|
|
1818
|
+
if (url === "/api/v1/messages" && req.method === "GET") {
|
|
1819
|
+
json(res, 200, { messages: store.listUnprocessed() });
|
|
1820
|
+
return;
|
|
1821
|
+
}
|
|
1822
|
+
// /api/v1/messages/:id... — GET single, POST :id/consume, POST :id/ack, DELETE :id
|
|
1823
|
+
if (url.startsWith("/api/v1/messages/")) {
|
|
1824
|
+
const tail = url.slice("/api/v1/messages/".length);
|
|
1825
|
+
const consumeMatch = tail.match(/^([^/?]+)\/consume\/?$/);
|
|
1826
|
+
if (consumeMatch && req.method === "POST") {
|
|
1827
|
+
const id = decodeURIComponent(consumeMatch[1]);
|
|
1828
|
+
const newSid = randomUUID();
|
|
1829
|
+
let out;
|
|
1830
|
+
try {
|
|
1831
|
+
out = store.consumeMessageTx(id, { sessionId: newSid });
|
|
1832
|
+
}
|
|
1833
|
+
catch (err) {
|
|
1834
|
+
if (/message not found/.test(errorMessage(err))) {
|
|
1835
|
+
json(res, 404, { error: "Message not found" });
|
|
1836
|
+
return;
|
|
1837
|
+
}
|
|
1838
|
+
throw err;
|
|
1839
|
+
}
|
|
1840
|
+
sseManager.broadcast({
|
|
1841
|
+
type: "message_consumed",
|
|
1842
|
+
messageId: id,
|
|
1843
|
+
sessionId: out.sessionId,
|
|
1844
|
+
});
|
|
1845
|
+
if (!out.alreadyConsumed && deps.pushService) {
|
|
1846
|
+
void deps.pushService.sendClose(id);
|
|
1847
|
+
}
|
|
1848
|
+
mlog.info("consume", {
|
|
1849
|
+
msg_id: id,
|
|
1850
|
+
sess_id: out.sessionId.slice(0, 8),
|
|
1851
|
+
already_consumed: out.alreadyConsumed,
|
|
1852
|
+
});
|
|
1853
|
+
json(res, 200, {
|
|
1854
|
+
sessionId: out.sessionId,
|
|
1855
|
+
alreadyConsumed: out.alreadyConsumed,
|
|
1856
|
+
});
|
|
1857
|
+
return;
|
|
1858
|
+
}
|
|
1859
|
+
const ackPost = tail.match(/^([^/?]+)\/ack\/?$/);
|
|
1860
|
+
const idOnly = tail.match(/^([^/?]+)\/?$/);
|
|
1861
|
+
const isAck = (ackPost !== null && req.method === "POST") ||
|
|
1862
|
+
(idOnly !== null && req.method === "DELETE");
|
|
1863
|
+
if (isAck) {
|
|
1864
|
+
const id = decodeURIComponent((ackPost ?? idOnly)[1]);
|
|
1865
|
+
const changes = store.deleteMessage(id);
|
|
1866
|
+
if (changes === 0) {
|
|
1867
|
+
json(res, 404, { error: "Message not found" });
|
|
1868
|
+
return;
|
|
1869
|
+
}
|
|
1870
|
+
sseManager.broadcast({ type: "message_acked", messageId: id });
|
|
1871
|
+
if (deps.pushService)
|
|
1872
|
+
void deps.pushService.sendClose(id);
|
|
1873
|
+
mlog.info("ack", { msg_id: id });
|
|
1874
|
+
json(res, 200, { ok: true });
|
|
1875
|
+
return;
|
|
1876
|
+
}
|
|
1877
|
+
if (idOnly && req.method === "GET") {
|
|
1878
|
+
const id = decodeURIComponent(idOnly[1]);
|
|
1879
|
+
const row = store.getMessage(id);
|
|
1880
|
+
if (!row) {
|
|
1881
|
+
json(res, 404, { error: "Message not found" });
|
|
1882
|
+
return;
|
|
1883
|
+
}
|
|
1884
|
+
json(res, 200, row);
|
|
1885
|
+
return;
|
|
1886
|
+
}
|
|
1887
|
+
}
|
|
787
1888
|
json(res, 404, { error: "Not found" });
|
|
788
1889
|
return;
|
|
789
1890
|
}
|
|
@@ -814,34 +1915,45 @@ export function createRequestHandler(deps) {
|
|
|
814
1915
|
json(res, 400, { error: "Missing required field: text" });
|
|
815
1916
|
return;
|
|
816
1917
|
}
|
|
817
|
-
const cwd = body.cwd
|
|
1918
|
+
const cwd = typeof body.cwd === "string" ? body.cwd : undefined;
|
|
818
1919
|
const { sessionId } = await sessions.createSession(bridge, cwd, undefined, "auto");
|
|
819
1920
|
const streamUrl = `/api/v1/sessions/${sessionId}/events/stream`;
|
|
820
1921
|
json(res, 202, { sessionId, streamUrl });
|
|
821
1922
|
// Fire-and-forget: send the prompt asynchronously, tracking busy state
|
|
822
1923
|
sessions.activePrompts.add(sessionId);
|
|
1924
|
+
sessions.syncBusy(sessionId);
|
|
823
1925
|
// Generate title (fire-and-forget)
|
|
824
1926
|
if (titleService && !sessions.sessionHasTitle.has(sessionId)) {
|
|
825
1927
|
titleService.generate(bridge, text, sessionId, (title) => {
|
|
826
|
-
const titleEvent = {
|
|
1928
|
+
const titleEvent = {
|
|
1929
|
+
type: "session_title_updated",
|
|
1930
|
+
sessionId,
|
|
1931
|
+
title,
|
|
1932
|
+
};
|
|
827
1933
|
sseManager.broadcast(titleEvent);
|
|
828
1934
|
});
|
|
829
1935
|
}
|
|
830
|
-
bridge
|
|
1936
|
+
bridge
|
|
1937
|
+
.prompt(sessionId, text)
|
|
831
1938
|
.catch(() => { })
|
|
832
|
-
.finally(() =>
|
|
1939
|
+
.finally(() => {
|
|
1940
|
+
sessions.activePrompts.delete(sessionId);
|
|
1941
|
+
sessions.syncBusy(sessionId);
|
|
1942
|
+
});
|
|
833
1943
|
return;
|
|
834
1944
|
}
|
|
835
1945
|
// POST /api/beta/clients/:clientId/visibility
|
|
836
1946
|
const visMatch = url.match(/^\/api\/beta\/clients\/([^/]+)\/visibility$/);
|
|
837
1947
|
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
1948
|
const clientId = decodeURIComponent(visMatch[1]);
|
|
844
|
-
if (
|
|
1949
|
+
// Trust boundary: accept if the client is (a) currently connected
|
|
1950
|
+
// via SSE, or (b) known to the ClientRegistry (populated on /hello,
|
|
1951
|
+
// persists across SSE disconnect). Registry check fixes the
|
|
1952
|
+
// pagehide-beacon race where iOS PWA suspension drops the SSE TCP
|
|
1953
|
+
// connection before the beacon egresses.
|
|
1954
|
+
const clientKnown = sseManager.clients.has(clientId) ||
|
|
1955
|
+
deps.clientRegistry?.get(clientId) !== undefined;
|
|
1956
|
+
if (!clientKnown) {
|
|
845
1957
|
json(res, 404, { error: "Client not found" });
|
|
846
1958
|
return;
|
|
847
1959
|
}
|
|
@@ -857,11 +1969,48 @@ export function createRequestHandler(deps) {
|
|
|
857
1969
|
json(res, 400, { error: "Missing or invalid 'visible' field" });
|
|
858
1970
|
return;
|
|
859
1971
|
}
|
|
860
|
-
//
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
1972
|
+
// sessionId patch semantics: absent = preserve, null = clear,
|
|
1973
|
+
// string = replace. Zod can't distinguish omitted from explicit
|
|
1974
|
+
// null after parse, so branch on raw body key.
|
|
1975
|
+
const hasSessionIdKey = Object.prototype.hasOwnProperty.call(body, "sessionId");
|
|
1976
|
+
let sessionIdPatch;
|
|
1977
|
+
if (!hasSessionIdKey) {
|
|
1978
|
+
sessionIdPatch = undefined;
|
|
1979
|
+
}
|
|
1980
|
+
else if (body.sessionId === null) {
|
|
1981
|
+
sessionIdPatch = null;
|
|
1982
|
+
}
|
|
1983
|
+
else if (typeof body.sessionId === "string" &&
|
|
1984
|
+
body.sessionId.length > 0) {
|
|
1985
|
+
sessionIdPatch = body.sessionId;
|
|
1986
|
+
}
|
|
1987
|
+
else {
|
|
1988
|
+
sessionIdPatch = null;
|
|
1989
|
+
}
|
|
1990
|
+
if (deps.clientRegistry) {
|
|
1991
|
+
// setVisibility no-ops on unknown clients; auto-register here so
|
|
1992
|
+
// SSE-only clients (which haven't sent /hello yet) still take
|
|
1993
|
+
// effect. The trust boundary above already gated on identity.
|
|
1994
|
+
if (!deps.clientRegistry.get(clientId)) {
|
|
1995
|
+
deps.clientRegistry.register(clientId, { capabilities: [] });
|
|
1996
|
+
}
|
|
1997
|
+
const { becameVisibleFor } = deps.clientRegistry.setVisibility(clientId, {
|
|
1998
|
+
visible: body.visible,
|
|
1999
|
+
active: sessionIdPatch,
|
|
2000
|
+
});
|
|
2001
|
+
// Edge-triggered only: heartbeat refreshes repeat the same
|
|
2002
|
+
// (visible:true, sessionId:X) POST every 15s — firing sendClose
|
|
2003
|
+
// on each would hammer banner recall. Only the first such
|
|
2004
|
+
// transition after a change should recall stale banners.
|
|
2005
|
+
if (becameVisibleFor && deps.pushService) {
|
|
2006
|
+
void deps.pushService.sendClose(`sess-${becameVisibleFor}-done`);
|
|
2007
|
+
if (sessions) {
|
|
2008
|
+
for (const perm of sessions.pendingPermissions.values()) {
|
|
2009
|
+
if (perm.sessionId === becameVisibleFor) {
|
|
2010
|
+
void deps.pushService.sendClose(`sess-${becameVisibleFor}-perm-${perm.requestId}`);
|
|
2011
|
+
}
|
|
2012
|
+
}
|
|
2013
|
+
}
|
|
865
2014
|
}
|
|
866
2015
|
}
|
|
867
2016
|
json(res, 200, { ok: true });
|
|
@@ -894,11 +2043,12 @@ export function createRequestHandler(deps) {
|
|
|
894
2043
|
json(res, 400, { error: "Invalid JSON" });
|
|
895
2044
|
return;
|
|
896
2045
|
}
|
|
897
|
-
if (!body.endpoint || !body.keys?.auth || !body.keys
|
|
2046
|
+
if (!body.endpoint || !body.keys?.auth || !body.keys.p256dh) {
|
|
898
2047
|
json(res, 400, { error: "Missing endpoint or keys (auth, p256dh)" });
|
|
899
2048
|
return;
|
|
900
2049
|
}
|
|
901
2050
|
store.saveSubscription(body.endpoint, body.keys.auth, body.keys.p256dh);
|
|
2051
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- defensive
|
|
902
2052
|
if (body.clientId && deps.pushService) {
|
|
903
2053
|
deps.pushService.registerClient(body.clientId, body.endpoint);
|
|
904
2054
|
}
|
|
@@ -957,7 +2107,11 @@ export function createRequestHandler(deps) {
|
|
|
957
2107
|
return;
|
|
958
2108
|
}
|
|
959
2109
|
// --- Static files ---
|
|
960
|
-
|
|
2110
|
+
let staticPath = url;
|
|
2111
|
+
const htmlEntry = HTML_ENTRYPOINTS.find((e) => e.urlPath === staticPath);
|
|
2112
|
+
if (htmlEntry)
|
|
2113
|
+
staticPath = "/" + htmlEntry.file;
|
|
2114
|
+
const filePath = join(deps.publicDir, staticPath);
|
|
961
2115
|
if (!filePath.startsWith(deps.publicDir)) {
|
|
962
2116
|
res.writeHead(403);
|
|
963
2117
|
res.end("Forbidden");
|
|
@@ -966,7 +2120,23 @@ export function createRequestHandler(deps) {
|
|
|
966
2120
|
try {
|
|
967
2121
|
const data = await readFile(filePath);
|
|
968
2122
|
const ext = extname(filePath);
|
|
969
|
-
|
|
2123
|
+
const base = filePath.slice(filePath.lastIndexOf("/") + 1);
|
|
2124
|
+
const isHashedAsset = /\.[A-Za-z0-9_-]{8,}\.(js|css)$/.test(base);
|
|
2125
|
+
// `/lib/**` is vendored third-party content pinned by upstream SHA in
|
|
2126
|
+
// package metadata — treat as immutable just like content-hashed bundles.
|
|
2127
|
+
// Avoids re-downloading multi-hundred-KB wasm on every consumer init.
|
|
2128
|
+
const isVendoredLib = staticPath.startsWith("/lib/");
|
|
2129
|
+
const cacheControl = isHashedAsset || isVendoredLib
|
|
2130
|
+
? "public, max-age=31536000, immutable"
|
|
2131
|
+
: "no-cache";
|
|
2132
|
+
const headers = {
|
|
2133
|
+
"Content-Type": MIME[ext] ?? "application/octet-stream",
|
|
2134
|
+
"Cache-Control": cacheControl,
|
|
2135
|
+
};
|
|
2136
|
+
// CSP applies to HTML entrypoints (where script/style execute).
|
|
2137
|
+
if (htmlEntry)
|
|
2138
|
+
headers["Content-Security-Policy"] = CSP_POLICY;
|
|
2139
|
+
res.writeHead(200, headers);
|
|
970
2140
|
res.end(data);
|
|
971
2141
|
}
|
|
972
2142
|
catch {
|