@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.
Files changed (135) hide show
  1. package/CHANGELOG.md +440 -1
  2. package/README.md +156 -972
  3. package/package.json +13 -13
  4. package/server/README.md +112 -25
  5. package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
  6. package/server/claude-worker/claude-sdk-adapter.mjs +57 -4
  7. package/server/claude-worker/claude-session-process.mjs +217 -11
  8. package/server/claude-worker/claude-session-worker.mjs +26 -1
  9. package/server/claude-worker/task-output-discovery.mjs +126 -0
  10. package/server/copilot-worker/copilot-sdk-session-process.mjs +235 -14
  11. package/server/copilot-worker/copilot-sdk-session-worker.mjs +8 -0
  12. package/server/copilot-worker/copilot-shell-output-mirror.mjs +94 -0
  13. package/server/copilot-worker/copilot-task-settle.mjs +96 -0
  14. package/server/db-schema.mjs +68 -2
  15. package/server/grok-worker/acp-client.mjs +2 -1
  16. package/server/grok-worker/acp-host-services.mjs +46 -6
  17. package/server/grok-worker/grok-background-tasks.mjs +241 -0
  18. package/server/grok-worker/grok-sdk-adapter.mjs +3 -1
  19. package/server/grok-worker/grok-session-worker.mjs +33 -1
  20. package/server/grok-worker/grok-turn-runner.mjs +11 -0
  21. package/server/migrations/0013-previews.mjs +130 -0
  22. package/server/migrations/0014-conversation-groups.mjs +89 -0
  23. package/server/migrations/0015-conversation-group-position.mjs +76 -0
  24. package/server/migrations/0016-share-access-log.mjs +110 -0
  25. package/server/migrations/0017-conversation-history-indexes.mjs +89 -0
  26. package/server/public/app/about-ui.js +73 -0
  27. package/server/public/app/about-view.mjs +234 -0
  28. package/server/public/app/action-confirmations.js +31 -14
  29. package/server/public/app/agent-exchange-fold-view.mjs +40 -101
  30. package/server/public/app/api-client.js +264 -16
  31. package/server/public/app/ask-user-view.js +17 -15
  32. package/server/public/app/background-tasks-view.mjs +285 -16
  33. package/server/public/app/bootstrap.js +253 -37
  34. package/server/public/app/chat-actions-menu-state.mjs +20 -10
  35. package/server/public/app/claude-background-command-limit-options.mjs +21 -0
  36. package/server/public/app/context-menu.mjs +90 -6
  37. package/server/public/app/conversation-group-drag.mjs +191 -0
  38. package/server/public/app/conversation-group-order.mjs +265 -0
  39. package/server/public/app/conversation-group-state.mjs +100 -0
  40. package/server/public/app/conversation-list-drag.mjs +449 -0
  41. package/server/public/app/conversation-payload-cache.mjs +357 -0
  42. package/server/public/app/conversation-turn-state.mjs +81 -0
  43. package/server/public/app/conversation-view.js +567 -94
  44. package/server/public/app/conversation-window-retention.mjs +96 -0
  45. package/server/public/app/cwd-picker.js +1 -1
  46. package/server/public/app/hidden-messages-view.mjs +312 -0
  47. package/server/public/app/host-suspend-ui.js +7 -1
  48. package/server/public/app/journal-view.js +1033 -82
  49. package/server/public/app/preview-cards.mjs +117 -31
  50. package/server/public/app/preview-command.mjs +10 -1
  51. package/server/public/app/relay-board-view.js +19 -6
  52. package/server/public/app/relay-prompt-sanitizer.mjs +11 -4
  53. package/server/public/app/relay-restart-error.mjs +16 -0
  54. package/server/public/app/settings-modal.js +150 -10
  55. package/server/public/app/settings-tabs.js +1 -1
  56. package/server/public/app/share-format.mjs +49 -0
  57. package/server/public/app/share-preview-marks.mjs +167 -0
  58. package/server/public/app/share-state.js +109 -0
  59. package/server/public/app/shared-conversations-settings.js +480 -0
  60. package/server/public/app/socket-handlers.js +138 -20
  61. package/server/public/app/store.js +121 -30
  62. package/server/public/app/subagent-row.mjs +3 -3
  63. package/server/public/app/subagent-viewer.mjs +37 -7
  64. package/server/public/app/task-console-render.mjs +237 -0
  65. package/server/public/app/task-console-watch.mjs +42 -0
  66. package/server/public/app/task-console.mjs +630 -0
  67. package/server/public/app/task-output-text.mjs +234 -0
  68. package/server/public/app/thinking-follow.js +0 -4
  69. package/server/public/app/transcript-island.mjs +73 -0
  70. package/server/public/app/transcript-jump-bottom-view.mjs +230 -26
  71. package/server/public/app/transcript-jump-bottom.mjs +170 -22
  72. package/server/public/app/transcript-reading-anchor.mjs +256 -0
  73. package/server/public/app/transcript-reload-freshness.mjs +17 -1
  74. package/server/public/app/transcript-viewport-anchor.mjs +159 -0
  75. package/server/public/index.html +625 -21
  76. package/server/relay-tools.md +3 -1
  77. package/server/relay.mjs +2 -1
  78. package/server/repositories/conversation-group-repository.mjs +444 -0
  79. package/server/repositories/message-repository.mjs +9 -0
  80. package/server/repositories/question-repository.mjs +4 -0
  81. package/server/repositories/session-repository.mjs +12 -37
  82. package/server/repositories/share-repository.mjs +322 -0
  83. package/server/repositories/share-visibility-statements.mjs +10 -0
  84. package/server/routes/about-routes.mjs +15 -0
  85. package/server/routes/cache-routes.mjs +4 -0
  86. package/server/routes/conversation-group-routes.mjs +171 -0
  87. package/server/routes/messages-routes.mjs +433 -68
  88. package/server/routes/preview-routes.mjs +9 -1
  89. package/server/routes/sessions-routes.mjs +901 -253
  90. package/server/server-runtime.mjs +392 -99
  91. package/server/services/about-service.mjs +461 -0
  92. package/server/services/background-task-store.mjs +365 -0
  93. package/server/services/background-work-view.mjs +228 -0
  94. package/server/services/cache-rebuild-service.mjs +30 -0
  95. package/server/services/cli-process-runner.mjs +4 -21
  96. package/server/services/conversation-history-page.mjs +199 -0
  97. package/server/services/delete-archive-service.mjs +4 -1
  98. package/server/services/detached-worker-watch-service.mjs +150 -0
  99. package/server/services/hidden-message-list.mjs +21 -0
  100. package/server/services/host-suspend-command.mjs +2 -1
  101. package/server/services/message-pin-list.mjs +1 -1
  102. package/server/services/preview-health-probe.mjs +8 -1
  103. package/server/services/preview-proxy-server.mjs +8 -4
  104. package/server/services/preview-registry-service.mjs +676 -26
  105. package/server/services/preview-static-handler.mjs +40 -7
  106. package/server/services/preview-store.mjs +274 -0
  107. package/server/services/rate-limit-service.mjs +12 -1
  108. package/server/services/relay-auth.mjs +183 -0
  109. package/server/services/relay-cli-launcher-service.mjs +3 -1
  110. package/server/services/relay-event-service.mjs +17 -0
  111. package/server/services/relay-prompt-sanitizer.mjs +11 -4
  112. package/server/services/remote-relay-dispatcher.mjs +187 -7
  113. package/server/services/request-boundary.mjs +183 -0
  114. package/server/services/session-activity-tracker.mjs +231 -19
  115. package/server/services/session-worker-launch-service.mjs +190 -6
  116. package/server/services/session-worker-process-service.mjs +10 -6
  117. package/server/services/share-access-log-service.mjs +123 -0
  118. package/server/services/share-path-redaction.mjs +424 -0
  119. package/server/services/status-event-service.mjs +7 -3
  120. package/server/services/task-output-rooms.mjs +259 -0
  121. package/server/services/task-output-service.mjs +562 -0
  122. package/server/services/tunnel-worker-path-guard.mjs +24 -41
  123. package/server/services/windows-autostart-service.mjs +6 -1
  124. package/server/services/windows-boot-autostart-service.mjs +207 -23
  125. package/shared/claude-background-command-limit.mjs +83 -0
  126. package/shared/preview-tool-core.mjs +15 -5
  127. package/shared/remote-relay-contract.mjs +35 -3
  128. package/shared/share-path-privacy.mjs +30 -0
  129. package/shared/task-output-text.mjs +737 -0
  130. package/shared/windows-system-tools.mjs +106 -0
  131. package/shared/worker-runtime/background-task-digest.mjs +30 -0
  132. package/shared/worker-runtime/heartbeat.mjs +24 -0
  133. package/shared/worker-runtime/kept-posts.mjs +83 -0
  134. package/shared/worker-runtime/task-output-spool.mjs +181 -0
  135. package/shared/worker-runtime/worker-websocket-link.mjs +47 -15
@@ -0,0 +1,562 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Reading a background task's output for the console: the file the worker
5
+ * names as the task's `outputPath` (the Claude CLI's task file, a Copilot
6
+ * shell log, the worker's own spool), read by the relay itself.
7
+ *
8
+ * - readTail / readBefore / readRange: byte windows of the file, cut on line
9
+ * and character boundaries, returned cleaned (control codes out, colours
10
+ * kept, credentials scrubbed) with the processed line model.
11
+ * - the path guard: an absolute path of at most 1,024 characters, a regular
12
+ * file that is not a symlink (opened with O_NOFOLLOW where the platform has
13
+ * it), reads capped per request.
14
+ * - snapshots: the 8 KiB a worker posts for a task with no file (Claude's
15
+ * getTaskOutput), cached per task.
16
+ * - the health poller: every 10 s, for live tasks with an output file (and
17
+ * ended ones, read once more after the end), the time of the last output
18
+ * and the test runner's counts from the last 64 KB, sent to the pages as
19
+ * `background_tasks_health` when they change.
20
+ *
21
+ * The worker is trusted (it runs on this host as this user); the guard stops
22
+ * accidents, not an attacker.
23
+ */
24
+
25
+ import fs from 'node:fs';
26
+
27
+ import {
28
+ TASK_OUTPUT_LIMITS,
29
+ cleanTaskOutput,
30
+ foldTaskOutput,
31
+ normalizeTaskOutputKind,
32
+ normalizeTaskOutputPath,
33
+ parseRunnerSummary,
34
+ stripControlKeepSgr,
35
+ } from '../../shared/task-output-text.mjs';
36
+
37
+ const NEWLINE = 0x0a;
38
+
39
+ function clampInt(value, { min, max, fallback }) {
40
+ if (value === undefined || value === null || value === '') return fallback;
41
+ const number = Number(value);
42
+ if (!Number.isFinite(number)) return fallback;
43
+ return Math.min(max, Math.max(min, Math.trunc(number)));
44
+ }
45
+
46
+ /** The file kinds the relay reads itself. */
47
+ export function isFileOutputKind(kind) {
48
+ return kind === 'file' || kind === 'spool';
49
+ }
50
+
51
+ /** A task row's output as `{ kind, path }` (`path` '' when there is no readable one). */
52
+ export function taskOutputSource(task) {
53
+ const path = normalizeTaskOutputPath(task?.outputPath);
54
+ const kind = normalizeTaskOutputKind(task?.outputKind, path);
55
+ return { kind, path: isFileOutputKind(kind) ? path : '' };
56
+ }
57
+
58
+ /**
59
+ * What the file at `filePath` is: `{ ok: true, size, mtimeMs }`, or
60
+ * `{ ok: false, code }` with code `invalid-path`, `missing`, `symlink`,
61
+ * `not-file` or `unreadable`.
62
+ */
63
+ export async function inspectOutputFile(filePath, { fsImpl = fs } = {}) {
64
+ const path = normalizeTaskOutputPath(filePath);
65
+ if (!path) return { ok: false, code: 'invalid-path' };
66
+ let stats;
67
+ try {
68
+ stats = await fsImpl.promises.lstat(path);
69
+ } catch (error) {
70
+ return { ok: false, code: error?.code === 'ENOENT' ? 'missing' : 'unreadable' };
71
+ }
72
+ if (stats.isSymbolicLink()) return { ok: false, code: 'symlink' };
73
+ if (!stats.isFile()) return { ok: false, code: 'not-file' };
74
+ return { ok: true, size: stats.size, mtimeMs: stats.mtimeMs };
75
+ }
76
+
77
+ // Reads [start, end) of the file. Opened without following a symlink where
78
+ // the platform can, and checked again on the open handle: a path swapped
79
+ // between the lstat and the open is refused.
80
+ async function readBytes(path, start, end, { fsImpl = fs } = {}) {
81
+ // O_NONBLOCK: a FIFO swapped in after the lstat must not hold a pool
82
+ // thread until a writer appears (no effect on a regular file).
83
+ const flags = fsImpl.constants.O_RDONLY | (fsImpl.constants.O_NOFOLLOW || 0) | (fsImpl.constants.O_NONBLOCK || 0);
84
+ let handle;
85
+ try {
86
+ handle = await fsImpl.promises.open(path, flags);
87
+ } catch (error) {
88
+ return { ok: false, code: error?.code === 'ENOENT' ? 'missing' : (error?.code === 'ELOOP' ? 'symlink' : 'unreadable') };
89
+ }
90
+ try {
91
+ const stats = await handle.stat();
92
+ if (!stats.isFile()) return { ok: false, code: 'not-file' };
93
+ const size = stats.size;
94
+ const from = Math.max(0, Math.min(start, size));
95
+ const to = Math.max(from, Math.min(end, size));
96
+ const buffer = Buffer.alloc(to - from);
97
+ let read = 0;
98
+ while (read < buffer.length) {
99
+ const { bytesRead } = await handle.read(buffer, read, buffer.length - read, from + read);
100
+ if (!bytesRead) break;
101
+ read += bytesRead;
102
+ }
103
+ return { ok: true, buffer: buffer.subarray(0, read), start: from, size, mtimeMs: stats.mtimeMs };
104
+ } catch {
105
+ return { ok: false, code: 'unreadable' };
106
+ } finally {
107
+ try { await handle.close(); } catch {}
108
+ }
109
+ }
110
+
111
+ /**
112
+ * How many bytes of `buffer` can be decoded and cleaned now without cutting
113
+ * a UTF-8 character or an escape sequence in two: the rest waits for the
114
+ * next read (the writer is mid-write).
115
+ */
116
+ export function safeChunkLength(buffer) {
117
+ let end = buffer.length;
118
+ // An incomplete UTF-8 sequence at the end.
119
+ let lead = end - 1;
120
+ let continuation = 0;
121
+ while (lead >= 0 && continuation < 3 && (buffer[lead] & 0xc0) === 0x80) {
122
+ lead -= 1;
123
+ continuation += 1;
124
+ }
125
+ if (lead >= 0) {
126
+ const byte = buffer[lead];
127
+ const width = byte >= 0xf0 ? 4 : byte >= 0xe0 ? 3 : byte >= 0xc0 ? 2 : 1;
128
+ if (width > 1 && continuation + 1 < width) end = lead;
129
+ }
130
+ // An escape sequence still open at the end (ESC, ESC [ 3 1, ESC ] title…).
131
+ const tailStart = Math.max(0, end - 256);
132
+ const tail = buffer.subarray(tailStart, end).toString('latin1');
133
+ const esc = tail.lastIndexOf('\x1b');
134
+ if (esc !== -1) {
135
+ const rest = tail.slice(esc);
136
+ const complete = /^\x1b\[[0-?]*[ -/]*[@-~]/.test(rest)
137
+ || /^\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/.test(rest)
138
+ || /^\x1b[^[\]]/.test(rest);
139
+ // An OSC that never ends within 256 bytes is garbage; let it through.
140
+ const hopelessOsc = rest.startsWith('\x1b]') && rest.length >= 256;
141
+ if (!complete && !hopelessOsc) end = tailStart + esc;
142
+ }
143
+ return end;
144
+ }
145
+
146
+ // The byte offset just after the first newline at or after `from`, or -1.
147
+ function afterNewline(buffer, from = 0) {
148
+ const at = buffer.indexOf(NEWLINE, from);
149
+ return at === -1 ? -1 : at + 1;
150
+ }
151
+
152
+ // The start of the last `count` lines of `buffer` (a line ends with "\n";
153
+ // an unterminated last line counts as one).
154
+ function startOfLastLines(buffer, count) {
155
+ let lines = 0;
156
+ let position = buffer.length;
157
+ if (position > 0 && buffer[position - 1] === NEWLINE) position -= 1;
158
+ while (position > 0) {
159
+ const at = buffer.lastIndexOf(NEWLINE, position - 1);
160
+ lines += 1;
161
+ if (lines >= count || at === -1) return lines >= count ? at + 1 : 0;
162
+ position = at;
163
+ }
164
+ return 0;
165
+ }
166
+
167
+ function skipContinuationBytes(buffer, from) {
168
+ let start = from;
169
+ while (start < buffer.length && (buffer[start] & 0xc0) === 0x80) start += 1;
170
+ return start;
171
+ }
172
+
173
+ function shapeWindow(buffer, windowStart, size, mtimeMs) {
174
+ const text = cleanTaskOutput(buffer.toString('utf8'));
175
+ return {
176
+ ok: true,
177
+ text,
178
+ startOffset: windowStart,
179
+ endOffset: windowStart + buffer.length,
180
+ size,
181
+ mtime: mtimeMs,
182
+ hasMoreBefore: windowStart > 0,
183
+ lines: foldTaskOutput(text),
184
+ };
185
+ }
186
+
187
+ // The last `lines` lines of the bytes before `end`, within `maxBytes`.
188
+ async function readLinesEndingAt(filePath, end, { lines, maxBytes, trimEnd, fsImpl }) {
189
+ const windowStart = Math.max(0, end - maxBytes);
190
+ const read = await readBytes(filePath, windowStart, end, { fsImpl });
191
+ if (!read.ok) return read;
192
+ let buffer = read.buffer;
193
+ let start = 0;
194
+ // A window that starts mid-file starts on the next line; one long line
195
+ // with no newline at all starts on the next character.
196
+ if (read.start > 0) {
197
+ const next = afterNewline(buffer);
198
+ start = next === -1 || next >= buffer.length ? skipContinuationBytes(buffer, 0) : next;
199
+ }
200
+ buffer = buffer.subarray(start);
201
+ const fromLines = startOfLastLines(buffer, lines);
202
+ buffer = buffer.subarray(fromLines);
203
+ const firstByte = read.start + start + fromLines;
204
+ if (trimEnd) buffer = buffer.subarray(0, safeChunkLength(buffer));
205
+ return shapeWindow(buffer, firstByte, read.size, read.mtimeMs);
206
+ }
207
+
208
+ /**
209
+ * The end of a task's output: the last `lines` lines (default 2,000, at most
210
+ * 5,000) within `maxBytes` (at most 256 KB). Returns `{ ok, text,
211
+ * startOffset, endOffset, size, mtime, hasMoreBefore, lines }`: `text` the
212
+ * cleaned slice (for a viewer's own folder), `lines` the processed line
213
+ * model, offsets in bytes of the file. The end stops short of a character or
214
+ * escape the writer has not finished; `endOffset` is where the live chunks
215
+ * continue. `{ ok: false, code }` when the file cannot be read.
216
+ */
217
+ export async function readTail(filePath, { lines, maxBytes, fsImpl = fs } = {}) {
218
+ const info = await inspectOutputFile(filePath, { fsImpl });
219
+ if (!info.ok) return info;
220
+ return readLinesEndingAt(normalizeTaskOutputPath(filePath), info.size, {
221
+ lines: clampInt(lines, { min: 1, max: TASK_OUTPUT_LIMITS.tailLinesMax, fallback: TASK_OUTPUT_LIMITS.tailLinesDefault }),
222
+ maxBytes: clampInt(maxBytes, { min: 1, max: TASK_OUTPUT_LIMITS.requestBytesMax, fallback: TASK_OUTPUT_LIMITS.requestBytesMax }),
223
+ trimEnd: true,
224
+ fsImpl,
225
+ });
226
+ }
227
+
228
+ /** The `lines` lines that end at byte offset `before` (a `startOffset` an earlier read returned). */
229
+ export async function readBefore(filePath, { before, lines, maxBytes, fsImpl = fs } = {}) {
230
+ const info = await inspectOutputFile(filePath, { fsImpl });
231
+ if (!info.ok) return info;
232
+ const end = clampInt(before, { min: 0, max: info.size, fallback: info.size });
233
+ return readLinesEndingAt(normalizeTaskOutputPath(filePath), end, {
234
+ lines: clampInt(lines, { min: 1, max: TASK_OUTPUT_LIMITS.tailLinesMax, fallback: TASK_OUTPUT_LIMITS.tailLinesDefault }),
235
+ maxBytes: clampInt(maxBytes, { min: 1, max: TASK_OUTPUT_LIMITS.requestBytesMax, fallback: TASK_OUTPUT_LIMITS.requestBytesMax }),
236
+ trimEnd: false,
237
+ fsImpl,
238
+ });
239
+ }
240
+
241
+ /**
242
+ * The bytes from `start` on, at most `maxBytes` (64 KB), for a live chunk:
243
+ * `{ ok, offset, endOffset, text, size, mtime }` with `text` cleaned and the
244
+ * end on a safe boundary. `endOffset === offset` when nothing complete is
245
+ * there yet.
246
+ */
247
+ export async function readRange(filePath, {
248
+ start,
249
+ maxBytes = TASK_OUTPUT_LIMITS.chunkBytes,
250
+ // Stop after the last line break (`\n` or `\r`): credentials are scrubbed
251
+ // per piece, so a piece must not end inside one. An open line goes out
252
+ // only when it is `longLineBytes` long, or with a later call without this.
253
+ holdOpenLine = false,
254
+ longLineBytes = TASK_OUTPUT_LIMITS.chunkBytes,
255
+ fsImpl = fs,
256
+ } = {}) {
257
+ const path = normalizeTaskOutputPath(filePath);
258
+ if (!path) return { ok: false, code: 'invalid-path' };
259
+ const from = Math.max(0, Math.trunc(Number(start) || 0));
260
+ const read = await readBytes(path, from, from + Math.max(1, maxBytes), { fsImpl });
261
+ if (!read.ok) return read;
262
+ let length = safeChunkLength(read.buffer);
263
+ if (holdOpenLine && length > 0) {
264
+ let cut = -1;
265
+ for (let index = length - 1; index >= 0; index -= 1) {
266
+ const byte = read.buffer[index];
267
+ if (byte === NEWLINE || byte === 0x0d) {
268
+ cut = index + 1;
269
+ break;
270
+ }
271
+ }
272
+ if (cut > 0) length = cut;
273
+ else if (length < longLineBytes) length = 0;
274
+ }
275
+ return {
276
+ ok: true,
277
+ offset: read.start,
278
+ endOffset: read.start + length,
279
+ text: cleanTaskOutput(read.buffer.subarray(0, length).toString('utf8')),
280
+ size: read.size,
281
+ mtime: read.mtimeMs,
282
+ };
283
+ }
284
+
285
+ // ─── Snapshots ───────────────────────────────────────────────────────────────
286
+
287
+ /**
288
+ * The output a worker posted for a task with no file (`output-snapshot`),
289
+ * per store key and task; kept an hour like the ended rows.
290
+ */
291
+ export function createTaskOutputSnapshots({ now = () => Date.now(), keepMs = TASK_OUTPUT_LIMITS.endedKeepMs, max = 200 } = {}) {
292
+ const snapshots = new Map();
293
+ const keyOf = (key, taskId) => `${String(key || '').trim()}\u0000${String(taskId || '').trim()}`;
294
+
295
+ function prune() {
296
+ const cutoff = now() - keepMs;
297
+ for (const [key, entry] of snapshots) if (entry.at < cutoff) snapshots.delete(key);
298
+ while (snapshots.size > max) snapshots.delete(snapshots.keys().next().value);
299
+ }
300
+
301
+ return {
302
+ set(key, taskId, { output, totalBytes, truncated } = {}) {
303
+ const text = String(output ?? '').slice(0, 64 * 1024);
304
+ const bytes = Buffer.byteLength(text, 'utf8');
305
+ const total = Number.isInteger(Number(totalBytes)) && Number(totalBytes) >= bytes ? Number(totalBytes) : bytes;
306
+ const id = keyOf(key, taskId);
307
+ snapshots.delete(id);
308
+ snapshots.set(id, { output: text, totalBytes: total, truncated: truncated === true || total > bytes, at: now() });
309
+ prune();
310
+ },
311
+ get(...args) {
312
+ prune();
313
+ const [keys, taskId] = args;
314
+ for (const key of Array.isArray(keys) ? keys : [keys]) {
315
+ const entry = snapshots.get(keyOf(key, taskId));
316
+ if (entry) return entry;
317
+ }
318
+ return null;
319
+ },
320
+ forget(key, taskId) {
321
+ snapshots.delete(keyOf(key, taskId));
322
+ },
323
+ };
324
+ }
325
+
326
+ /** A snapshot as the output route answers it. */
327
+ export function shapeSnapshot(snapshot) {
328
+ const text = cleanTaskOutput(snapshot.output);
329
+ const bytes = Buffer.byteLength(snapshot.output, 'utf8');
330
+ return {
331
+ text,
332
+ startOffset: Math.max(0, snapshot.totalBytes - bytes),
333
+ endOffset: snapshot.totalBytes,
334
+ size: snapshot.totalBytes,
335
+ mtime: snapshot.at,
336
+ hasMoreBefore: false,
337
+ truncated: !!snapshot.truncated,
338
+ lines: foldTaskOutput(text),
339
+ };
340
+ }
341
+
342
+ // ─── Health ──────────────────────────────────────────────────────────────────
343
+
344
+ /**
345
+ * Runner counts of the window `buffer` that starts at byte `windowStart` of
346
+ * the file: `{ counts, runner, final, firstFailureOffset }`, the offset the
347
+ * byte where the first failure's line starts (null when there is none in the
348
+ * window).
349
+ */
350
+ export function healthOfWindow(buffer, windowStart) {
351
+ let start = 0;
352
+ if (windowStart > 0) {
353
+ const next = afterNewline(buffer);
354
+ start = next === -1 ? buffer.length : next;
355
+ }
356
+ const texts = [];
357
+ const offsets = [];
358
+ let position = start;
359
+ while (position < buffer.length) {
360
+ const at = buffer.indexOf(NEWLINE, position);
361
+ const end = at === -1 ? buffer.length : at;
362
+ const raw = buffer.subarray(position, end).toString('utf8');
363
+ // A `\r` redraw keeps its last state, as the console shows it.
364
+ const body = raw.endsWith('\r') ? raw.slice(0, -1) : raw;
365
+ texts.push(stripControlKeepSgr(body.slice(body.lastIndexOf('\r') + 1)));
366
+ offsets.push(windowStart + position);
367
+ position = end + 1;
368
+ }
369
+ const summary = parseRunnerSummary(texts);
370
+ if (!summary) return { counts: null, runner: null, final: false, firstFailureOffset: null };
371
+ return {
372
+ counts: { passed: summary.passed, failed: summary.failed, skipped: summary.skipped, ...(summary.flaky ? { flaky: summary.flaky } : {}) },
373
+ runner: summary.runner,
374
+ final: summary.final,
375
+ firstFailureOffset: Number.isInteger(summary.firstFailureLine) ? offsets[summary.firstFailureLine] : null,
376
+ };
377
+ }
378
+
379
+ function sameHealthFacts(a, b) {
380
+ if (!a || !b) return false;
381
+ return a.runner === b.runner
382
+ && a.final === b.final
383
+ && a.firstFailureOffset === b.firstFailureOffset
384
+ && JSON.stringify(a.counts) === JSON.stringify(b.counts);
385
+ }
386
+
387
+ /**
388
+ * Every `intervalMs` (10 s) over the live tasks with an output file: `stat`,
389
+ * and when the size or time changed, the last 64 KB parsed for runner
390
+ * counts. Readings go into the store (`setHealth`); a key's readings go to
391
+ * the pages as `background_tasks_health { conversationId, health }` when the
392
+ * counts change, and when the last output moved on by a minute or more (the
393
+ * stall hint counts minutes). `conversationId` is the store key, as in
394
+ * `background_tasks`.
395
+ *
396
+ * Ended rows are read too, until a reading finds the file as the last one
397
+ * did: a task that ends between two ticks has its last output (a runner's
398
+ * summary) read after the end, onto the ended row and to the pages. `poke`
399
+ * runs a tick soon, for the moment a task ends.
400
+ */
401
+ export function createTaskHealthPoller({
402
+ store,
403
+ io,
404
+ intervalMs = 10_000,
405
+ windowBytes = TASK_OUTPUT_LIMITS.healthBytes,
406
+ fsImpl = fs,
407
+ logger = console,
408
+ setIntervalImpl = setInterval,
409
+ clearIntervalImpl = clearInterval,
410
+ setTimeoutImpl = setTimeout,
411
+ pokeDelayMs = 250,
412
+ } = {}) {
413
+ let timer = null;
414
+ let running = false;
415
+ let again = false;
416
+ let poked = null;
417
+ // key → taskId → the reading last sent to the pages.
418
+ const sent = new Map();
419
+ // key → taskId → the `endedAt` of the ended row whose file was read after
420
+ // the end and found unchanged: nothing more to read for it.
421
+ const settledEnded = new Map();
422
+
423
+ async function readHealth(task, previous) {
424
+ const { path } = taskOutputSource(task);
425
+ if (!path) return null;
426
+ const info = await inspectOutputFile(path, { fsImpl });
427
+ // A file not there yet (a spool is named before its first byte): no
428
+ // output yet, quiet since the task started.
429
+ if (!info.ok && info.code === 'missing' && !previous?.size) {
430
+ const startedAt = Number(task?.startedAt) || null;
431
+ return { lastOutputAt: startedAt, size: 0, counts: null, runner: null, final: false, firstFailureOffset: null };
432
+ }
433
+ if (!info.ok) return previous || null;
434
+ if (previous && previous.size === info.size && previous.lastOutputAt === info.mtimeMs) return previous;
435
+ const windowStart = Math.max(0, info.size - windowBytes);
436
+ const read = await readBytes(path, windowStart, info.size, { fsImpl });
437
+ if (!read.ok) return previous || null;
438
+ return {
439
+ lastOutputAt: info.mtimeMs,
440
+ size: info.size,
441
+ ...healthOfWindow(read.buffer, read.start),
442
+ };
443
+ }
444
+
445
+ // The same file as the last reading saw, with the same facts.
446
+ function sameReading(a, b) {
447
+ if (a === b) return true;
448
+ return !!a && !!b && a.size === b.size && a.lastOutputAt === b.lastOutputAt && sameHealthFacts(a, b);
449
+ }
450
+
451
+ // The ended rows of `key` read after their end: `{ taskId: reading }` of
452
+ // those whose reading changed.
453
+ async function readEnded(key) {
454
+ const changed = {};
455
+ const rows = store.ended?.(key) || [];
456
+ const done = settledEnded.get(key) || new Map();
457
+ const kept = new Map();
458
+ for (const row of rows) {
459
+ const taskId = String(row?.taskId || '').trim();
460
+ if (!taskId || !taskOutputSource(row).path) continue;
461
+ if (done.get(taskId) === row.endedAt) {
462
+ kept.set(taskId, row.endedAt);
463
+ continue;
464
+ }
465
+ let reading = null;
466
+ try {
467
+ reading = await readHealth(row, row.health || null);
468
+ } catch (error) {
469
+ logger?.warn?.(`[task-output] health read failed for ended task ${taskId.slice(0, 12)}: ${error?.message || error}`);
470
+ }
471
+ if (!reading) continue;
472
+ if (sameReading(row.health, reading)) {
473
+ kept.set(taskId, row.endedAt);
474
+ continue;
475
+ }
476
+ if (store.setEndedHealth?.(key, taskId, reading)) changed[taskId] = reading;
477
+ }
478
+ if (kept.size) settledEnded.set(key, kept);
479
+ else settledEnded.delete(key);
480
+ return changed;
481
+ }
482
+
483
+ async function tick({ poked: fromPoke = false } = {}) {
484
+ if (!store?.sets) return;
485
+ if (running) {
486
+ if (fromPoke) again = true;
487
+ return;
488
+ }
489
+ running = true;
490
+ try {
491
+ // Ended rows of conversations nobody opens again expire here.
492
+ store.pruneAll?.();
493
+ const keys = new Set([...store.sets.keys(), ...(store.endedKeys?.() || [])]);
494
+ for (const key of keys) {
495
+ const tasks = Array.isArray(store.sets.get(key)?.tasks) ? store.sets.get(key).tasks : [];
496
+ const changed = {};
497
+ let anyChange = false;
498
+ const current = {};
499
+ for (const task of tasks) {
500
+ const taskId = String(task?.taskId || '').trim();
501
+ if (!taskId || !taskOutputSource(task).path) continue;
502
+ let reading = null;
503
+ try {
504
+ reading = await readHealth(task, store.health(key, taskId));
505
+ } catch (error) {
506
+ logger?.warn?.(`[task-output] health read failed for task ${taskId.slice(0, 12)}: ${error?.message || error}`);
507
+ }
508
+ if (!reading) continue;
509
+ store.setHealth(key, taskId, reading);
510
+ current[taskId] = reading;
511
+ const last = sent.get(key)?.get(taskId);
512
+ if (!last || !sameHealthFacts(last, reading) || reading.lastOutputAt - last.lastOutputAt >= 60_000) {
513
+ changed[taskId] = reading;
514
+ anyChange = true;
515
+ }
516
+ }
517
+ const endedChanged = await readEnded(key);
518
+ if (Object.keys(endedChanged).length) anyChange = true;
519
+ if (!anyChange) continue;
520
+ const record = new Map(sent.get(key) || []);
521
+ for (const [taskId, reading] of Object.entries(changed)) record.set(taskId, reading);
522
+ // Tasks gone from the set leave the record.
523
+ for (const taskId of [...record.keys()]) if (!current[taskId]) record.delete(taskId);
524
+ sent.set(key, record);
525
+ try {
526
+ io?.emit?.('background_tasks_health', { conversationId: key, health: { ...current, ...endedChanged } });
527
+ } catch {}
528
+ }
529
+ for (const key of [...sent.keys()]) if (!store.sets.has(key)) sent.delete(key);
530
+ for (const key of [...settledEnded.keys()]) if (!keys.has(key)) settledEnded.delete(key);
531
+ } finally {
532
+ running = false;
533
+ }
534
+ // A poke during the tick: once more, for what it may have missed.
535
+ if (again) {
536
+ again = false;
537
+ await tick();
538
+ }
539
+ }
540
+
541
+ return {
542
+ tick,
543
+ /** A task ended: a tick soon rather than within 10 s. */
544
+ poke() {
545
+ if (poked) return;
546
+ poked = setTimeoutImpl(() => {
547
+ poked = null;
548
+ tick({ poked: true }).catch(() => {});
549
+ }, pokeDelayMs);
550
+ poked?.unref?.();
551
+ },
552
+ start() {
553
+ if (timer) return;
554
+ timer = setIntervalImpl(() => { tick().catch(() => {}); }, intervalMs);
555
+ timer?.unref?.();
556
+ },
557
+ stop() {
558
+ if (timer) clearIntervalImpl(timer);
559
+ timer = null;
560
+ },
561
+ };
562
+ }
@@ -3,27 +3,7 @@
3
3
  import { URL } from 'url';
4
4
 
5
5
  import { buildAcceptedWorkerPaths } from './session-worker-websocket-service.mjs';
6
-
7
- // Cloudflare's edge stamps `cf-ray` on every proxied request and a remote client
8
- // cannot strip it, so its presence means the request came in over the tunnel
9
- // rather than from a local session worker on 127.0.0.1.
10
- const DEFAULT_TUNNEL_MARKER_HEADERS = ['cf-ray'];
11
-
12
- function normalizeHeaderName(value) {
13
- return String(value || '').trim().toLowerCase();
14
- }
15
-
16
- function normalizeMarkerHeaders(extraHeaders) {
17
- const names = [...DEFAULT_TUNNEL_MARKER_HEADERS];
18
- const extras = Array.isArray(extraHeaders)
19
- ? extraHeaders
20
- : String(extraHeaders || '').split(',');
21
- for (const entry of extras) {
22
- const name = normalizeHeaderName(entry);
23
- if (name && !names.includes(name)) names.push(name);
24
- }
25
- return names;
26
- }
6
+ import { createLocalRequestCheck, resolveTunnelMarkerHeaders } from './request-boundary.mjs';
27
7
 
28
8
  function requestPathname(req) {
29
9
  const rawUrl = String(req?.url || '/');
@@ -35,18 +15,24 @@ function requestPathname(req) {
35
15
  }
36
16
 
37
17
  /**
38
- * Blocks session-worker WebSocket paths when the request arrived through a
39
- * public tunnel. The guard keys off *the path being a worker path*, never off
40
- * "unauthenticated request bearing cf-ray" — shared conversations legitimately
41
- * arrive over Cloudflare with no credentials and must keep working.
18
+ * Blocks session-worker WebSocket paths for every request that is not truly
19
+ * local (request-boundary.mjs): a peer that is not loopback, an edge marker
20
+ * header, or a forwarding header. Session workers are spawned by the relay on
21
+ * its own machine and connect over 127.0.0.1 without any of these. The guard
22
+ * keys off *the path being a worker path*, never off "unauthenticated request
23
+ * bearing cf-ray" — shared conversations legitimately arrive over Cloudflare
24
+ * with no credentials and must keep working.
42
25
  */
43
26
  export function createTunnelWorkerPathGuard({
44
27
  pathPrefix = '',
45
28
  extraMarkerHeaders = [],
29
+ localRequestCheck = null,
46
30
  logger = console,
47
31
  } = {}) {
48
32
  const workerPaths = new Set(buildAcceptedWorkerPaths(pathPrefix));
49
- const markerHeaders = normalizeMarkerHeaders(extraMarkerHeaders);
33
+ const localCheck = localRequestCheck
34
+ || createLocalRequestCheck({ markerHeaders: resolveTunnelMarkerHeaders(extraMarkerHeaders) });
35
+ const markerHeaders = localCheck.markerHeaders;
50
36
 
51
37
  function isWorkerPath(pathname) {
52
38
  if (workerPaths.has(pathname)) return true;
@@ -57,21 +43,18 @@ export function createTunnelWorkerPathGuard({
57
43
  return false;
58
44
  }
59
45
 
60
- function matchedMarkerHeader(headers) {
61
- if (!headers) return null;
62
- for (const name of markerHeaders) {
63
- const value = headers[name];
64
- if (typeof value === 'string' ? value.trim() : value) return name;
65
- }
66
- return null;
46
+ function describeRefusal(refusal) {
47
+ if (!refusal) return null;
48
+ if (refusal.reason === 'peer') return 'a non-loopback peer';
49
+ return refusal.header;
67
50
  }
68
51
 
69
- function evaluate(req) {
52
+ function evaluate(req, socket = null) {
70
53
  const pathname = requestPathname(req);
71
54
  if (!isWorkerPath(pathname)) return { blocked: false, pathname, marker: null };
72
- const marker = matchedMarkerHeader(req?.headers);
73
- if (!marker) return { blocked: false, pathname, marker: null };
74
- return { blocked: true, pathname, marker };
55
+ const refusal = localCheck.explain({ headers: req?.headers, socket: req?.socket || socket });
56
+ if (!refusal) return { blocked: false, pathname, marker: null };
57
+ return { blocked: true, pathname, marker: describeRefusal(refusal), reason: refusal.reason };
75
58
  }
76
59
 
77
60
  // Express middleware: 403 the request before any auth middleware runs.
@@ -81,15 +64,15 @@ export function createTunnelWorkerPathGuard({
81
64
  next();
82
65
  return;
83
66
  }
84
- logger?.warn?.(`[tunnel-guard] Blocked session-worker path ${result.pathname} carrying ${result.marker}`);
85
- res.status(403).json({ error: 'Session worker endpoints are not reachable through the tunnel' });
67
+ logger?.warn?.(`[tunnel-guard] Blocked session-worker path ${result.pathname}: not local (${result.marker})`);
68
+ res.status(403).json({ error: 'Session worker endpoints are reachable from this machine only' });
86
69
  }
87
70
 
88
71
  // Raw upgrade guard: destroy the socket before the WebSocket handshake.
89
72
  function handleUpgrade(req, socket) {
90
- const result = evaluate(req);
73
+ const result = evaluate(req, socket);
91
74
  if (!result.blocked) return false;
92
- logger?.warn?.(`[tunnel-guard] Blocked session-worker upgrade ${result.pathname} carrying ${result.marker}`);
75
+ logger?.warn?.(`[tunnel-guard] Blocked session-worker upgrade ${result.pathname}: not local (${result.marker})`);
93
76
  try {
94
77
  socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
95
78
  } catch {}
@@ -14,7 +14,12 @@ export function escapeBatchValue(value) {
14
14
  }
15
15
 
16
16
  export function resolveWindowsStartupDirectory(env = process.env, pathImpl = path) {
17
- const appData = String(env?.APPDATA || '').trim();
17
+ // A relay started without a full profile (the boot task) may lack APPDATA;
18
+ // the roaming folder then is where resolveOarRoot looks too.
19
+ const appData = String(env?.APPDATA || '').trim()
20
+ || (String(env?.USERPROFILE || '').trim()
21
+ ? pathImpl.join(String(env.USERPROFILE).trim(), 'AppData', 'Roaming')
22
+ : '');
18
23
  if (!appData) {
19
24
  throw new Error('Windows autostart requires the current user APPDATA directory.');
20
25
  }