@oar-sh/oar 0.9.10 → 0.9.12
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/CHANGELOG.md +440 -1
- package/README.md +156 -972
- package/package.json +13 -13
- package/server/README.md +112 -25
- package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +57 -4
- package/server/claude-worker/claude-session-process.mjs +217 -11
- package/server/claude-worker/claude-session-worker.mjs +26 -1
- package/server/claude-worker/task-output-discovery.mjs +126 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +235 -14
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +8 -0
- package/server/copilot-worker/copilot-shell-output-mirror.mjs +94 -0
- package/server/copilot-worker/copilot-task-settle.mjs +96 -0
- package/server/db-schema.mjs +68 -2
- package/server/grok-worker/acp-client.mjs +2 -1
- package/server/grok-worker/acp-host-services.mjs +46 -6
- package/server/grok-worker/grok-background-tasks.mjs +241 -0
- package/server/grok-worker/grok-sdk-adapter.mjs +3 -1
- package/server/grok-worker/grok-session-worker.mjs +33 -1
- package/server/grok-worker/grok-turn-runner.mjs +11 -0
- package/server/migrations/0013-previews.mjs +130 -0
- package/server/migrations/0014-conversation-groups.mjs +89 -0
- package/server/migrations/0015-conversation-group-position.mjs +76 -0
- package/server/migrations/0016-share-access-log.mjs +110 -0
- package/server/migrations/0017-conversation-history-indexes.mjs +89 -0
- package/server/public/app/about-ui.js +73 -0
- package/server/public/app/about-view.mjs +234 -0
- package/server/public/app/action-confirmations.js +31 -14
- package/server/public/app/agent-exchange-fold-view.mjs +40 -101
- package/server/public/app/api-client.js +264 -16
- package/server/public/app/ask-user-view.js +17 -15
- package/server/public/app/background-tasks-view.mjs +285 -16
- package/server/public/app/bootstrap.js +253 -37
- package/server/public/app/chat-actions-menu-state.mjs +20 -10
- package/server/public/app/claude-background-command-limit-options.mjs +21 -0
- package/server/public/app/context-menu.mjs +90 -6
- package/server/public/app/conversation-group-drag.mjs +191 -0
- package/server/public/app/conversation-group-order.mjs +265 -0
- package/server/public/app/conversation-group-state.mjs +100 -0
- package/server/public/app/conversation-list-drag.mjs +449 -0
- package/server/public/app/conversation-payload-cache.mjs +357 -0
- package/server/public/app/conversation-turn-state.mjs +81 -0
- package/server/public/app/conversation-view.js +567 -94
- package/server/public/app/conversation-window-retention.mjs +96 -0
- package/server/public/app/cwd-picker.js +1 -1
- package/server/public/app/hidden-messages-view.mjs +312 -0
- package/server/public/app/host-suspend-ui.js +7 -1
- package/server/public/app/journal-view.js +1033 -82
- package/server/public/app/preview-cards.mjs +117 -31
- package/server/public/app/preview-command.mjs +10 -1
- package/server/public/app/relay-board-view.js +19 -6
- package/server/public/app/relay-prompt-sanitizer.mjs +11 -4
- package/server/public/app/relay-restart-error.mjs +16 -0
- package/server/public/app/settings-modal.js +150 -10
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/share-format.mjs +49 -0
- package/server/public/app/share-preview-marks.mjs +167 -0
- package/server/public/app/share-state.js +109 -0
- package/server/public/app/shared-conversations-settings.js +480 -0
- package/server/public/app/socket-handlers.js +138 -20
- package/server/public/app/store.js +121 -30
- package/server/public/app/subagent-row.mjs +3 -3
- package/server/public/app/subagent-viewer.mjs +37 -7
- package/server/public/app/task-console-render.mjs +237 -0
- package/server/public/app/task-console-watch.mjs +42 -0
- package/server/public/app/task-console.mjs +630 -0
- package/server/public/app/task-output-text.mjs +234 -0
- package/server/public/app/thinking-follow.js +0 -4
- package/server/public/app/transcript-island.mjs +73 -0
- package/server/public/app/transcript-jump-bottom-view.mjs +230 -26
- package/server/public/app/transcript-jump-bottom.mjs +170 -22
- package/server/public/app/transcript-reading-anchor.mjs +256 -0
- package/server/public/app/transcript-reload-freshness.mjs +17 -1
- package/server/public/app/transcript-viewport-anchor.mjs +159 -0
- package/server/public/index.html +625 -21
- package/server/relay-tools.md +3 -1
- package/server/relay.mjs +2 -1
- package/server/repositories/conversation-group-repository.mjs +444 -0
- package/server/repositories/message-repository.mjs +9 -0
- package/server/repositories/question-repository.mjs +4 -0
- package/server/repositories/session-repository.mjs +12 -37
- package/server/repositories/share-repository.mjs +322 -0
- package/server/repositories/share-visibility-statements.mjs +10 -0
- package/server/routes/about-routes.mjs +15 -0
- package/server/routes/cache-routes.mjs +4 -0
- package/server/routes/conversation-group-routes.mjs +171 -0
- package/server/routes/messages-routes.mjs +433 -68
- package/server/routes/preview-routes.mjs +9 -1
- package/server/routes/sessions-routes.mjs +901 -253
- package/server/server-runtime.mjs +392 -99
- package/server/services/about-service.mjs +461 -0
- package/server/services/background-task-store.mjs +365 -0
- package/server/services/background-work-view.mjs +228 -0
- package/server/services/cache-rebuild-service.mjs +30 -0
- package/server/services/cli-process-runner.mjs +4 -21
- package/server/services/conversation-history-page.mjs +199 -0
- package/server/services/delete-archive-service.mjs +4 -1
- package/server/services/detached-worker-watch-service.mjs +150 -0
- package/server/services/hidden-message-list.mjs +21 -0
- package/server/services/host-suspend-command.mjs +2 -1
- package/server/services/message-pin-list.mjs +1 -1
- package/server/services/preview-health-probe.mjs +8 -1
- package/server/services/preview-proxy-server.mjs +8 -4
- package/server/services/preview-registry-service.mjs +676 -26
- package/server/services/preview-static-handler.mjs +40 -7
- package/server/services/preview-store.mjs +274 -0
- package/server/services/rate-limit-service.mjs +12 -1
- package/server/services/relay-auth.mjs +183 -0
- package/server/services/relay-cli-launcher-service.mjs +3 -1
- package/server/services/relay-event-service.mjs +17 -0
- package/server/services/relay-prompt-sanitizer.mjs +11 -4
- package/server/services/remote-relay-dispatcher.mjs +187 -7
- package/server/services/request-boundary.mjs +183 -0
- package/server/services/session-activity-tracker.mjs +231 -19
- package/server/services/session-worker-launch-service.mjs +190 -6
- package/server/services/session-worker-process-service.mjs +10 -6
- package/server/services/share-access-log-service.mjs +123 -0
- package/server/services/share-path-redaction.mjs +424 -0
- package/server/services/status-event-service.mjs +7 -3
- package/server/services/task-output-rooms.mjs +259 -0
- package/server/services/task-output-service.mjs +562 -0
- package/server/services/tunnel-worker-path-guard.mjs +24 -41
- package/server/services/windows-autostart-service.mjs +6 -1
- package/server/services/windows-boot-autostart-service.mjs +207 -23
- package/shared/claude-background-command-limit.mjs +83 -0
- package/shared/preview-tool-core.mjs +15 -5
- package/shared/remote-relay-contract.mjs +35 -3
- package/shared/share-path-privacy.mjs +30 -0
- package/shared/task-output-text.mjs +737 -0
- package/shared/windows-system-tools.mjs +106 -0
- package/shared/worker-runtime/background-task-digest.mjs +30 -0
- package/shared/worker-runtime/heartbeat.mjs +24 -0
- package/shared/worker-runtime/kept-posts.mjs +83 -0
- package/shared/worker-runtime/task-output-spool.mjs +181 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +47 -15
|
@@ -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()
|
|
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
|
};
|