@oar-sh/oar 0.9.10 → 0.9.11

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 (104) hide show
  1. package/CHANGELOG.md +314 -1
  2. package/README.md +156 -972
  3. package/package.json +13 -13
  4. package/server/README.md +95 -22
  5. package/server/claude-worker/claude-session-process.mjs +89 -11
  6. package/server/claude-worker/claude-session-worker.mjs +9 -1
  7. package/server/copilot-worker/copilot-sdk-session-process.mjs +76 -11
  8. package/server/copilot-worker/copilot-sdk-session-worker.mjs +6 -0
  9. package/server/db-schema.mjs +59 -2
  10. package/server/grok-worker/acp-client.mjs +2 -1
  11. package/server/grok-worker/acp-host-services.mjs +4 -3
  12. package/server/migrations/0013-previews.mjs +130 -0
  13. package/server/migrations/0014-conversation-groups.mjs +89 -0
  14. package/server/migrations/0015-conversation-group-position.mjs +76 -0
  15. package/server/migrations/0016-share-access-log.mjs +110 -0
  16. package/server/public/app/about-ui.js +73 -0
  17. package/server/public/app/about-view.mjs +234 -0
  18. package/server/public/app/action-confirmations.js +31 -14
  19. package/server/public/app/agent-exchange-fold-view.mjs +40 -101
  20. package/server/public/app/api-client.js +251 -16
  21. package/server/public/app/ask-user-view.js +17 -15
  22. package/server/public/app/bootstrap.js +158 -32
  23. package/server/public/app/chat-actions-menu-state.mjs +20 -10
  24. package/server/public/app/context-menu.mjs +90 -6
  25. package/server/public/app/conversation-group-drag.mjs +191 -0
  26. package/server/public/app/conversation-group-order.mjs +265 -0
  27. package/server/public/app/conversation-group-state.mjs +100 -0
  28. package/server/public/app/conversation-list-drag.mjs +449 -0
  29. package/server/public/app/conversation-turn-state.mjs +81 -0
  30. package/server/public/app/conversation-view.js +136 -93
  31. package/server/public/app/cwd-picker.js +1 -1
  32. package/server/public/app/hidden-messages-view.mjs +312 -0
  33. package/server/public/app/host-suspend-ui.js +7 -1
  34. package/server/public/app/journal-view.js +787 -22
  35. package/server/public/app/preview-cards.mjs +117 -31
  36. package/server/public/app/preview-command.mjs +10 -1
  37. package/server/public/app/relay-board-view.js +19 -6
  38. package/server/public/app/relay-restart-error.mjs +16 -0
  39. package/server/public/app/settings-modal.js +40 -8
  40. package/server/public/app/settings-tabs.js +1 -1
  41. package/server/public/app/share-format.mjs +49 -0
  42. package/server/public/app/share-preview-marks.mjs +167 -0
  43. package/server/public/app/share-state.js +109 -0
  44. package/server/public/app/shared-conversations-settings.js +480 -0
  45. package/server/public/app/socket-handlers.js +80 -14
  46. package/server/public/app/store.js +96 -30
  47. package/server/public/app/subagent-row.mjs +3 -3
  48. package/server/public/app/subagent-viewer.mjs +30 -6
  49. package/server/public/app/thinking-follow.js +0 -4
  50. package/server/public/app/transcript-jump-bottom-view.mjs +194 -20
  51. package/server/public/app/transcript-jump-bottom.mjs +132 -21
  52. package/server/public/app/transcript-viewport-anchor.mjs +140 -0
  53. package/server/public/index.html +386 -15
  54. package/server/relay-tools.md +2 -1
  55. package/server/relay.mjs +2 -1
  56. package/server/repositories/conversation-group-repository.mjs +444 -0
  57. package/server/repositories/message-repository.mjs +1 -0
  58. package/server/repositories/session-repository.mjs +12 -37
  59. package/server/repositories/share-repository.mjs +322 -0
  60. package/server/repositories/share-visibility-statements.mjs +10 -0
  61. package/server/routes/about-routes.mjs +15 -0
  62. package/server/routes/cache-routes.mjs +4 -0
  63. package/server/routes/conversation-group-routes.mjs +171 -0
  64. package/server/routes/messages-routes.mjs +184 -65
  65. package/server/routes/preview-routes.mjs +9 -1
  66. package/server/routes/sessions-routes.mjs +771 -193
  67. package/server/server-runtime.mjs +316 -100
  68. package/server/services/about-service.mjs +461 -0
  69. package/server/services/background-task-store.mjs +128 -0
  70. package/server/services/background-work-view.mjs +228 -0
  71. package/server/services/cache-rebuild-service.mjs +30 -0
  72. package/server/services/delete-archive-service.mjs +4 -1
  73. package/server/services/detached-worker-watch-service.mjs +150 -0
  74. package/server/services/hidden-message-list.mjs +21 -0
  75. package/server/services/host-suspend-command.mjs +2 -1
  76. package/server/services/message-pin-list.mjs +1 -1
  77. package/server/services/preview-health-probe.mjs +8 -1
  78. package/server/services/preview-proxy-server.mjs +8 -4
  79. package/server/services/preview-registry-service.mjs +676 -26
  80. package/server/services/preview-static-handler.mjs +40 -7
  81. package/server/services/preview-store.mjs +274 -0
  82. package/server/services/rate-limit-service.mjs +12 -1
  83. package/server/services/relay-auth.mjs +183 -0
  84. package/server/services/relay-cli-launcher-service.mjs +3 -1
  85. package/server/services/relay-event-service.mjs +17 -0
  86. package/server/services/remote-relay-dispatcher.mjs +57 -5
  87. package/server/services/request-boundary.mjs +183 -0
  88. package/server/services/session-activity-tracker.mjs +227 -19
  89. package/server/services/session-worker-launch-service.mjs +190 -6
  90. package/server/services/session-worker-process-service.mjs +10 -6
  91. package/server/services/share-access-log-service.mjs +123 -0
  92. package/server/services/share-path-redaction.mjs +424 -0
  93. package/server/services/status-event-service.mjs +7 -3
  94. package/server/services/tunnel-worker-path-guard.mjs +24 -41
  95. package/server/services/windows-autostart-service.mjs +6 -1
  96. package/server/services/windows-boot-autostart-service.mjs +207 -23
  97. package/shared/preview-tool-core.mjs +15 -5
  98. package/shared/remote-relay-contract.mjs +3 -1
  99. package/shared/share-path-privacy.mjs +30 -0
  100. package/shared/windows-system-tools.mjs +106 -0
  101. package/shared/worker-runtime/background-task-digest.mjs +30 -0
  102. package/shared/worker-runtime/heartbeat.mjs +24 -0
  103. package/shared/worker-runtime/kept-posts.mjs +83 -0
  104. package/shared/worker-runtime/worker-websocket-link.mjs +47 -15
@@ -3,6 +3,7 @@
3
3
  import { execFile, execFileSync } from 'child_process';
4
4
 
5
5
  import { collectProcessDescendants, isChildProcessOf } from '../../shared/worker-runtime/process-tree.mjs';
6
+ import { windowsSystemToolPath } from '../../shared/windows-system-tools.mjs';
6
7
 
7
8
  function normalizeSessionId(value) {
8
9
  const text = String(value || '').trim();
@@ -319,8 +320,9 @@ export function createSessionWorkerProcessInspector({
319
320
 
320
321
  function getWindowsProcessSnapshot() {
321
322
  if (platform !== 'win32') return [];
322
- const output = execFileSyncImpl('powershell.exe', ['-NoProfile', '-Command', WINDOWS_PROCESS_SNAPSHOT_SCRIPT], {
323
- stdio: ['ignore', 'pipe', 'ignore'],
323
+ const output = execFileSyncImpl(windowsSystemToolPath('powershell.exe'), ['-NoProfile', '-Command', WINDOWS_PROCESS_SNAPSHOT_SCRIPT], {
324
+ // stderr is kept (not shown): a failed read reports it.
325
+ stdio: ['ignore', 'pipe', 'pipe'],
324
326
  windowsHide: true,
325
327
  maxBuffer: WINDOWS_PROCESS_SNAPSHOT_MAX_BUFFER,
326
328
  });
@@ -374,11 +376,13 @@ export function createSessionWorkerProcessInspector({
374
376
  if (platform !== 'win32') return Promise.resolve([]);
375
377
  return new Promise((resolve, reject) => {
376
378
  execFileImpl(
377
- 'powershell.exe',
379
+ windowsSystemToolPath('powershell.exe'),
378
380
  ['-NoProfile', '-Command', WINDOWS_PROCESS_SNAPSHOT_SCRIPT],
379
381
  { windowsHide: true, timeout: WINDOWS_PROCESS_SNAPSHOT_TIMEOUT_MS, maxBuffer: WINDOWS_PROCESS_SNAPSHOT_MAX_BUFFER },
380
- (error, stdout) => {
382
+ (error, stdout, stderr) => {
381
383
  if (error) {
384
+ // A failed read reports what PowerShell said.
385
+ if (stderr && error.stderr === undefined) error.stderr = stderr;
382
386
  reject(error);
383
387
  return;
384
388
  }
@@ -541,7 +545,7 @@ export function createSessionWorkerProcessInspector({
541
545
  function stopWindowsPids(pids) {
542
546
  const ids = uniquePids(pids);
543
547
  if (!ids.length) return [];
544
- execFileSyncImpl('powershell.exe', ['-NoProfile', '-Command', buildStopWindowsPidsScript(ids)], {
548
+ execFileSyncImpl(windowsSystemToolPath('powershell.exe'), ['-NoProfile', '-Command', buildStopWindowsPidsScript(ids)], {
545
549
  stdio: ['ignore', 'ignore', 'ignore'],
546
550
  });
547
551
  return ids;
@@ -553,7 +557,7 @@ export function createSessionWorkerProcessInspector({
553
557
  if (!ids.length) return Promise.resolve([]);
554
558
  return new Promise((resolve, reject) => {
555
559
  execFileImpl(
556
- 'powershell.exe',
560
+ windowsSystemToolPath('powershell.exe'),
557
561
  ['-NoProfile', '-Command', buildStopWindowsPidsScript(ids)],
558
562
  { windowsHide: true, timeout: WINDOWS_STOP_TIMEOUT_MS, maxBuffer: WINDOWS_PROCESS_SNAPSHOT_MAX_BUFFER },
559
563
  (error) => {
@@ -0,0 +1,123 @@
1
+ 'use strict';
2
+
3
+ import { createHmac } from 'node:crypto';
4
+
5
+ // What counts as an open of a shared conversation.
6
+ //
7
+ // The shared page fetches GET /api/shared/:token every 900 ms while it is in
8
+ // the foreground, so a fetch is not an open. Two rules decide:
9
+ //
10
+ // - The page's first fetch after it loads carries `open=1`. That fetch is an
11
+ // open, unless the same viewer opened the same share in the last
12
+ // RELOAD_DEDUPE_MS (a double load, a reload storm).
13
+ // - Any other fetch is an open only when the viewer has not fetched the share
14
+ // for IDLE_MS: a page that slept in the background and came back, or a
15
+ // client that never sends the flag (a script reading the API). Nobody reads
16
+ // the share unlogged by leaving the flag out.
17
+ //
18
+ // A viewer is the share's lineage plus a keyed hash of address and user agent
19
+ // (`viewerKey`), so the rules and the "distinct viewers" count work whether or
20
+ // not the address is stored. When the address is unknown (no socket address),
21
+ // every fetch of that viewer looks alike, which only merges viewers.
22
+ //
23
+ // Last-seen times live in memory. After a relay restart the last logged open
24
+ // stands in for them, so a reader polling across the restart counts once more
25
+ // only when their last open is older than IDLE_MS.
26
+
27
+ export const SHARE_OPEN_IDLE_MS = 30 * 60_000;
28
+ export const SHARE_OPEN_RELOAD_DEDUPE_MS = 10_000;
29
+ const DEFAULT_MAX_TRACKED_VIEWERS = 4_096;
30
+ const USER_AGENT_MAX = 512;
31
+
32
+ function cleanText(value, max) {
33
+ const text = String(value ?? '').replace(/[\u0000-\u001f\u007f]+/g, ' ').trim();
34
+ return text ? text.slice(0, max) : '';
35
+ }
36
+
37
+ /** `::ffff:203.0.113.7` is the IPv4 address it wraps. */
38
+ export function normalizeViewerIp(value) {
39
+ const ip = String(value || '').trim();
40
+ if (!ip) return '';
41
+ const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i.exec(ip);
42
+ return (mapped ? mapped[1] : ip).slice(0, 128);
43
+ }
44
+
45
+ /**
46
+ * The viewer of a public share request: the address as Express derives it
47
+ * through the app's `trust proxy` setting (a forwarded header from an
48
+ * untrusted peer is ignored), the user agent, and the edge's country, read
49
+ * only when the request came through a trusted proxy.
50
+ */
51
+ export function readShareRequestViewer(req) {
52
+ const ip = normalizeViewerIp(req?.ip || req?.socket?.remoteAddress || '');
53
+ const userAgent = cleanText(req?.headers?.['user-agent'], USER_AGENT_MAX);
54
+ let country = '';
55
+ const trust = typeof req?.app?.get === 'function' ? req.app.get('trust proxy fn') : null;
56
+ const peer = String(req?.socket?.remoteAddress || '').trim();
57
+ if (typeof trust === 'function' && peer && trust(peer, 0)) {
58
+ const header = String(req?.headers?.['cf-ipcountry'] || '').trim().toUpperCase();
59
+ if (/^[A-Z0-9]{2}$/.test(header)) country = header;
60
+ }
61
+ return { ip, userAgent, country };
62
+ }
63
+
64
+ export function createShareAccessLog({
65
+ repository,
66
+ now = () => Date.now(),
67
+ idleMs = SHARE_OPEN_IDLE_MS,
68
+ reloadDedupeMs = SHARE_OPEN_RELOAD_DEDUPE_MS,
69
+ maxTrackedViewers = DEFAULT_MAX_TRACKED_VIEWERS,
70
+ } = {}) {
71
+ // `${lineageId}:${viewerKey}` -> { seenAt, openedAt } (epoch ms)
72
+ const viewers = new Map();
73
+
74
+ function viewerKeyFor(ip, userAgent) {
75
+ return createHmac('sha256', repository.readViewerKeySecret())
76
+ .update(`${ip || ''}\n${userAgent || ''}`)
77
+ .digest('hex')
78
+ .slice(0, 32);
79
+ }
80
+
81
+ function prune(at) {
82
+ for (const [key, entry] of viewers) {
83
+ if (at - entry.seenAt > idleMs) viewers.delete(key);
84
+ }
85
+ if (viewers.size <= maxTrackedViewers) return;
86
+ const oldest = [...viewers.entries()].sort((a, b) => a[1].seenAt - b[1].seenAt);
87
+ for (const [key] of oldest.slice(0, viewers.size - maxTrackedViewers)) viewers.delete(key);
88
+ }
89
+
90
+ /**
91
+ * Called for every successful read of a share. Logs an open when the rules
92
+ * above say so; `storeIp` false keeps the address out of the row.
93
+ */
94
+ function noteFetch({ share, viewer, opened = false, storeIp = true }) {
95
+ const at = Number(now());
96
+ const viewerKey = viewerKeyFor(viewer?.ip, viewer?.userAgent);
97
+ const key = `${share.lineageId}:${viewerKey}`;
98
+ let entry = viewers.get(key);
99
+ if (!entry) {
100
+ const lastOpen = Date.parse(repository.lastOpenAt(share.lineageId, viewerKey) || '');
101
+ if (Number.isFinite(lastOpen)) entry = { seenAt: lastOpen, openedAt: lastOpen };
102
+ }
103
+ const isOpen = !entry
104
+ || (opened ? at - entry.openedAt > reloadDedupeMs : at - entry.seenAt > idleMs);
105
+ let recorded = false;
106
+ if (isOpen) {
107
+ recorded = repository.recordOpen({
108
+ lineageId: share.lineageId,
109
+ conversationId: share.conversationId,
110
+ openedAt: new Date(at).toISOString(),
111
+ ip: storeIp ? viewer?.ip || null : null,
112
+ userAgent: viewer?.userAgent || null,
113
+ country: viewer?.country || null,
114
+ viewerKey,
115
+ });
116
+ }
117
+ viewers.set(key, { seenAt: at, openedAt: isOpen ? at : entry.openedAt });
118
+ prune(at);
119
+ return { opened: isOpen, recorded, viewerKey };
120
+ }
121
+
122
+ return { noteFetch, viewerKeyFor };
123
+ }
@@ -0,0 +1,424 @@
1
+ 'use strict';
2
+
3
+ // Path privacy for shared conversations: what a share link serves of the relay
4
+ // host's paths and file names. Pure functions; the share routes build one
5
+ // redactor per response and run it over the whole payload, so raw paths never
6
+ // leave the relay. The owner's own view never passes through here.
7
+ //
8
+ // Modes (shared/share-path-privacy.mjs): `show` leaves everything, `filename`
9
+ // drops the directories and keeps the file name, `hide` puts a placeholder in
10
+ // place of path and name.
11
+ //
12
+ // Two tiers:
13
+ // - Reliable: fields that are a path by their key (`path`, `cwd`, `file_path`,
14
+ // `…Path`, `…Root`…), attachment names, `@file:`/`@folder:` mentions (also
15
+ // relative ones), diff and patch file headers, and the input of a tool call
16
+ // stored as JSON. The whole value is a path, whatever it looks like.
17
+ // - Best effort: every other string (replies, thoughts, tool lines, code
18
+ // blocks, quoted tool output). In one pass, leftmost match first:
19
+ // 1. http(s)/ws(s) URLs and the relay's own `/api/…` and `/shared/…` URLs
20
+ // are kept as they are (the query of an `/api/` URL is filtered);
21
+ // 2. file:// URLs;
22
+ // 3. the known roots (conversation cwd, workspace roots, home, the relay's
23
+ // data and upload dirs) with whatever path follows them: these also
24
+ // match with spaces in the root, any case and either separator on
25
+ // Windows;
26
+ // 4. absolute Windows paths (C:\…, C:/…, \\server\share\…), `~/…` and
27
+ // multi-segment Unix paths (/a/b…);
28
+ // 5. the OS user name as a segment between separators.
29
+ // Relative paths are left alone, and so are single-segment ones (/tmp).
30
+ // Markdown and code fences keep their structure: a path never swallows
31
+ // brackets, parentheses, backticks, quotes, `*` or `|`.
32
+ //
33
+ // Preview (`annotate: true`): every replacement is wrapped in
34
+ // SHARE_PREVIEW_MARKS.redactedStart/End, and whatever still looks like a path
35
+ // afterwards (a relative path with a file extension, a `home/x` or
36
+ // `Users/x` segment, the user name as a word, and in `show` mode every
37
+ // absolute path) in suspectStart/End. The original is never sent. The marks
38
+ // are private-use characters; the browser turns them into highlights
39
+ // (public/app/share-preview-marks.mjs). Mark characters already in the input
40
+ // are dropped first, so they only ever mean what this module put there.
41
+
42
+ import {
43
+ DEFAULT_SHARE_PATH_PRIVACY_MODE,
44
+ SHARE_PATH_PRIVACY_MODES,
45
+ } from '../../shared/share-path-privacy.mjs';
46
+
47
+ export const SHARE_PATH_PLACEHOLDER = '‹hidden›';
48
+
49
+ export const SHARE_PREVIEW_MARKS = Object.freeze({
50
+ redactedStart: '\uE000',
51
+ redactedEnd: '\uE001',
52
+ suspectStart: '\uE002',
53
+ suspectEnd: '\uE003',
54
+ });
55
+
56
+ const MARK_CHARS_RE = /[\uE000-\uE003]/g;
57
+ const MARKED_REGION_SPLIT_RE = /(\uE000[^\uE001]*\uE001)/;
58
+
59
+ // What ends a path in free text: whitespace, separators, quotes, brackets and
60
+ // the markdown characters that may stand right after one.
61
+ const STOP_COMMON = '\\s\\\\/<>"\'`()\\[\\]{}|*,;\\uE000-\\uE003';
62
+ // A Unix segment may hold `:` (a line number, a route parameter).
63
+ const SEG_U = `[^${STOP_COMMON}]`;
64
+ // A Windows segment never holds `:` or `?`.
65
+ const SEG_W = `[^${STOP_COMMON}:?]`;
66
+ const NOT_AFTER = '(?<![\\w.\\-~/\\\\$%])';
67
+ const URL_TAIL = '[^\\s<>"\'`]+';
68
+ const RELAY_URL_TAIL = '[^\\s<>"\'`()\\[\\]{}|]*';
69
+
70
+ // Paths that name nothing of the host.
71
+ const NEUTRAL_PATHS = new Set(['/dev/null', '/dev/stdin', '/dev/stdout', '/dev/stderr']);
72
+ const TRAILING_PUNCTUATION_RE = /[.:!?]+$/;
73
+ const MIN_USER_NAME_LENGTH = 2;
74
+ const MIN_SUSPECT_USER_NAME_LENGTH = 3;
75
+ const MAX_WALK_DEPTH = 64;
76
+ const MAX_JSON_TEXT_CHARS = 256 * 1024;
77
+
78
+ // Keys whose value is a path, whatever it looks like.
79
+ const PATH_KEY_RE = /^(?:path|paths|cwd|dir|directory|folder|reference|file_path|notebook_path|filepath|filename)$|(?:Path|Paths|Root|Roots|Cwd|Dir|Directory)$/;
80
+ // Keys that never hold a path: ids, hashes, times.
81
+ const SKIP_KEY_RE = /^(?:id|sha256|token|timestamp|seq)$|(?:Id|Ids|At)$/;
82
+ // An attachment's own name.
83
+ const ATTACHMENT_NAME_KEY_RE = /^(?:name|fileName|file_name|originalName)$/;
84
+
85
+ function escapeRegExp(value) {
86
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
87
+ }
88
+
89
+ function caseless(value) {
90
+ return [...String(value)]
91
+ .map((ch) => (/[a-z]/i.test(ch) ? `[${ch.toLowerCase()}${ch.toUpperCase()}]` : escapeRegExp(ch)))
92
+ .join('');
93
+ }
94
+
95
+ function isWindowsStyle(value) {
96
+ return /^[A-Za-z]:/.test(value) || value.includes('\\');
97
+ }
98
+
99
+ /** The roots as they can be matched: absolute, without trailing separators, longest first. */
100
+ export function normalizeKnownRoots(roots = []) {
101
+ const seen = new Set();
102
+ const out = [];
103
+ for (const raw of Array.isArray(roots) ? roots : [roots]) {
104
+ let value = String(raw ?? '').trim();
105
+ if (!value) continue;
106
+ const windows = isWindowsStyle(value);
107
+ value = value.replace(/[\\/]+$/, '');
108
+ const absolute = windows ? /^(?:[A-Za-z]:[\\/]|\\\\)/.test(`${value}\\`) : value.startsWith('/');
109
+ if (!absolute) continue;
110
+ // A drive or the file system root alone says nothing and would match everywhere.
111
+ const segments = value.split(/[\\/]+/).filter((part) => part && !/^[A-Za-z]:$/.test(part));
112
+ if (!segments.length) continue;
113
+ const key = windows ? value.replace(/\//g, '\\').toLowerCase() : value;
114
+ if (seen.has(key)) continue;
115
+ seen.add(key);
116
+ out.push(value);
117
+ }
118
+ return out.sort((a, b) => b.length - a.length);
119
+ }
120
+
121
+ // A separator is one character, or two for a backslash escaped in JSON: an
122
+ // unbounded run would make every position of a run of slashes a new attempt
123
+ // over the whole run.
124
+ function rootSource(root) {
125
+ const windows = isWindowsStyle(root);
126
+ const parts = root.split(/[\\/]+/);
127
+ const sep = windows ? '(?:\\\\{1,2}|/)' : '/';
128
+ return parts.map((part) => (windows ? caseless(part) : escapeRegExp(part))).join(sep);
129
+ }
130
+
131
+ function normalizeUserNames(userNames = []) {
132
+ const seen = new Set();
133
+ const out = [];
134
+ for (const raw of Array.isArray(userNames) ? userNames : [userNames]) {
135
+ const value = String(raw ?? '').trim();
136
+ if (value.length < MIN_USER_NAME_LENGTH || /[\s\\/]/.test(value)) continue;
137
+ const key = value.toLowerCase();
138
+ if (seen.has(key)) continue;
139
+ seen.add(key);
140
+ out.push(value);
141
+ }
142
+ return out.sort((a, b) => b.length - a.length);
143
+ }
144
+
145
+ function normalizeRelayPathPrefix(value) {
146
+ const prefix = String(value || '').trim().replace(/\/+$/, '');
147
+ if (!prefix || prefix === '/') return '';
148
+ return prefix.startsWith('/') ? prefix : `/${prefix}`;
149
+ }
150
+
151
+ function normalizeMode(mode) {
152
+ const value = String(mode || '').trim().toLowerCase();
153
+ return SHARE_PATH_PRIVACY_MODES.includes(value) ? value : DEFAULT_SHARE_PATH_PRIVACY_MODE;
154
+ }
155
+
156
+ /** The last segment of a path; '' for a root, a drive or a bare `~`. */
157
+ export function pathBaseName(value) {
158
+ const trimmed = String(value || '').replace(/[\\/]+$/, '');
159
+ const parts = trimmed.split(/[\\/]+/);
160
+ const last = parts[parts.length - 1] || '';
161
+ if (/^[A-Za-z]:$/.test(last) || last === '~') return '';
162
+ return last;
163
+ }
164
+
165
+ /**
166
+ * A redactor for one response. `roots` are the known absolute roots, `userNames`
167
+ * the OS user name(s), `relayPathPrefix` the relay's mount path (its URLs are
168
+ * kept). Returns `{ mode, text, path, name, payload }`.
169
+ */
170
+ export function createSharePathRedactor({
171
+ mode = DEFAULT_SHARE_PATH_PRIVACY_MODE,
172
+ roots = [],
173
+ userNames = [],
174
+ relayPathPrefix = '',
175
+ annotate = false,
176
+ } = {}) {
177
+ const resolvedMode = normalizeMode(mode);
178
+ const knownRoots = normalizeKnownRoots(roots);
179
+ const names = normalizeUserNames(userNames);
180
+ const namesLower = new Set(names.map((name) => name.toLowerCase()));
181
+ const prefix = normalizeRelayPathPrefix(relayPathPrefix);
182
+ const prefixSource = prefix ? `(?:${escapeRegExp(prefix)})?` : '';
183
+ const marks = SHARE_PREVIEW_MARKS;
184
+
185
+ const httpUrl = `\\b(?:https?|wss?):\\/\\/${URL_TAIL}`;
186
+ const relayUrl = `${NOT_AFTER}${prefixSource}\\/(?:api|shared)\\/${RELAY_URL_TAIL}`;
187
+ const mention = `@(?:file|folder):[^\\s\`<>()\\[\\]"']+`;
188
+ const fileUrl = `\\bfile:\\/\\/[^\\s<>"'\`()\\[\\]{}|]+`;
189
+ const known = knownRoots.length
190
+ ? `(?<![\\w.\\-~])(?:${knownRoots.map(rootSource).join('|')})(?!${SEG_U})(?:[\\\\/]+${SEG_U}+)*[\\\\/]*`
191
+ : null;
192
+ const win = `(?<![\\w.\\-])[A-Za-z]:[\\\\/]+(?:${SEG_W}+[\\\\/]+)*${SEG_W}*`;
193
+ const unc = `(?<![\\w\\\\/])\\\\\\\\${SEG_W}+(?:\\\\+${SEG_W}+)+`;
194
+ const tilde = `(?<![\\w.\\-~/\\\\])~[\\w.\\-]*[\\\\/]+(?:${SEG_U}+[\\\\/]+)*${SEG_U}*`;
195
+ const unix = `${NOT_AFTER}\\/(?:${SEG_U}+\\/+)+${SEG_U}*`;
196
+ const userSegment = names.length
197
+ ? `(?<=[\\\\/])(?:${names.map(caseless).join('|')})(?=[\\\\/])`
198
+ : null;
199
+
200
+ const alternatives = [
201
+ ['http', httpUrl],
202
+ ['relay', relayUrl],
203
+ ['mention', mention],
204
+ ['fileUrl', fileUrl],
205
+ ...(known ? [['known', known]] : []),
206
+ ['win', win],
207
+ ['unc', unc],
208
+ ['tilde', tilde],
209
+ ['unix', unix],
210
+ ...(userSegment ? [['user', userSegment]] : []),
211
+ ];
212
+ const groupNames = alternatives.map(([name]) => name);
213
+ const freeTextSource = alternatives.map(([, source]) => `(${source})`).join('|');
214
+
215
+ // Diff and patch headers name a file whatever the path looks like.
216
+ const DIFF_GIT_RE = /^(diff --git )a\/(\S+) b\/(\S+)$/gm;
217
+ const DIFF_SIDE_RE = /^((?:---|\+\+\+) )((?:a\/|b\/)?)(\S*[\\/.]\S*)$/gm;
218
+ const PATCH_FILE_RE = /^(\*\*\* (?:Add|Update|Delete) File: |\*\*\* Move to: |Index: )(\S.*)$/gm;
219
+
220
+ const suspectNames = names.filter((entry) => entry.length >= MIN_SUSPECT_USER_NAME_LENGTH);
221
+ const suspectAlternatives = [
222
+ ['http', httpUrl],
223
+ ['relay', relayUrl],
224
+ ['absolute', [fileUrl, ...(known ? [known] : []), win, unc, tilde, unix].join('|')],
225
+ ['relative', '(?<![\\w.\\-/\\\\])[\\w.\\-]+(?:[\\\\/][\\w.\\-]+)+'],
226
+ ...(suspectNames.length ? [['user', `(?<![\\w.\\-])(?:${suspectNames.map(caseless).join('|')})(?![\\w\\-])`]] : []),
227
+ ];
228
+ const suspectKinds = suspectAlternatives.map(([kind]) => kind);
229
+ const suspectSource = suspectAlternatives.map(([, source]) => `(${source})`).join('|');
230
+
231
+ const markRedacted = (original, replacement) => {
232
+ if (!annotate || replacement === original) return replacement;
233
+ return `${marks.redactedStart}${replacement}${marks.redactedEnd}`;
234
+ };
235
+
236
+ const isUserName = (value) => namesLower.has(String(value || '').toLowerCase());
237
+
238
+ // One path as the mode shows it, marked in a preview when it changed.
239
+ // `strict` (a structured field): even a bare name is hidden in `hide` mode.
240
+ function renderPath(raw, { strict = false } = {}) {
241
+ const value = String(raw ?? '');
242
+ if (!value || resolvedMode === 'show') return value;
243
+ if (NEUTRAL_PATHS.has(value)) return value;
244
+ const base = pathBaseName(value);
245
+ if (!base) return strict && resolvedMode === 'hide' ? markRedacted(value, SHARE_PATH_PLACEHOLDER) : value;
246
+ if (resolvedMode === 'hide' || isUserName(base)) return markRedacted(value, SHARE_PATH_PLACEHOLDER);
247
+ return markRedacted(value, base);
248
+ }
249
+
250
+ // Trailing sentence punctuation stays outside the path.
251
+ function renderTrimmed(match, options) {
252
+ const tail = match.match(TRAILING_PUNCTUATION_RE)?.[0] || '';
253
+ const core = tail ? match.slice(0, -tail.length) : match;
254
+ if (!core) return match;
255
+ return `${renderPath(core, options)}${tail}`;
256
+ }
257
+
258
+ function renderFileUrl(match) {
259
+ const tail = match.match(TRAILING_PUNCTUATION_RE)?.[0] || '';
260
+ const core = tail ? match.slice(0, -tail.length) : match;
261
+ let target = core.replace(/^file:\/\//i, '');
262
+ try { target = decodeURIComponent(target); } catch {}
263
+ const base = pathBaseName(target);
264
+ let replacement;
265
+ if (resolvedMode === 'hide' || !base || isUserName(base)) replacement = SHARE_PATH_PLACEHOLDER;
266
+ else replacement = base;
267
+ return `${markRedacted(core, replacement)}${tail}`;
268
+ }
269
+
270
+ function renderMention(match) {
271
+ const colon = match.indexOf(':');
272
+ const head = match.slice(0, colon + 1);
273
+ const rest = match.slice(colon + 1);
274
+ const tail = rest.match(TRAILING_PUNCTUATION_RE)?.[0] || '';
275
+ const core = tail ? rest.slice(0, -tail.length) : rest;
276
+ if (!core) return match;
277
+ return `${head}${renderPath(core, { strict: true })}${tail}`;
278
+ }
279
+
280
+ // The path of a relay URL is the relay's; its query can carry a host path.
281
+ function renderRelayUrl(match) {
282
+ const query = match.indexOf('?');
283
+ if (query === -1) return match;
284
+ return `${match.slice(0, query + 1)}${redactFreeText(match.slice(query + 1))}`;
285
+ }
286
+
287
+ function redactFreeText(text) {
288
+ const re = new RegExp(freeTextSource, 'g');
289
+ return text.replace(re, (match, ...groups) => {
290
+ const index = groups.findIndex((group, position) => position < groupNames.length && group !== undefined);
291
+ const kind = groupNames[index];
292
+ switch (kind) {
293
+ case 'http': return match;
294
+ case 'relay': return renderRelayUrl(match);
295
+ case 'mention': return renderMention(match);
296
+ case 'fileUrl': return renderFileUrl(match);
297
+ case 'user': return markRedacted(match, SHARE_PATH_PLACEHOLDER);
298
+ default: return renderTrimmed(match);
299
+ }
300
+ });
301
+ }
302
+
303
+ function redactHeaders(text) {
304
+ if (!/^(?:diff --git |--- |\+\+\+ |\*\*\* |Index: )/m.test(text)) return text;
305
+ return text
306
+ .replace(DIFF_GIT_RE, (_match, head, left, right) => `${head}a/${renderPath(left, { strict: true })} b/${renderPath(right, { strict: true })}`)
307
+ .replace(DIFF_SIDE_RE, (_match, head, side, target) => `${head}${side}${renderPath(target, { strict: true })}`)
308
+ .replace(PATCH_FILE_RE, (_match, head, target) => `${head}${renderPath(target.trim(), { strict: true })}`);
309
+ }
310
+
311
+ function flagSuspects(text) {
312
+ const re = new RegExp(suspectSource, 'g');
313
+ const flag = (match) => {
314
+ const core = match.replace(TRAILING_PUNCTUATION_RE, '');
315
+ if (!core) return match;
316
+ return `${marks.suspectStart}${core}${marks.suspectEnd}${match.slice(core.length)}`;
317
+ };
318
+ const flagPart = (part) => part.replace(re, (match, ...groups) => {
319
+ const index = groups.findIndex((group, position) => position < suspectKinds.length && group !== undefined);
320
+ switch (suspectKinds[index]) {
321
+ case 'relay': {
322
+ const query = match.indexOf('?');
323
+ return query === -1 ? match : `${match.slice(0, query + 1)}${flagPart(match.slice(query + 1))}`;
324
+ }
325
+ case 'absolute':
326
+ return NEUTRAL_PATHS.has(match.replace(TRAILING_PUNCTUATION_RE, '')) ? match : flag(match);
327
+ case 'relative': {
328
+ const core = match.replace(TRAILING_PUNCTUATION_RE, '');
329
+ return /\.[A-Za-z0-9]{1,10}$/.test(core) || /(?:^|[\\/])(?:home|users)[\\/]/i.test(core) ? flag(match) : match;
330
+ }
331
+ case 'user': return flag(match);
332
+ default: return match;
333
+ }
334
+ });
335
+ return text
336
+ .split(MARKED_REGION_SPLIT_RE)
337
+ .map((part, index) => (index % 2 === 1 || !part ? part : flagPart(part)))
338
+ .join('');
339
+ }
340
+
341
+ /** Free text: replies, thoughts, tool lines, code blocks. */
342
+ function text(value) {
343
+ if (typeof value !== 'string' || !value) return value;
344
+ let out = annotate ? value.replace(MARK_CHARS_RE, '') : value;
345
+ if (resolvedMode !== 'show') out = redactFreeText(redactHeaders(out));
346
+ if (annotate) out = flagSuspects(out);
347
+ return out;
348
+ }
349
+
350
+ /** A field that is a path by its key: the whole value is one. */
351
+ function path(value) {
352
+ if (typeof value !== 'string' || !value) return value;
353
+ const clean = annotate ? value.replace(MARK_CHARS_RE, '') : value;
354
+ const trimmed = clean.trim();
355
+ if (!trimmed) return clean;
356
+ if (/^(?:https?|wss?):\/\//i.test(trimmed)) return text(clean);
357
+ if (/^file:\/\//i.test(trimmed)) return resolvedMode === 'show' ? text(clean) : renderFileUrl(trimmed);
358
+ if (trimmed.startsWith('@') && !/^@(?:file|folder):/i.test(trimmed)) {
359
+ const rendered = `@${renderPath(trimmed.slice(1), { strict: true })}`;
360
+ return resolvedMode === 'show' ? text(clean) : rendered;
361
+ }
362
+ if (/^@(?:file|folder):/i.test(trimmed)) return resolvedMode === 'show' ? text(clean) : renderMention(trimmed);
363
+ if (resolvedMode === 'show') return text(clean);
364
+ return renderPath(trimmed, { strict: true });
365
+ }
366
+
367
+ /** An attachment's file name: hidden only in `hide` mode. */
368
+ function name(value) {
369
+ if (typeof value !== 'string' || !value) return value;
370
+ const clean = annotate ? value.replace(MARK_CHARS_RE, '') : value;
371
+ if (resolvedMode === 'hide') return markRedacted(clean, SHARE_PATH_PLACEHOLDER);
372
+ return text(clean);
373
+ }
374
+
375
+ // A tool call's input stored as JSON: its path fields are known by key.
376
+ // Unchanged input keeps its own formatting.
377
+ function jsonText(value, depth) {
378
+ if (resolvedMode === 'show') return null;
379
+ const trimmed = value.trim();
380
+ if (trimmed.length > MAX_JSON_TEXT_CHARS || !trimmed.startsWith('{') || !trimmed.endsWith('}')) return null;
381
+ let parsed;
382
+ try { parsed = JSON.parse(trimmed); } catch { return null; }
383
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
384
+ const walked = walk(parsed, depth + 1, {});
385
+ if (JSON.stringify(walked) === JSON.stringify(parsed)) return annotate ? value.replace(MARK_CHARS_RE, '') : value;
386
+ return JSON.stringify(walked, null, trimmed.includes('\n') ? 2 : undefined);
387
+ }
388
+
389
+ function walk(node, depth, { key = '', inAttachments = false } = {}) {
390
+ if (depth > MAX_WALK_DEPTH) return node;
391
+ if (typeof node === 'string') {
392
+ if (!node) return node;
393
+ if (key && SKIP_KEY_RE.test(key)) return node;
394
+ if (key && PATH_KEY_RE.test(key)) return path(node);
395
+ if (inAttachments && ATTACHMENT_NAME_KEY_RE.test(key)) return name(node);
396
+ return jsonText(node, depth) ?? text(node);
397
+ }
398
+ if (!node || typeof node !== 'object') return node;
399
+ if (Array.isArray(node)) {
400
+ const pathList = key && PATH_KEY_RE.test(key);
401
+ return node.map((item) => (pathList && typeof item === 'string'
402
+ ? path(item)
403
+ : walk(item, depth + 1, { key: pathList ? '' : key, inAttachments })));
404
+ }
405
+ if (typeof node.toJSON === 'function') return walk(node.toJSON(), depth + 1, { key, inAttachments });
406
+ const out = {};
407
+ for (const [childKey, item] of Object.entries(node)) {
408
+ const attachmentList = childKey === 'attachments' || childKey === 'draftAttachments';
409
+ out[childKey] = walk(item, depth + 1, {
410
+ key: childKey,
411
+ inAttachments: attachmentList || (inAttachments && !Array.isArray(item) && typeof item !== 'object'),
412
+ });
413
+ }
414
+ return out;
415
+ }
416
+
417
+ /** Every string of a JSON-like value, by the rules above. Returns a copy. */
418
+ function payload(value) {
419
+ if (resolvedMode === 'show' && !annotate) return value;
420
+ return walk(value, 0, {});
421
+ }
422
+
423
+ return { mode: resolvedMode, text, path, name, payload };
424
+ }
@@ -79,17 +79,21 @@ export function createStatusEventService(db, {
79
79
  for (const [key] of oldest) sharedAccessDedupe.delete(key);
80
80
  }
81
81
 
82
+ // `viewerIp` null or empty: the relay does not store viewer addresses, and
83
+ // the event carries none. `viewerKey` (a hash of address and browser) then
84
+ // keeps the dedupe per viewer instead of folding every viewer into one.
82
85
  function recordSharedAccess({
83
86
  shareToken,
84
87
  viewerIp,
88
+ viewerKey = '',
85
89
  conversationId,
86
90
  timestamp = Date.now(),
87
91
  } = {}) {
88
92
  const now = normalizeTimestamp(timestamp);
89
93
  const token = String(shareToken || '').trim();
90
- const ip = String(viewerIp || '').trim() || 'unknown';
94
+ const ip = String(viewerIp || '').trim();
91
95
  const normalizedConversationId = String(conversationId || '').trim();
92
- const dedupeKey = `${token}:${ip}`;
96
+ const dedupeKey = `${token}:${String(viewerKey || '').trim() || ip || 'unknown'}`;
93
97
  const previousSeenAt = sharedAccessDedupe.get(dedupeKey);
94
98
  if (Number.isFinite(previousSeenAt) && (now - previousSeenAt) <= normalizedDedupeTtlMs) {
95
99
  // A dedupe hit slides the window — a persisted event already backs it.
@@ -105,7 +109,7 @@ export function createStatusEventService(db, {
105
109
  source: 'server',
106
110
  details: {
107
111
  shareId: shareIdentifier(token),
108
- viewerIp: ip,
112
+ viewerIp: ip || undefined,
109
113
  conversationId: normalizedConversationId || undefined,
110
114
  },
111
115
  };