@lelouchhe/webagent 0.6.0 → 0.7.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.
@@ -1,4 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
2
3
  import { rm } from "node:fs/promises";
3
4
  import { stat } from "node:fs/promises";
4
5
  import { join } from "node:path";
@@ -55,6 +56,8 @@ export class SessionManager {
55
56
  * DELETE. Lookup is cheap (Map.get); rebuild is one SQLite query.
56
57
  */
57
58
  attachmentLabelCache = new Map();
59
+ agentCommandSnapshots = new Map();
60
+ agentCommandEpoch = randomUUID();
58
61
  cachedConfigOptions = [];
59
62
  agentInfo = null;
60
63
  store;
@@ -85,8 +88,10 @@ export class SessionManager {
85
88
  }
86
89
  // Clean up empty sessions (no events) older than the threshold
87
90
  const cleaned = this.store.deleteEmptySessions(EMPTY_SESSION_MIN_AGE_S);
88
- for (const id of cleaned)
91
+ for (const id of cleaned) {
89
92
  this.liveSessions.delete(id);
93
+ this.agentCommandSnapshots.delete(id);
94
+ }
90
95
  if (cleaned.length > 0)
91
96
  slog.info("cleaned empty session(s)", { count: cleaned.length });
92
97
  const sourceSession = inheritFromSessionId
@@ -262,6 +267,42 @@ export class SessionManager {
262
267
  invalidateLabelCache(sessionId) {
263
268
  this.attachmentLabelCache.delete(sessionId);
264
269
  }
270
+ updateAgentCommands(sessionId, commands) {
271
+ const current = this.agentCommandSnapshots.get(sessionId);
272
+ const snapshot = {
273
+ epoch: this.agentCommandEpoch,
274
+ revision: (current?.revision ?? 0) + 1,
275
+ commands: commands.map((command) => ({
276
+ ...command,
277
+ ...(command.input ? { input: { ...command.input } } : {}),
278
+ })),
279
+ };
280
+ this.agentCommandSnapshots.set(sessionId, snapshot);
281
+ return snapshot;
282
+ }
283
+ getAgentCommands(sessionId) {
284
+ return (this.agentCommandSnapshots.get(sessionId) ?? {
285
+ epoch: this.agentCommandEpoch,
286
+ revision: 0,
287
+ commands: [],
288
+ });
289
+ }
290
+ clearAgentCommands() {
291
+ const cleared = [];
292
+ for (const [sessionId, current] of this.agentCommandSnapshots) {
293
+ const snapshot = {
294
+ epoch: this.agentCommandEpoch,
295
+ revision: current.revision + 1,
296
+ commands: [],
297
+ };
298
+ this.agentCommandSnapshots.set(sessionId, snapshot);
299
+ cleared.push({
300
+ sessionId,
301
+ ...snapshot,
302
+ });
303
+ }
304
+ return cleared;
305
+ }
265
306
  /** Delete a session from store and clean up all state (including images). */
266
307
  deleteSession(sessionId) {
267
308
  const mode = this.store.deleteSession(sessionId);
@@ -272,6 +313,7 @@ export class SessionManager {
272
313
  this.activePrompts.delete(sessionId);
273
314
  this.runningBashProcs.delete(sessionId);
274
315
  this.attachmentLabelCache.delete(sessionId);
316
+ this.agentCommandSnapshots.delete(sessionId);
275
317
  // Clean pending permissions for this session
276
318
  for (const [reqId, perm] of this.pendingPermissions) {
277
319
  if (perm.sessionId === sessionId)
@@ -7,6 +7,7 @@ import { SanitizeError, sanitizeEventsForShare } from "./sanitize.js";
7
7
  import { buildContentDisposition, isInlineMime } from "../attachments.js";
8
8
  import { enrichStoredEventsForDisplay } from "../attachment-labels.js";
9
9
  import { log } from "../log.js";
10
+ import { HTTP_STATUS } from "../http-status.js";
10
11
  const slog = log.scope("share");
11
12
  // In-flight dedup for concurrent POST /share on the same session.
12
13
  // First caller does the work; concurrent callers await the same promise.
@@ -112,7 +113,9 @@ export async function handleShareRoutes(req, res, deps) {
112
113
  return true;
113
114
  }
114
115
  if (url === "/s" || url === "/s/") {
115
- res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
116
+ res.writeHead(HTTP_STATUS.NOT_FOUND, {
117
+ "Content-Type": "text/plain; charset=utf-8",
118
+ });
116
119
  res.end("share token required");
117
120
  return true;
118
121
  }
@@ -175,7 +178,9 @@ export async function handleShareRoutes(req, res, deps) {
175
178
  url.startsWith("/api/v1/shared/") ||
176
179
  url === "/api/v1/share" ||
177
180
  url.startsWith("/api/v1/share/")) {
178
- res.writeHead(404, { "Content-Type": "application/json" });
181
+ res.writeHead(HTTP_STATUS.NOT_FOUND, {
182
+ "Content-Type": "application/json",
183
+ });
179
184
  res.end(JSON.stringify({ error: "not found" }));
180
185
  return true;
181
186
  }
@@ -202,12 +207,12 @@ function resolveDisplayName(deps, validated) {
202
207
  async function handlePreviewCreate(req, res, deps, sessionId) {
203
208
  const session = deps.store.getSession(sessionId);
204
209
  if (!session) {
205
- json(res, 404, { error: "session not found" });
210
+ json(res, HTTP_STATUS.NOT_FOUND, { error: "session not found" });
206
211
  return;
207
212
  }
208
213
  // 409 guard: block while the agent is actively streaming into this session.
209
214
  if (deps.sessions?.getBusyKind(sessionId) === "agent") {
210
- json(res, 409, {
215
+ json(res, HTTP_STATUS.CONFLICT, {
211
216
  error: "session busy",
212
217
  detail: "此 session 正在接收 agent 输出,请等 agent 输出结束后再分享",
213
218
  });
@@ -221,13 +226,15 @@ async function handlePreviewCreate(req, res, deps, sessionId) {
221
226
  body = {};
222
227
  }
223
228
  catch {
224
- json(res, 400, { error: "invalid JSON body" });
229
+ json(res, HTTP_STATUS.BAD_REQUEST, { error: "invalid JSON body" });
225
230
  return;
226
231
  }
227
232
  let ttlHours = null;
228
233
  if (body.ttl_hours != null) {
229
234
  if (!Number.isFinite(body.ttl_hours) || body.ttl_hours < 0) {
230
- json(res, 400, { error: "ttl_hours must be a non-negative number" });
235
+ json(res, HTTP_STATUS.BAD_REQUEST, {
236
+ error: "ttl_hours must be a non-negative number",
237
+ });
231
238
  return;
232
239
  }
233
240
  ttlHours =
@@ -239,12 +246,12 @@ async function handlePreviewCreate(req, res, deps, sessionId) {
239
246
  // control/size rejected at entry rather than silently dropped.
240
247
  const dnResult = validateLabel(body.display_name, "display_name", 256);
241
248
  if (!dnResult.ok) {
242
- json(res, 400, { error: dnResult.reason });
249
+ json(res, HTTP_STATUS.BAD_REQUEST, { error: dnResult.reason });
243
250
  return;
244
251
  }
245
252
  const olResult = validateLabel(body.owner_label, "owner_label", 1024);
246
253
  if (!olResult.ok) {
247
- json(res, 400, { error: olResult.reason });
254
+ json(res, HTTP_STATUS.BAD_REQUEST, { error: olResult.reason });
248
255
  return;
249
256
  }
250
257
  const displayName = resolveDisplayName(deps, dnResult.value);
@@ -280,7 +287,7 @@ async function handlePreviewCreate(req, res, deps, sessionId) {
280
287
  if (!existingInflight)
281
288
  pendingShareCreates.set(sessionId, inflight);
282
289
  const result = await inflight;
283
- json(res, result.reused ? 200 : 201, {
290
+ json(res, result.reused ? HTTP_STATUS.OK : HTTP_STATUS.CREATED, {
284
291
  token: result.row.token,
285
292
  session_id: sessionId,
286
293
  snapshot_seq: result.row.share_snapshot_seq,
@@ -293,7 +300,7 @@ async function handlePreviewCreate(req, res, deps, sessionId) {
293
300
  }
294
301
  catch (err) {
295
302
  if (err instanceof SanitizeError) {
296
- json(res, 400, {
303
+ json(res, HTTP_STATUS.BAD_REQUEST, {
297
304
  error: "sanitize rejected",
298
305
  event_id: err.event_id,
299
306
  rule: err.rule,
@@ -303,7 +310,10 @@ async function handlePreviewCreate(req, res, deps, sessionId) {
303
310
  }
304
311
  const errorId = randomUUID();
305
312
  slog.error("preview_create", { error_id: errorId, error: err });
306
- json(res, 500, { error: "internal error", error_id: errorId });
313
+ json(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, {
314
+ error: "internal error",
315
+ error_id: errorId,
316
+ });
307
317
  }
308
318
  }
309
319
  function runSanitizeGate(events, cwd, internalHosts) {
@@ -327,25 +337,29 @@ async function handlePreviewRead(req, res, deps, sessionId) {
327
337
  const tokenHeader = req.headers["x-share-token"];
328
338
  const token = Array.isArray(tokenHeader) ? tokenHeader[0] : tokenHeader;
329
339
  if (!token) {
330
- json(res, 400, { error: "X-Share-Token header required" });
340
+ json(res, HTTP_STATUS.BAD_REQUEST, {
341
+ error: "X-Share-Token header required",
342
+ });
331
343
  return;
332
344
  }
333
345
  const row = deps.store.getShareByToken(token);
334
346
  if (!row) {
335
- json(res, 404, { error: "share not found" });
347
+ json(res, HTTP_STATUS.NOT_FOUND, { error: "share not found" });
336
348
  return;
337
349
  }
338
350
  if (row.session_id !== sessionId) {
339
- json(res, 404, { error: "share not found" });
351
+ json(res, HTTP_STATUS.NOT_FOUND, { error: "share not found" });
340
352
  return;
341
353
  }
342
354
  if (row.shared_at != null) {
343
- json(res, 409, { error: "share already active (use public viewer)" });
355
+ json(res, HTTP_STATUS.CONFLICT, {
356
+ error: "share already active (use public viewer)",
357
+ });
344
358
  return;
345
359
  }
346
360
  const session = deps.store.getSession(sessionId);
347
361
  if (!session) {
348
- json(res, 404, { error: "session not found" });
362
+ json(res, HTTP_STATUS.NOT_FOUND, { error: "session not found" });
349
363
  return;
350
364
  }
351
365
  const allEvents = deps.store
@@ -366,7 +380,7 @@ async function handlePreviewRead(req, res, deps, sessionId) {
366
380
  });
367
381
  // Staleness metadata drives the owner sticky bar text (§2.1 R2-c3).
368
382
  const eventsSinceSnapshot = Math.max(0, currentLastSeq - row.share_snapshot_seq);
369
- json(res, 200, {
383
+ json(res, HTTP_STATUS.OK, {
370
384
  schema_version: "1.0",
371
385
  share: {
372
386
  token: row.token,
@@ -386,7 +400,7 @@ async function handlePreviewRead(req, res, deps, sessionId) {
386
400
  }
387
401
  catch (err) {
388
402
  if (err instanceof SanitizeError) {
389
- json(res, 400, {
403
+ json(res, HTTP_STATUS.BAD_REQUEST, {
390
404
  error: "sanitize rejected",
391
405
  event_id: err.event_id,
392
406
  rule: err.rule,
@@ -395,7 +409,10 @@ async function handlePreviewRead(req, res, deps, sessionId) {
395
409
  }
396
410
  const errorId = randomUUID();
397
411
  slog.error("preview_read", { error_id: errorId, error: err });
398
- json(res, 500, { error: "internal error", error_id: errorId });
412
+ json(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, {
413
+ error: "internal error",
414
+ error_id: errorId,
415
+ });
399
416
  }
400
417
  }
401
418
  /**
@@ -420,24 +437,24 @@ async function handlePublish(req, res, deps, sessionId) {
420
437
  body = {};
421
438
  }
422
439
  catch {
423
- json(res, 400, { error: "invalid JSON body" });
440
+ json(res, HTTP_STATUS.BAD_REQUEST, { error: "invalid JSON body" });
424
441
  return;
425
442
  }
426
443
  if (!body.token || typeof body.token !== "string") {
427
- json(res, 400, { error: "token required" });
444
+ json(res, HTTP_STATUS.BAD_REQUEST, { error: "token required" });
428
445
  return;
429
446
  }
430
447
  const row = deps.store.getShareByToken(body.token);
431
448
  if (!row) {
432
- json(res, 404, { error: "share not found" });
449
+ json(res, HTTP_STATUS.NOT_FOUND, { error: "share not found" });
433
450
  return;
434
451
  }
435
452
  if (row.session_id !== sessionId) {
436
- json(res, 404, { error: "share not found" });
453
+ json(res, HTTP_STATUS.NOT_FOUND, { error: "share not found" });
437
454
  return;
438
455
  }
439
456
  if (row.shared_at != null) {
440
- json(res, 409, {
457
+ json(res, HTTP_STATUS.CONFLICT, {
441
458
  error: "share already active",
442
459
  token: row.token,
443
460
  shared_at: row.shared_at,
@@ -451,7 +468,7 @@ async function handlePublish(req, res, deps, sessionId) {
451
468
  if ("display_name" in body) {
452
469
  const r = validateLabel(body.display_name, "display_name", 256);
453
470
  if (!r.ok) {
454
- json(res, 400, { error: r.reason });
471
+ json(res, HTTP_STATUS.BAD_REQUEST, { error: r.reason });
455
472
  return;
456
473
  }
457
474
  displayName = r.value === "" ? null : r.value;
@@ -460,7 +477,7 @@ async function handlePublish(req, res, deps, sessionId) {
460
477
  if ("owner_label" in body) {
461
478
  const r = validateLabel(body.owner_label, "owner_label", 1024);
462
479
  if (!r.ok) {
463
- json(res, 400, { error: r.reason });
480
+ json(res, HTTP_STATUS.BAD_REQUEST, { error: r.reason });
464
481
  return;
465
482
  }
466
483
  ownerLabel = r.value === "" ? null : r.value;
@@ -473,18 +490,20 @@ async function handlePublish(req, res, deps, sessionId) {
473
490
  // Race: concurrent revoke/activate between getShareByToken and activateShare.
474
491
  const fresh = deps.store.getShareByToken(body.token);
475
492
  if (!fresh) {
476
- json(res, 410, { error: "share revoked" });
493
+ json(res, HTTP_STATUS.GONE, { error: "share revoked" });
477
494
  return;
478
495
  }
479
496
  if (fresh.shared_at != null) {
480
- json(res, 409, {
497
+ json(res, HTTP_STATUS.CONFLICT, {
481
498
  error: "share already active",
482
499
  token: fresh.token,
483
500
  shared_at: fresh.shared_at,
484
501
  });
485
502
  return;
486
503
  }
487
- json(res, 500, { error: "unexpected activate failure" });
504
+ json(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, {
505
+ error: "unexpected activate failure",
506
+ });
488
507
  return;
489
508
  }
490
509
  if (displayName !== undefined && displayName != null) {
@@ -492,13 +511,15 @@ async function handlePublish(req, res, deps, sessionId) {
492
511
  }
493
512
  const after = deps.store.getShareByToken(body.token);
494
513
  if (!after) {
495
- json(res, 500, { error: "post-activate read failed" });
514
+ json(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, {
515
+ error: "post-activate read failed",
516
+ });
496
517
  return;
497
518
  }
498
519
  const origin = deps.config.viewer_origin && deps.config.viewer_origin !== ""
499
520
  ? deps.config.viewer_origin.replace(/\/$/, "")
500
521
  : "";
501
- json(res, 200, {
522
+ json(res, HTTP_STATUS.OK, {
502
523
  token: after.token,
503
524
  session_id: sessionId,
504
525
  shared_at: after.shared_at,
@@ -516,7 +537,7 @@ async function handleViewerHtml(res, deps, token) {
516
537
  if (row?.shared_at == null) {
517
538
  // Preview tokens (shared_at IS NULL) MUST NOT resolve publicly.
518
539
  const csp = viewerCsp(deps.config.csp_enforce);
519
- res.writeHead(410, {
540
+ res.writeHead(HTTP_STATUS.GONE, {
520
541
  "Content-Type": "text/html; charset=utf-8",
521
542
  [csp.name]: csp.value,
522
543
  "Cache-Control": "no-store",
@@ -527,7 +548,7 @@ async function handleViewerHtml(res, deps, token) {
527
548
  }
528
549
  if (isExpired(row)) {
529
550
  const csp = viewerCsp(deps.config.csp_enforce);
530
- res.writeHead(410, {
551
+ res.writeHead(HTTP_STATUS.GONE, {
531
552
  "Content-Type": "text/html; charset=utf-8",
532
553
  [csp.name]: csp.value,
533
554
  "Cache-Control": "no-store",
@@ -537,13 +558,15 @@ async function handleViewerHtml(res, deps, token) {
537
558
  return;
538
559
  }
539
560
  if (!deps.publicDir) {
540
- json(res, 500, { error: "publicDir not configured" });
561
+ json(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, {
562
+ error: "publicDir not configured",
563
+ });
541
564
  return;
542
565
  }
543
566
  try {
544
567
  const html = await readFile(join(deps.publicDir, "share-viewer.html"), "utf-8");
545
568
  const csp = viewerCsp(deps.config.csp_enforce);
546
- res.writeHead(200, {
569
+ res.writeHead(HTTP_STATUS.OK, {
547
570
  "Content-Type": "text/html; charset=utf-8",
548
571
  [csp.name]: csp.value,
549
572
  "Cache-Control": "no-store, no-cache, must-revalidate",
@@ -558,7 +581,9 @@ async function handleViewerHtml(res, deps, token) {
558
581
  catch (err) {
559
582
  const errorId = randomUUID();
560
583
  slog.error("viewer_html", { error_id: errorId, error: err });
561
- res.writeHead(500, { "Content-Type": "text/plain" });
584
+ res.writeHead(HTTP_STATUS.INTERNAL_SERVER_ERROR, {
585
+ "Content-Type": "text/plain",
586
+ });
562
587
  res.end(`viewer unavailable (error_id=${errorId})`);
563
588
  }
564
589
  }
@@ -569,11 +594,11 @@ async function handleViewerHtml(res, deps, token) {
569
594
  async function handleSharedEvents(res, deps, token) {
570
595
  const row = deps.store.getShareByToken(token);
571
596
  if (row?.shared_at == null) {
572
- json(res, 410, { error: "share revoked or not found" });
597
+ json(res, HTTP_STATUS.GONE, { error: "share revoked or not found" });
573
598
  return;
574
599
  }
575
600
  if (isExpired(row)) {
576
- json(res, 410, { error: "share expired" });
601
+ json(res, HTTP_STATUS.GONE, { error: "share expired" });
577
602
  return;
578
603
  }
579
604
  // Public viewer must keep working after the owner deletes the source
@@ -581,7 +606,7 @@ async function handleSharedEvents(res, deps, token) {
581
606
  // them (Store.deleteSession soft-deletes when shares exist).
582
607
  const session = deps.store.getSessionIncludingDeleted(row.session_id);
583
608
  if (!session) {
584
- json(res, 500, { error: "session vanished" });
609
+ json(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, { error: "session vanished" });
585
610
  return;
586
611
  }
587
612
  const allEvents = deps.store
@@ -598,7 +623,7 @@ async function handleSharedEvents(res, deps, token) {
598
623
  internalHosts: deps.config.internal_hosts,
599
624
  });
600
625
  // Public response: DOES NOT expose session_id. Only title + display_name + meta.
601
- res.writeHead(200, {
626
+ res.writeHead(HTTP_STATUS.OK, {
602
627
  "Content-Type": "application/json",
603
628
  "Cache-Control": "no-store",
604
629
  "X-Content-Type-Options": "nosniff",
@@ -626,12 +651,15 @@ async function handleSharedEvents(res, deps, token) {
626
651
  rule: err.rule,
627
652
  event_id: err.event_id,
628
653
  });
629
- json(res, 410, { error: "share unavailable" });
654
+ json(res, HTTP_STATUS.GONE, { error: "share unavailable" });
630
655
  return;
631
656
  }
632
657
  const errorId = randomUUID();
633
658
  slog.error("shared_events", { error_id: errorId, error: err });
634
- json(res, 500, { error: "internal error", error_id: errorId });
659
+ json(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, {
660
+ error: "internal error",
661
+ error_id: errorId,
662
+ });
635
663
  }
636
664
  }
637
665
  /**
@@ -644,7 +672,9 @@ async function handleSharedEvents(res, deps, token) {
644
672
  */
645
673
  async function handleViewerAsset(res, deps, file) {
646
674
  if (!deps.publicDir) {
647
- res.writeHead(500, { "Content-Type": "text/plain" });
675
+ res.writeHead(HTTP_STATUS.INTERNAL_SERVER_ERROR, {
676
+ "Content-Type": "text/plain",
677
+ });
648
678
  res.end("publicDir not configured");
649
679
  return;
650
680
  }
@@ -654,7 +684,7 @@ async function handleViewerAsset(res, deps, file) {
654
684
  const cssMatch = /^(styles|share-viewer)(?:\.[A-Za-z0-9_-]+)?\.css$/.test(file);
655
685
  const jsMatch = /^(viewer|chunk)(?:\.[A-Za-z0-9_-]+)?\.js$/.test(file);
656
686
  if (!cssMatch && !jsMatch) {
657
- res.writeHead(404, { "Content-Type": "text/plain" });
687
+ res.writeHead(HTTP_STATUS.NOT_FOUND, { "Content-Type": "text/plain" });
658
688
  res.end("not found");
659
689
  return;
660
690
  }
@@ -672,7 +702,7 @@ async function handleViewerAsset(res, deps, file) {
672
702
  : "text/css; charset=utf-8";
673
703
  try {
674
704
  const buf = await readFile(filePath);
675
- res.writeHead(200, {
705
+ res.writeHead(HTTP_STATUS.OK, {
676
706
  "Content-Type": contentType,
677
707
  "Cache-Control": cacheControl,
678
708
  "X-Content-Type-Options": "nosniff",
@@ -681,7 +711,7 @@ async function handleViewerAsset(res, deps, file) {
681
711
  res.end(buf);
682
712
  }
683
713
  catch {
684
- res.writeHead(404, { "Content-Type": "text/plain" });
714
+ res.writeHead(HTTP_STATUS.NOT_FOUND, { "Content-Type": "text/plain" });
685
715
  res.end("not found");
686
716
  }
687
717
  }
@@ -692,28 +722,30 @@ async function handleViewerAsset(res, deps, file) {
692
722
  */
693
723
  async function handleViewerImage(res, deps, token, file) {
694
724
  if (!deps.dataDir) {
695
- json(res, 500, { error: "dataDir not configured" });
725
+ json(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, {
726
+ error: "dataDir not configured",
727
+ });
696
728
  return;
697
729
  }
698
730
  const row = deps.store.getShareByToken(token);
699
731
  if (row?.shared_at == null) {
700
- json(res, 410, { error: "share unavailable" });
732
+ json(res, HTTP_STATUS.GONE, { error: "share unavailable" });
701
733
  return;
702
734
  }
703
735
  if (isExpired(row)) {
704
- json(res, 410, { error: "share expired" });
736
+ json(res, HTTP_STATUS.GONE, { error: "share expired" });
705
737
  return;
706
738
  }
707
739
  // Only allow simple filenames — reject any path separators / dotfiles / traversal.
708
740
  if (!/^[A-Za-z0-9._-]+$/.test(file) || file.startsWith(".")) {
709
- json(res, 404, { error: "invalid file" });
741
+ json(res, HTTP_STATUS.NOT_FOUND, { error: "invalid file" });
710
742
  return;
711
743
  }
712
744
  const sessionRoot = join(deps.dataDir, "sessions", row.session_id, "attachments");
713
745
  const filePath = join(sessionRoot, file);
714
746
  // Final realpath-style guard: must stay under <dataDir>/sessions/<sid>/attachments.
715
747
  if (!filePath.startsWith(sessionRoot + "/") && filePath !== sessionRoot) {
716
- res.writeHead(403);
748
+ res.writeHead(HTTP_STATUS.FORBIDDEN);
717
749
  res.end("Forbidden");
718
750
  return;
719
751
  }
@@ -740,11 +772,11 @@ async function handleViewerImage(res, deps, token, file) {
740
772
  const disposition = isInlineMime(mime) ? "inline" : "attachment";
741
773
  headers["Content-Disposition"] = buildContentDisposition(disposition, att.name);
742
774
  }
743
- res.writeHead(200, headers);
775
+ res.writeHead(HTTP_STATUS.OK, headers);
744
776
  res.end(buf);
745
777
  }
746
778
  catch {
747
- res.writeHead(404, { "Content-Type": "text/plain" });
779
+ res.writeHead(HTTP_STATUS.NOT_FOUND, { "Content-Type": "text/plain" });
748
780
  res.end("Not found");
749
781
  }
750
782
  }
@@ -810,11 +842,11 @@ async function handleRevoke(req, res, deps, sessionId) {
810
842
  body = {};
811
843
  }
812
844
  catch {
813
- json(res, 400, { error: "invalid JSON body" });
845
+ json(res, HTTP_STATUS.BAD_REQUEST, { error: "invalid JSON body" });
814
846
  return;
815
847
  }
816
848
  if (!body.token || typeof body.token !== "string") {
817
- json(res, 400, { error: "token required" });
849
+ json(res, HTTP_STATUS.BAD_REQUEST, { error: "token required" });
818
850
  return;
819
851
  }
820
852
  const row = deps.store.getShareByToken(body.token);
@@ -822,7 +854,7 @@ async function handleRevoke(req, res, deps, sessionId) {
822
854
  // Idempotent DELETE: row already gone (revoked or never existed).
823
855
  // We can't verify session ownership without a row, but the token
824
856
  // is opaque/random so leaking "revoked or never existed" is fine.
825
- json(res, 200, {
857
+ json(res, HTTP_STATUS.OK, {
826
858
  ok: true,
827
859
  token: body.token,
828
860
  revoked: false,
@@ -831,7 +863,7 @@ async function handleRevoke(req, res, deps, sessionId) {
831
863
  return;
832
864
  }
833
865
  if (row.session_id !== sessionId) {
834
- json(res, 404, { error: "share not found" });
866
+ json(res, HTTP_STATUS.NOT_FOUND, { error: "share not found" });
835
867
  return;
836
868
  }
837
869
  const revoked = deps.store.revokeShare(body.token);
@@ -847,7 +879,7 @@ async function handleRevoke(req, res, deps, sessionId) {
847
879
  }).catch(() => { });
848
880
  }
849
881
  }
850
- json(res, 200, {
882
+ json(res, HTTP_STATUS.OK, {
851
883
  ok: true,
852
884
  token: body.token,
853
885
  revoked,
@@ -871,27 +903,27 @@ async function handlePatchLabel(req, res, deps, sessionId) {
871
903
  body = {};
872
904
  }
873
905
  catch {
874
- json(res, 400, { error: "invalid JSON body" });
906
+ json(res, HTTP_STATUS.BAD_REQUEST, { error: "invalid JSON body" });
875
907
  return;
876
908
  }
877
909
  if (!body.token || typeof body.token !== "string") {
878
- json(res, 400, { error: "token required" });
910
+ json(res, HTTP_STATUS.BAD_REQUEST, { error: "token required" });
879
911
  return;
880
912
  }
881
913
  const row = deps.store.getShareByToken(body.token);
882
914
  if (!row) {
883
- json(res, 404, { error: "share not found" });
915
+ json(res, HTTP_STATUS.NOT_FOUND, { error: "share not found" });
884
916
  return;
885
917
  }
886
918
  if (row.session_id !== sessionId) {
887
- json(res, 404, { error: "share not found" });
919
+ json(res, HTTP_STATUS.NOT_FOUND, { error: "share not found" });
888
920
  return;
889
921
  }
890
922
  let ownerLabel = undefined;
891
923
  if ("owner_label" in body) {
892
924
  const v = validateLabel(body.owner_label, "owner_label", 1024);
893
925
  if (!v.ok) {
894
- json(res, 400, { error: v.reason });
926
+ json(res, HTTP_STATUS.BAD_REQUEST, { error: v.reason });
895
927
  return;
896
928
  }
897
929
  ownerLabel = v.value === "" ? null : v.value;
@@ -900,7 +932,7 @@ async function handlePatchLabel(req, res, deps, sessionId) {
900
932
  if ("display_name" in body) {
901
933
  const v = validateLabel(body.display_name, "display_name", 256);
902
934
  if (!v.ok) {
903
- json(res, 400, { error: v.reason });
935
+ json(res, HTTP_STATUS.BAD_REQUEST, { error: v.reason });
904
936
  return;
905
937
  }
906
938
  displayName = v.value === "" ? null : v.value;
@@ -911,10 +943,12 @@ async function handlePatchLabel(req, res, deps, sessionId) {
911
943
  deps.store.updateShareDisplayName(body.token, displayName);
912
944
  const after = deps.store.getShareByToken(body.token);
913
945
  if (!after) {
914
- json(res, 500, { error: "post-patch read failed" });
946
+ json(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, {
947
+ error: "post-patch read failed",
948
+ });
915
949
  return;
916
950
  }
917
- json(res, 200, {
951
+ json(res, HTTP_STATUS.OK, {
918
952
  token: after.token,
919
953
  session_id: sessionId,
920
954
  owner_label: after.owner_label,
@@ -928,7 +962,7 @@ async function handlePatchLabel(req, res, deps, sessionId) {
928
962
  */
929
963
  async function handleOwnerList(req, res, deps) {
930
964
  const rows = deps.store.listOwnerShares();
931
- json(res, 200, { shares: rows });
965
+ json(res, HTTP_STATUS.OK, { shares: rows });
932
966
  }
933
967
  /**
934
968
  * GET /api/v1/share/by — read the owner's default display_name (used by
@@ -937,7 +971,7 @@ async function handleOwnerList(req, res, deps) {
937
971
  */
938
972
  async function handleByGet(req, res, deps) {
939
973
  const value = deps.store.getOwnerPref(DEFAULT_DISPLAY_NAME_KEY) ?? null;
940
- json(res, 200, { value });
974
+ json(res, HTTP_STATUS.OK, { value });
941
975
  }
942
976
  /**
943
977
  * PUT /api/v1/share/by — set or clear the owner's default display_name.
@@ -953,20 +987,20 @@ async function handleByPut(req, res, deps) {
953
987
  body = {};
954
988
  }
955
989
  catch {
956
- json(res, 400, { error: "invalid JSON body" });
990
+ json(res, HTTP_STATUS.BAD_REQUEST, { error: "invalid JSON body" });
957
991
  return;
958
992
  }
959
993
  const v = validateLabel(body.value, "value", 256);
960
994
  if (!v.ok) {
961
- json(res, 400, { error: v.reason });
995
+ json(res, HTTP_STATUS.BAD_REQUEST, { error: v.reason });
962
996
  return;
963
997
  }
964
998
  if (v.value === "") {
965
999
  deps.store.clearOwnerPref(DEFAULT_DISPLAY_NAME_KEY);
966
- json(res, 200, { value: null });
1000
+ json(res, HTTP_STATUS.OK, { value: null });
967
1001
  }
968
1002
  else {
969
1003
  deps.store.setOwnerPref(DEFAULT_DISPLAY_NAME_KEY, v.value);
970
- json(res, 200, { value: v.value });
1004
+ json(res, HTTP_STATUS.OK, { value: v.value });
971
1005
  }
972
1006
  }
@@ -1,5 +1,6 @@
1
+ import { HTTP_STATUS } from "../http-status.js";
1
2
  export class SanitizeError extends Error {
2
- status = 400;
3
+ status = HTTP_STATUS.BAD_REQUEST;
3
4
  /** The event seq that triggered the hard-reject; surfaced to owner as event_id. */
4
5
  event_id;
5
6
  rule;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lelouchhe/webagent",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "A terminal-style web UI for ACP-compatible agents",
5
5
  "type": "module",
6
6
  "license": "MIT",