@lelouchhe/webagent 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +58 -23
  2. package/bin/webagent.mjs +119 -8
  3. package/config.toml +96 -3
  4. package/dist/index.html +64 -41
  5. package/dist/js/app.GSAIYHML.js +4 -0
  6. package/dist/js/chunk.AJZBJBMO.js +1 -0
  7. package/dist/js/chunk.CGWFHJI2.js +76 -0
  8. package/dist/js/chunk.D4ZYHJAM.js +1 -0
  9. package/dist/js/chunk.VZXGXFNN.js +5 -0
  10. package/dist/js/login.PYIK52HN.js +1 -0
  11. package/dist/js/viewer.6DT53STL.js +1 -0
  12. package/dist/login.html +49 -0
  13. package/dist/share-viewer.00gubshk.css +114 -0
  14. package/dist/share-viewer.html +53 -0
  15. package/dist/styles.012p32dz.css +1443 -0
  16. package/dist/sw.js +79 -27
  17. package/dist/theme-init.js +6 -0
  18. package/lib/agent-detect.js +110 -0
  19. package/lib/atomic-write.js +50 -0
  20. package/lib/attachment-dispatch.js +86 -0
  21. package/lib/attachment-interceptor.js +130 -0
  22. package/lib/attachment-labels.js +139 -0
  23. package/lib/attachments.js +154 -0
  24. package/lib/auth-middleware.js +102 -0
  25. package/lib/auth-store.js +269 -0
  26. package/lib/auth.js +89 -0
  27. package/lib/bootstrap.js +70 -0
  28. package/lib/bridge.js +244 -93
  29. package/lib/client-registry.js +60 -0
  30. package/lib/config.js +123 -9
  31. package/lib/daemon.js +175 -41
  32. package/lib/event-handler.js +209 -91
  33. package/lib/log-fmt.js +67 -0
  34. package/lib/log.js +83 -0
  35. package/lib/message-cleanup.js +48 -0
  36. package/lib/mode-bucket.js +62 -0
  37. package/lib/preflight.js +195 -0
  38. package/lib/push-service.js +338 -45
  39. package/lib/routes.js +1202 -144
  40. package/lib/server.js +149 -33
  41. package/lib/session-manager.js +164 -18
  42. package/lib/session-state.js +160 -0
  43. package/lib/sessions-anchor.js +28 -0
  44. package/lib/share/cleanup.js +45 -0
  45. package/lib/share/routes.js +972 -0
  46. package/lib/share/sanitize.js +179 -0
  47. package/lib/sse-manager.js +94 -8
  48. package/lib/sse-ticket.js +45 -0
  49. package/lib/startup-checks.js +94 -0
  50. package/lib/store.js +624 -30
  51. package/lib/title-service.js +42 -9
  52. package/lib/tokens.js +50 -0
  53. package/lib/types.js +23 -0
  54. package/package.json +38 -4
  55. package/dist/js/app.2562YGRO.js +0 -10
  56. package/dist/styles.008ve1hx.css +0 -669
  57. package/lib/shared/constants.js +0 -17
package/dist/sw.js CHANGED
@@ -1,12 +1,20 @@
1
1
  // Minimal service worker for PWA installability + push notifications.
2
2
  // No offline caching — app requires SSE connection.
3
3
 
4
- self.addEventListener('install', () => self.skipWaiting());
5
- self.addEventListener('activate', (e) => e.waitUntil(self.clients.claim()));
4
+ self.addEventListener("install", () => self.skipWaiting());
5
+ self.addEventListener("activate", (e) => e.waitUntil(self.clients.claim()));
6
6
 
7
7
  // --- Push notifications ---
8
+ //
9
+ // Two payload shapes from the server:
10
+ // { kind: "notify", title, body, tag, data } → showNotification
11
+ // { kind: "close", tag } → close any existing
12
+ // notifications with that
13
+ // tag (cross-device recall
14
+ // for acked/consumed inbox
15
+ // messages).
8
16
 
9
- self.addEventListener('push', (e) => {
17
+ self.addEventListener("push", (e) => {
10
18
  if (!e.data) return;
11
19
 
12
20
  let payload;
@@ -16,36 +24,80 @@ self.addEventListener('push', (e) => {
16
24
  return;
17
25
  }
18
26
 
19
- const { title, body, data } = payload;
20
- e.waitUntil(
21
- self.registration.showNotification(title || 'WebAgent', {
22
- body: body || '',
23
- icon: '/icon-192.png',
24
- badge: '/icon-192.png',
25
- tag: data?.sessionId || 'default',
26
- data: data || {},
27
- })
28
- );
27
+ if (payload.kind === "close" && payload.tag) {
28
+ e.waitUntil(closeByTag(payload.tag));
29
+ return;
30
+ }
31
+
32
+ // Backward-compat: older payloads had no `kind`. Treat as notify.
33
+ const { title, body, tag, data } = payload;
34
+ const finalTag = tag || data?.sessionId || "default";
35
+ e.waitUntil(showNotify(title, body, finalTag, data));
36
+ });
37
+
38
+ // iOS Safari's tag-based notification collapse in showNotification is
39
+ // unreliable: notifications stack in Notification Center even with
40
+ // identical tag. The community-standard workaround is to explicitly close
41
+ // any existing same-tag notifications in the SW before showing the new
42
+ // one. Harmless on conformant platforms (they already replace).
43
+ //
44
+ // NOTE: In iOS 17 PWA dogfood this workaround did NOT actually collapse
45
+ // banners either — two same-tag pushes still produced two banners. The
46
+ // underlying cause appears to be in WebKit / APNs and is outside our
47
+ // control. We keep this code because (a) it's correct per the Web
48
+ // Notifications spec, (b) it works on Chrome/Firefox/Android, and
49
+ // (c) future iOS versions may honor it.
50
+ async function showNotify(title, body, tag, data) {
51
+ const existing = await self.registration.getNotifications({ tag });
52
+ for (const n of existing) n.close();
53
+ await self.registration.showNotification(title || "WebAgent", {
54
+ body: body || "",
55
+ icon: "/icon-192.png",
56
+ badge: "/icon-192.png",
57
+ tag,
58
+ data: data || {},
59
+ });
60
+ }
61
+
62
+ async function closeByTag(tag) {
63
+ const notifications = await self.registration.getNotifications({ tag });
64
+ for (const n of notifications) n.close();
65
+ }
66
+
67
+ // Allow page-side code to request close of a message-tagged notification
68
+ // (frontend dispatches this on `message_acked` / `message_consumed` so the
69
+ // local device's banner disappears without waiting for the server's silent
70
+ // close push).
71
+ self.addEventListener("message", (e) => {
72
+ const msg = e.data;
73
+ if (msg && msg.type === "close-notification" && msg.tag) {
74
+ e.waitUntil(closeByTag(msg.tag));
75
+ }
29
76
  });
30
77
 
31
- self.addEventListener('notificationclick', (e) => {
78
+ self.addEventListener("notificationclick", (e) => {
32
79
  e.notification.close();
33
80
 
34
- const sessionId = e.notification.data?.sessionId;
35
- const urlHash = sessionId ? `/#${sessionId}` : '/';
81
+ const data = e.notification.data || {};
82
+ // For inbox messages, route to the bound session (if set) or root.
83
+ // For session events, route to the session.
84
+ const sessionId = data.sessionId;
85
+ const urlHash = sessionId ? `/#${sessionId}` : "/";
36
86
 
37
87
  e.waitUntil(
38
- self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => {
39
- // Focus existing window if open
40
- for (const client of clients) {
41
- if (client.url.includes(self.location.origin)) {
42
- client.focus();
43
- client.postMessage({ type: 'navigate', sessionId });
44
- return;
88
+ self.clients
89
+ .matchAll({ type: "window", includeUncontrolled: true })
90
+ .then((clients) => {
91
+ // Focus existing window if open
92
+ for (const client of clients) {
93
+ if (client.url.includes(self.location.origin)) {
94
+ client.focus();
95
+ client.postMessage({ type: "navigate", sessionId });
96
+ return;
97
+ }
45
98
  }
46
- }
47
- // Otherwise open a new window
48
- return self.clients.openWindow(urlHash);
49
- })
99
+ // Otherwise open a new window
100
+ return self.clients.openWindow(urlHash);
101
+ }),
50
102
  );
51
103
  });
@@ -0,0 +1,6 @@
1
+ // Set theme attribute synchronously before stylesheet renders, to avoid FOUC.
2
+ // Extracted from inline <script> so a strict CSP `script-src 'self'` works.
3
+ document.documentElement.setAttribute(
4
+ "data-theme",
5
+ localStorage.getItem("theme") || "auto",
6
+ );
@@ -0,0 +1,110 @@
1
+ // Agent auto-detection.
2
+ //
3
+ // When `agent_cmd` is the sentinel "auto" (the new default), we scan PATH
4
+ // in two passes:
5
+ //
6
+ // L1 — ACP-ready binaries that speak the protocol directly. First hit
7
+ // wins; we return its exact run command. Order is by perceived
8
+ // popularity / native-vs-wrapper, not alphabetical — see comments
9
+ // on each row.
10
+ //
11
+ // L2 — Bare vendor CLIs whose ACP support requires a separate adapter
12
+ // package. We don't auto-`npx` them (silent network downloads /
13
+ // supply-chain trust / startup latency are all real costs); we
14
+ // just tell the user which adapter to install.
15
+ //
16
+ // If a user explicitly set `agent_cmd` in their TOML, this module is
17
+ // skipped — explicit config always wins. Detection is a first-run
18
+ // affordance, not a policy.
19
+ import { spawnSync } from "node:child_process";
20
+ // L1: ACP-ready binaries. Each row is "if `bin` is in PATH, run `cmd`".
21
+ // Verified against https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json.
22
+ export const L1_CANDIDATES = [
23
+ { bin: "copilot", cmd: "copilot --acp", label: "GitHub Copilot CLI" },
24
+ { bin: "gemini", cmd: "gemini --acp", label: "Gemini CLI" },
25
+ { bin: "opencode", cmd: "opencode acp", label: "OpenCode" },
26
+ {
27
+ bin: "claude-agent-acp",
28
+ cmd: "claude-agent-acp",
29
+ label: "Claude Code (via ACP adapter)",
30
+ },
31
+ { bin: "codex-acp", cmd: "codex-acp", label: "Codex (via ACP adapter)" },
32
+ {
33
+ bin: "qwen",
34
+ cmd: "qwen --acp --experimental-skills",
35
+ label: "Qwen Code",
36
+ },
37
+ ];
38
+ // L2: Bare vendor CLIs that need a separate adapter to speak ACP.
39
+ export const L2_CANDIDATES = [
40
+ {
41
+ bin: "claude",
42
+ label: "Claude Code",
43
+ adapter: "@agentclientprotocol/claude-agent-acp",
44
+ install: "npm i -g @agentclientprotocol/claude-agent-acp",
45
+ },
46
+ {
47
+ bin: "codex",
48
+ label: "Codex",
49
+ adapter: "@zed-industries/codex-acp",
50
+ install: "npm i -g @zed-industries/codex-acp",
51
+ },
52
+ ];
53
+ function inPath(bin) {
54
+ const which = process.platform === "win32" ? "where" : "which";
55
+ try {
56
+ const r = spawnSync(which, [bin], { stdio: "ignore" });
57
+ return r.status === 0;
58
+ }
59
+ catch {
60
+ return false;
61
+ }
62
+ }
63
+ export function detectAgent() {
64
+ for (const c of L1_CANDIDATES) {
65
+ if (inPath(c.bin)) {
66
+ return { ok: true, cmd: c.cmd, bin: c.bin, label: c.label };
67
+ }
68
+ }
69
+ for (const c of L2_CANDIDATES) {
70
+ if (inPath(c.bin)) {
71
+ return {
72
+ ok: false,
73
+ kind: "l2-hint",
74
+ bin: c.bin,
75
+ label: c.label,
76
+ adapter: c.adapter,
77
+ install: c.install,
78
+ };
79
+ }
80
+ }
81
+ return { ok: false, kind: "none" };
82
+ }
83
+ // Format a multi-line, copy-pasteable hint for the operator. server.ts
84
+ // prints this verbatim and exits non-zero when detection fails.
85
+ export function formatDetectionFailure(result) {
86
+ if (result.kind === "l2-hint") {
87
+ return [
88
+ `[bridge] detected ${result.label} (${result.bin}) but no ACP adapter.`,
89
+ ``,
90
+ ` Install the adapter to use ${result.label} with webagent:`,
91
+ ` ${result.install}`,
92
+ ``,
93
+ ` Or set agent_cmd in config.toml to a different ACP agent.`,
94
+ ].join("\n");
95
+ }
96
+ return [
97
+ `[bridge] no ACP-ready agent found in PATH.`,
98
+ ``,
99
+ ` Install one of:`,
100
+ ` npm i -g @github/copilot # Copilot CLI`,
101
+ ` npm i -g @google/gemini-cli # Gemini CLI`,
102
+ ` npm i -g opencode-ai # OpenCode`,
103
+ ``,
104
+ ` Or for Claude Code / Codex, install the ACP adapter:`,
105
+ ` npm i -g @agentclientprotocol/claude-agent-acp`,
106
+ ` npm i -g @zed-industries/codex-acp`,
107
+ ``,
108
+ ` Then re-run webagent (or set agent_cmd in config.toml).`,
109
+ ].join("\n");
110
+ }
@@ -0,0 +1,50 @@
1
+ // Atomic file writes via tmp + rename(2).
2
+ //
3
+ // rename(2) is atomic within a filesystem on POSIX (and on Windows on the
4
+ // same volume), so concurrent readers only ever see the old file, the new
5
+ // file, or ENOENT — never a half-written or zero-length file. Plain
6
+ // writeFile is open(O_TRUNC) → write → close, which exposes a
7
+ // truncate-but-not-yet-written race window where polling readers see ""
8
+ // and crash on JSON.parse. The flake that motivated extracting this
9
+ // helper was test/daemon.test.ts reading the PID file mid-write.
10
+ //
11
+ // Used for the daemon PID file (`webagent.pid`) and the auth token store
12
+ // (`auth.json`). Both have a single writer process, so no inter-process
13
+ // lock is needed — just atomicity against a polling reader.
14
+ //
15
+ // proper-lockfile is also a dependency in this repo but it provides
16
+ // inter-process *locking* (sentinel-dir semaphore), not atomic writes.
17
+ // Locking plus naive writeFile would still expose the truncate race to
18
+ // readers that don't hold the lock.
19
+ import { writeFileSync, renameSync, chmodSync } from "node:fs";
20
+ import { open, chmod, rename } from "node:fs/promises";
21
+ /**
22
+ * Synchronous atomic write. Used in code paths where awaiting isn't
23
+ * convenient (e.g. supervisor bootstrap before signal handlers are
24
+ * installed).
25
+ */
26
+ export function atomicWriteFileSync(path, data, mode) {
27
+ const tmp = `${path}.tmp`;
28
+ writeFileSync(tmp, data, mode != null ? { mode } : undefined);
29
+ if (mode != null)
30
+ chmodSync(tmp, mode);
31
+ renameSync(tmp, path);
32
+ }
33
+ /**
34
+ * Async atomic write. `mode` is applied via open()'s mode arg AND a
35
+ * follow-up chmod so the final file ends up with the desired perms
36
+ * regardless of umask or whether the temp file pre-existed.
37
+ */
38
+ export async function atomicWriteFile(path, data, mode) {
39
+ const tmp = `${path}.tmp`;
40
+ const fh = await open(tmp, "w", mode);
41
+ try {
42
+ await fh.writeFile(data);
43
+ }
44
+ finally {
45
+ await fh.close();
46
+ }
47
+ if (mode != null)
48
+ await chmod(tmp, mode);
49
+ await rename(tmp, path);
50
+ }
@@ -0,0 +1,86 @@
1
+ import { readFile, realpath } from "node:fs/promises";
2
+ import { pathToFileURL } from "node:url";
3
+ import { isInsideSessionAttachments } from "./sessions-anchor.js";
4
+ const NOOP_LOGGER = { warn: () => { } };
5
+ /**
6
+ * Builds the ACP prompt block for one client-supplied attachment. Returns
7
+ * a fallback text block on any failure (DB miss, disk miss, anchor breach,
8
+ * cross-session reference) so the prompt turn never gets stuck in a retry
9
+ * loop just because one image vanished.
10
+ *
11
+ * Trust boundary (decision 10 in uploads-plan v2.6): client only supplies
12
+ * `attachmentId`. Everything else (realpath, anchor, MIME for read) comes
13
+ * from the server-side row.
14
+ */
15
+ export class AttachmentDispatcher {
16
+ store;
17
+ sessionsAnchor;
18
+ logger;
19
+ constructor(store, sessionsAnchor, logger = NOOP_LOGGER) {
20
+ this.store = store;
21
+ this.sessionsAnchor = sessionsAnchor;
22
+ this.logger = logger;
23
+ }
24
+ async dispatch(sessionId, ref) {
25
+ const fallback = (reason) => {
26
+ this.logger.warn(`[attachments] dispatch fallback (${reason}) for ${sessionId}/${ref.attachmentId}`);
27
+ return {
28
+ type: "text",
29
+ text: `[attachment removed: ${ref.displayName}]`,
30
+ };
31
+ };
32
+ // Reject any client trying to smuggle a uri / data / path. The shape
33
+ // of AttachmentRef already forbids these statically; this guard is a
34
+ // belt-and-suspenders for callers passing a wider object via `as any`.
35
+ const wider = ref;
36
+ if (typeof wider.uri === "string" ||
37
+ typeof wider.data === "string" ||
38
+ typeof wider.path === "string") {
39
+ return fallback("client_supplied_external_field");
40
+ }
41
+ const row = this.store.getAttachment(sessionId, ref.attachmentId);
42
+ if (!row)
43
+ return fallback("row_not_found");
44
+ // Cross-session reference — the row exists but for a DIFFERENT session.
45
+ // store.getAttachment scopes by session_id so this should already be
46
+ // caught by row_not_found, but assert defensively.
47
+ if (row.session_id !== sessionId) {
48
+ return fallback("cross_session");
49
+ }
50
+ // Anchor check on the stored realpath. If the file was moved out from
51
+ // under us, or a future bug let an attacker inject a row with a path
52
+ // outside SESSIONS_ANCHOR/<sid>/attachments/, we MUST refuse to dispatch
53
+ // it as a `file://` URI — the agent would happily read it.
54
+ let resolvedPath;
55
+ try {
56
+ resolvedPath = await realpath(row.realpath);
57
+ }
58
+ catch {
59
+ return fallback("realpath_failed");
60
+ }
61
+ if (!isInsideSessionAttachments(this.sessionsAnchor, sessionId, resolvedPath)) {
62
+ return fallback("path_outside_anchor");
63
+ }
64
+ if (ref.kind === "image") {
65
+ try {
66
+ const buf = await readFile(resolvedPath);
67
+ return {
68
+ type: "image",
69
+ data: buf.toString("base64"),
70
+ mimeType: row.mime,
71
+ };
72
+ }
73
+ catch {
74
+ return fallback("read_failed");
75
+ }
76
+ }
77
+ // kind === "file" → ACP resource_link with file:// URI built from the
78
+ // realpath (NOT from any client-supplied string).
79
+ return {
80
+ type: "resource_link",
81
+ uri: pathToFileURL(resolvedPath).toString(),
82
+ name: row.name,
83
+ mimeType: row.mime,
84
+ };
85
+ }
86
+ }
@@ -0,0 +1,130 @@
1
+ // Permission auto-approve interceptor for attachment reads.
2
+ //
3
+ // Plan §1.4 (uploads-plan v2.6, lines 105-183). When an agent requests
4
+ // permission to *read* a path that we know is one of the user-uploaded
5
+ // session attachments, auto-approve with `allow_once`. Any deviation
6
+ // from the strict allowlist falls through to the user prompt.
7
+ //
8
+ // Defenses (mirrored from the plan):
9
+ // F1 kind === "read" only
10
+ // F2 every locations[].path realpath ∈ session attachment realpaths
11
+ // F4 schema gate — locations must exist; if rawInput has known path
12
+ // keys they must also realpath into the attachment set; if it has
13
+ // none, allow but bump schemaDrift counter so we notice when the
14
+ // Copilot CLI changes its raw-input field names.
15
+ // F6 any realpath / DB error → fall through (deny auto-approve, not
16
+ // deny the user — the user dialog still shows)
17
+ // F7 four counters, dumped hourly via attachInterceptorLogger.
18
+ import { realpath as fsRealpath } from "node:fs/promises";
19
+ const READ_TOOL_ALLOWLIST = new Set(["view", "read_file"]);
20
+ const RAWINPUT_PATH_KEYS = ["path", "filePath", "file"];
21
+ export function createCounters() {
22
+ return {
23
+ autoAllowed: 0,
24
+ fellThrough: 0,
25
+ realpathErrors: 0,
26
+ schemaDrift: 0,
27
+ };
28
+ }
29
+ async function checkLocationPaths(locations, attachmentRealpaths, counters, log, miss) {
30
+ for (const loc of locations) {
31
+ let rp;
32
+ try {
33
+ rp = await fsRealpath(loc.path);
34
+ }
35
+ catch (e) {
36
+ counters.realpathErrors++;
37
+ log.warn?.("attachment interceptor realpath error", {
38
+ path: loc.path,
39
+ error: e.code ?? e.message,
40
+ });
41
+ return miss("realpath_failed");
42
+ }
43
+ if (!attachmentRealpaths.has(rp))
44
+ return miss("path_not_in_attachments");
45
+ }
46
+ return true;
47
+ }
48
+ async function checkRawInputPaths(raw, attachmentRealpaths, miss) {
49
+ let foundAnyKey = false;
50
+ for (const key of RAWINPUT_PATH_KEYS) {
51
+ const v = raw[key];
52
+ if (typeof v !== "string")
53
+ continue;
54
+ foundAnyKey = true;
55
+ let rp;
56
+ try {
57
+ rp = await fsRealpath(v);
58
+ }
59
+ catch {
60
+ return { ok: false, result: miss("rawinput_realpath_failed") };
61
+ }
62
+ if (!attachmentRealpaths.has(rp)) {
63
+ return { ok: false, result: miss("rawinput_path_mismatch") };
64
+ }
65
+ }
66
+ return { ok: true, foundAnyKey };
67
+ }
68
+ /**
69
+ * Returns true iff the request matches the strict
70
+ * "agent reading a known session attachment" pattern.
71
+ *
72
+ * Returning false does NOT deny the user prompt — it just declines to
73
+ * auto-approve, so the normal permission UI continues to render.
74
+ */
75
+ export async function shouldAutoApproveAttachmentRead(ev, deps) {
76
+ const { counters, logger } = deps;
77
+ const log = logger ?? {};
78
+ const miss = (reason) => {
79
+ counters.fellThrough++;
80
+ log.debug?.("attachment auto-allow miss", {
81
+ reason,
82
+ sessionId: ev.sessionId,
83
+ toolKind: ev.toolKind,
84
+ toolName: ev.toolName,
85
+ });
86
+ return false;
87
+ };
88
+ if (ev.toolKind !== "read")
89
+ return miss("tool_kind_not_read");
90
+ if (typeof ev.toolName === "string" &&
91
+ !READ_TOOL_ALLOWLIST.has(ev.toolName)) {
92
+ return miss("tool_name_not_allowlisted");
93
+ }
94
+ if (!Array.isArray(ev.locations) || ev.locations.length === 0) {
95
+ return miss("no_locations");
96
+ }
97
+ let attachmentRealpaths;
98
+ try {
99
+ attachmentRealpaths = new Set(deps.listAttachmentRealpaths(ev.sessionId));
100
+ }
101
+ catch (e) {
102
+ log.warn?.("attachment interceptor db error", {
103
+ sessionId: ev.sessionId,
104
+ error: e.message,
105
+ });
106
+ return miss("db_error");
107
+ }
108
+ const locOk = await checkLocationPaths(ev.locations, attachmentRealpaths, counters, log, miss);
109
+ if (locOk !== true)
110
+ return locOk;
111
+ const raw = ev.rawInput;
112
+ if (raw && typeof raw === "object") {
113
+ const r = await checkRawInputPaths(raw, attachmentRealpaths, miss);
114
+ if (!r.ok)
115
+ return r.result;
116
+ if (!r.foundAnyKey) {
117
+ counters.schemaDrift++;
118
+ log.warn?.("attachment interceptor schema drift: rawInput has no known path key", { rawInputKeys: Object.keys(raw) });
119
+ deps.onSchemaDrift?.({ rawInputKeys: Object.keys(raw) });
120
+ }
121
+ }
122
+ counters.autoAllowed++;
123
+ log.info?.("attachment auto-allowed", {
124
+ sessionId: ev.sessionId,
125
+ toolKind: ev.toolKind,
126
+ toolName: ev.toolName,
127
+ locations: ev.locations.map((l) => l.path),
128
+ });
129
+ return true;
130
+ }
@@ -0,0 +1,139 @@
1
+ import { basename } from "node:path";
2
+ /**
3
+ * Build a label map from raw attachment rows. Keys: realpath +
4
+ * basename(realpath). Value: `<name> [#<id4>]`.
5
+ *
6
+ * `id` is sliced to 4 chars unconditionally — the post-hoc
7
+ * disambiguation suffix doubles as a stable reference anchor in
8
+ * conversation ("the abc1 file"). No collision detection.
9
+ */
10
+ export function buildLabelMap(rows) {
11
+ const m = new Map();
12
+ for (const r of rows) {
13
+ const label = `${r.name} [#${r.id.slice(0, 4)}]`;
14
+ m.set(r.realpath, label);
15
+ const bn = basename(r.realpath);
16
+ // Don't shadow an existing realpath entry. Realpaths are
17
+ // absolute so collision with a basename is extremely rare; this
18
+ // is defensive only.
19
+ if (!m.has(bn))
20
+ m.set(bn, label);
21
+ }
22
+ return m;
23
+ }
24
+ /** Substring-replace each map key with its label. Longer keys first
25
+ * so the basename entry doesn't clobber a full-path occurrence. */
26
+ function rewriteString(s, map) {
27
+ if (map.size === 0)
28
+ return s;
29
+ // Sort keys descending by length: ensures e.g. `/a/b/file.pdf`
30
+ // gets replaced before its `file.pdf` basename.
31
+ const keys = [...map.keys()].sort((a, b) => b.length - a.length);
32
+ let out = s;
33
+ for (const k of keys) {
34
+ if (!out.includes(k))
35
+ continue;
36
+ out = out.split(k).join(map.get(k) ?? "");
37
+ }
38
+ return out;
39
+ }
40
+ /**
41
+ * Replace internal uuid attachment paths with user-visible labels
42
+ * in the fields users see. Pure: returns a new event object when
43
+ * any field changed, otherwise the original reference.
44
+ *
45
+ * Touched:
46
+ * - `tool_call.title` (substring rewrite)
47
+ * - `tool_call.rawInput.path` (exact-match replace)
48
+ * - `permission_request.title` (substring rewrite)
49
+ *
50
+ * NOT touched (deliberate):
51
+ * - `permission_request.rawInput` — F2 interceptor
52
+ * (`attachment-interceptor.ts`) authoritatively reads this for
53
+ * realpath-equality auto-approve. Mutating it here would silently
54
+ * break the security gate even though enrich runs after the
55
+ * decision today; future read paths must not be poisoned.
56
+ * - `permission_request.locations[].path` — ACP protocol field,
57
+ * not user-visible in our UI.
58
+ * - `user_message.attachments[].displayName` — rendered as
59
+ * `<a class="user-file" download>` link, modality is different.
60
+ * - All other event types — pass through.
61
+ */
62
+ export function enrichEventForDisplay(event, map) {
63
+ if (map.size === 0)
64
+ return event;
65
+ if (event.type === "tool_call") {
66
+ const newTitle = rewriteString(event.title, map);
67
+ let newRawInput = event.rawInput;
68
+ if (event.rawInput &&
69
+ typeof event.rawInput === "object" &&
70
+ typeof event.rawInput.path === "string") {
71
+ const replaced = map.get(event.rawInput.path);
72
+ if (replaced) {
73
+ newRawInput = { ...event.rawInput, path: replaced };
74
+ }
75
+ }
76
+ if (newTitle !== event.title || newRawInput !== event.rawInput) {
77
+ return { ...event, title: newTitle, rawInput: newRawInput };
78
+ }
79
+ return event;
80
+ }
81
+ if (event.type === "permission_request") {
82
+ const newTitle = rewriteString(event.title, map);
83
+ if (newTitle !== event.title) {
84
+ return { ...event, title: newTitle };
85
+ }
86
+ return event;
87
+ }
88
+ return event;
89
+ }
90
+ /**
91
+ * JSON-string variant of `enrichEventForDisplay` for the replay
92
+ * path: `store.getEvents()` returns rows whose `data` is a JSON
93
+ * string and whose `type` lives in a sibling column. Same chokepoint
94
+ * spirit as `reSignAttachmentUrlsInJson` in auth.ts.
95
+ *
96
+ * Mutates `row.data` in place when enrichment changes anything
97
+ * (returning the row unchanged otherwise).
98
+ */
99
+ export function enrichStoredEventDataForDisplay(type, data, map) {
100
+ if (map.size === 0)
101
+ return data;
102
+ if (type !== "tool_call" && type !== "permission_request")
103
+ return data;
104
+ let parsed;
105
+ try {
106
+ parsed = JSON.parse(data);
107
+ }
108
+ catch {
109
+ return data;
110
+ }
111
+ // Synthesize a minimal event shape so we can reuse the object-level
112
+ // enricher. `sessionId` etc. don't matter to enrich logic.
113
+ const ev = { type, ...parsed };
114
+ const out = enrichEventForDisplay(ev, map);
115
+ if (out === ev)
116
+ return data;
117
+ // Strip the synthesized `type` to match the stored shape.
118
+ const { type: _t, ...rest } = out;
119
+ void _t;
120
+ return JSON.stringify(rest);
121
+ }
122
+ /**
123
+ * Replay-path egress chokepoint: rewrites each stored event row's
124
+ * `data` field with attachment labels in place. Mutates rows.
125
+ *
126
+ * Use anywhere `store.getEvents` results are sent to clients
127
+ * (history GET, share viewer). Live SSE goes through
128
+ * `SseManager.sendEvent` which has its own object-level enricher.
129
+ */
130
+ export function enrichStoredEventsForDisplay(events, map) {
131
+ if (map.size === 0)
132
+ return events;
133
+ for (const ev of events) {
134
+ if (typeof ev.data === "string") {
135
+ ev.data = enrichStoredEventDataForDisplay(ev.type, ev.data, map);
136
+ }
137
+ }
138
+ return events;
139
+ }