@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,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
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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
|
|
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
|
|
61
|
-
if (!
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
73
|
-
if (!
|
|
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}
|
|
85
|
-
res.status(403).json({ error: 'Session worker endpoints are
|
|
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}
|
|
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
|
-
|
|
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
|
}
|