@oar-sh/oar 0.9.8 → 0.9.10
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 +478 -0
- package/README.md +129 -39
- package/bin/bat/oar.bat +46 -46
- package/bin/oar.js +814 -228
- package/package.json +5 -6
- package/server/README.md +306 -4
- package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
- package/server/claude-worker/claude-session-process.mjs +35 -1
- package/server/claude-worker/claude-turn-publisher.mjs +3 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +31 -4
- package/server/claude-worker/subagent-timeline-recorder.mjs +572 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +3 -1
- package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
- package/server/db-schema.mjs +44 -0
- package/server/migrations/0008-message-pins.mjs +85 -0
- package/server/migrations/0009-subagent-timeline.mjs +135 -0
- package/server/migrations/0010-subagent-timeline-pruning.mjs +89 -0
- package/server/migrations/0011-relay-event-subscriptions.mjs +125 -0
- package/server/migrations/0012-cross-relay-event-subscriptions.mjs +116 -0
- package/server/public/app/agent-exchange-fold-view.mjs +248 -0
- package/server/public/app/api-client.js +91 -0
- package/server/public/app/archive-confirm.mjs +28 -0
- package/server/public/app/ask-user-view.js +3 -0
- package/server/public/app/attachments-view.js +1024 -131
- package/server/public/app/back-guard.mjs +58 -0
- package/server/public/app/background-tasks-view.mjs +18 -26
- package/server/public/app/bootstrap.js +230 -35
- package/server/public/app/chat-actions-menu-state.mjs +116 -0
- package/server/public/app/claude-cloud-conversation-ui.js +10 -22
- package/server/public/app/composer-control-state.mjs +16 -0
- package/server/public/app/composer-keyboard.mjs +54 -0
- package/server/public/app/conversation-view.js +471 -110
- package/server/public/app/explorer-edit-state.mjs +68 -0
- package/server/public/app/external-link-policy.mjs +4 -1
- package/server/public/app/file-version.mjs +58 -0
- package/server/public/app/host-suspend-ui.js +6 -3
- package/server/public/app/journal-view.js +161 -27
- package/server/public/app/no-conversation-state.mjs +58 -0
- package/server/public/app/pinned-messages-view.mjs +253 -0
- package/server/public/app/provider-readiness.mjs +120 -0
- package/server/public/app/pwa-install.js +1 -1
- package/server/public/app/relay-board-view.js +3 -0
- package/server/public/app/relay-dot-state.mjs +8 -1
- package/server/public/app/relay-event-subscriptions-ui.js +184 -0
- package/server/public/app/relay-event-view.mjs +234 -0
- package/server/public/app/router.js +8 -2
- package/server/public/app/settings-modal.js +53 -1
- package/server/public/app/socket-handlers.js +65 -28
- package/server/public/app/store.js +25 -2
- package/server/public/app/subagent-row.mjs +138 -0
- package/server/public/app/subagent-timeline-view.mjs +186 -0
- package/server/public/app/subagent-viewer-watch.mjs +50 -0
- package/server/public/app/subagent-viewer.mjs +534 -0
- package/server/public/app/task-row-format.mjs +29 -0
- package/server/public/app/thinking-follow.js +4 -0
- package/server/public/app/transcript-jump-bottom-view.mjs +290 -0
- package/server/public/app/transcript-jump-bottom.mjs +99 -0
- package/server/public/app/transcript-reload-freshness.mjs +25 -0
- package/server/public/app/update-ui.js +1 -1
- package/server/public/app/viewer-chat-gallery.mjs +59 -0
- package/server/public/app/viewer-controls-state.mjs +59 -0
- package/server/public/app/viewer-gallery.mjs +46 -0
- package/server/public/app/viewer-gestures.mjs +105 -0
- package/server/public/app/viewer-history.mjs +45 -0
- package/server/public/app/viewer-tap.mjs +112 -0
- package/server/public/index.html +700 -59
- package/server/repositories/message-pin-statements.mjs +39 -0
- package/server/repositories/message-repository.mjs +34 -3
- package/server/repositories/question-repository.mjs +9 -3
- package/server/repositories/remote-relay-repository.mjs +6 -2
- package/server/repositories/subagent-timeline-statements.mjs +170 -0
- package/server/routes/ask-user-routes.mjs +3 -0
- package/server/routes/messages-routes.mjs +548 -111
- package/server/routes/relay-event-routes.mjs +157 -0
- package/server/routes/remote-relay-routes.mjs +15 -0
- package/server/routes/sessions-routes.mjs +164 -24
- package/server/server-runtime.mjs +274 -62
- package/server/services/cloudflared-tunnel-service.mjs +139 -41
- package/server/services/explorer-entry-name.mjs +26 -0
- package/server/services/explorer-mutations.mjs +122 -0
- package/server/services/file-serving.mjs +155 -0
- package/server/services/message-pin-list.mjs +52 -0
- package/server/services/oar-cli-helpers.mjs +139 -4
- package/server/services/oar-cli-menu.mjs +240 -0
- package/server/services/oar-cli-wsl.mjs +129 -0
- package/server/services/preview-proxy-server.mjs +41 -23
- package/server/services/push-dispatch-service.mjs +18 -0
- package/server/services/relay-event-link-service.mjs +289 -0
- package/server/services/relay-event-service.mjs +1935 -0
- package/server/services/relay-shutdown-gate.mjs +31 -0
- package/server/services/remote-relay-caller-context.mjs +52 -7
- package/server/services/remote-relay-dispatcher.mjs +494 -2
- package/server/services/remote-relay-registry-service.mjs +11 -1
- package/server/services/safe-served-content.mjs +61 -11
- package/server/services/session-activity-tracker.mjs +487 -0
- package/server/services/session-history-refresh-service.mjs +31 -0
- package/server/services/stale-attempt-log-limiter.mjs +96 -0
- package/server/services/stale-recovery-clock-guard.mjs +57 -0
- package/server/services/stale-subagent-run-sweep.mjs +109 -0
- package/server/services/subagent-record-retention.mjs +164 -0
- package/server/services/subagent-timeline-service.mjs +328 -0
- package/server/services/subagent-viewer-rooms.mjs +119 -0
- package/server/services/upgrade-socket-guard.mjs +16 -0
- package/shared/relay-events.mjs +371 -0
- package/shared/remote-relay-contract.mjs +157 -5
- package/shared/subagent-record-retention.mjs +31 -0
- package/shared/subagent-timeline.mjs +79 -0
|
@@ -2,16 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import fs from 'fs';
|
|
5
|
-
import { createRequire } from 'module';
|
|
6
5
|
import { spawn } from 'child_process';
|
|
7
6
|
|
|
8
|
-
const requireFromHere = createRequire(import.meta.url);
|
|
9
|
-
|
|
10
7
|
const BACKOFF_STEPS = [5_000, 10_000, 20_000, 40_000, 60_000];
|
|
11
8
|
const READINESS_WINDOW_MS = 5_000;
|
|
12
9
|
const STABLE_CONNECTION_MS = 30_000;
|
|
13
10
|
const FAST_EXIT_MS = 10_000;
|
|
14
11
|
const MAX_CONSECUTIVE_FAST_EXITS = 3;
|
|
12
|
+
const BINARY_RECHECK_MS = 60_000;
|
|
13
|
+
// What Windows starts without a shell. A `.cmd`/`.bat` launcher would need
|
|
14
|
+
// cmd.exe, and the tunnel token does not go through a shell command line.
|
|
15
|
+
const WINDOWS_DIRECT_EXTENSIONS = ['.exe', '.com'];
|
|
15
16
|
|
|
16
17
|
function toText(value) {
|
|
17
18
|
return String(value || '').trim();
|
|
@@ -29,28 +30,91 @@ function normalizeExtraArgs(rawValue) {
|
|
|
29
30
|
return rawValue.map((entry) => toText(entry)).filter(Boolean);
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
33
|
+
function hasDirectoryPart(value) {
|
|
34
|
+
return value.includes('/') || value.includes('\\');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Where the cloudflared the tunnel would run really is, or null. OAR ships no
|
|
39
|
+
* copy: a name is looked up on PATH (on Windows with the `.exe`/`.com`
|
|
40
|
+
* suffixes PATHEXT lists), a path must be an executable file.
|
|
41
|
+
*/
|
|
42
|
+
export function locateCloudflaredBinary(binary, {
|
|
43
|
+
platform = process.platform,
|
|
44
|
+
env = process.env,
|
|
45
|
+
fsImpl = fs,
|
|
46
|
+
pathImpl = platform === 'win32' ? path.win32 : path.posix,
|
|
40
47
|
} = {}) {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
+
const name = toText(binary);
|
|
49
|
+
if (!name) return null;
|
|
50
|
+
|
|
51
|
+
let names = [name];
|
|
52
|
+
if (platform === 'win32' && !WINDOWS_DIRECT_EXTENSIONS.includes(pathImpl.extname(name).toLowerCase())) {
|
|
53
|
+
const listed = String(env?.PATHEXT || '')
|
|
54
|
+
.split(';')
|
|
55
|
+
.map((ext) => toText(ext).toLowerCase())
|
|
56
|
+
.filter((ext) => WINDOWS_DIRECT_EXTENSIONS.includes(ext));
|
|
57
|
+
names = (listed.length ? listed : WINDOWS_DIRECT_EXTENSIONS).map((ext) => `${name}${ext}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const isExecutableFile = (candidate) => {
|
|
61
|
+
try {
|
|
62
|
+
if (!fsImpl.statSync(candidate).isFile()) return false;
|
|
63
|
+
// Windows has no execute bit; the suffix above decided.
|
|
64
|
+
if (platform !== 'win32') fsImpl.accessSync(candidate, fs.constants.X_OK);
|
|
65
|
+
return true;
|
|
66
|
+
} catch {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const dirs = hasDirectoryPart(name)
|
|
72
|
+
? [null]
|
|
73
|
+
: String(env?.PATH ?? env?.Path ?? '').split(pathImpl.delimiter).map(toText).filter(Boolean);
|
|
74
|
+
for (const dir of dirs) {
|
|
75
|
+
for (const candidate of names) {
|
|
76
|
+
const full = dir === null ? candidate : pathImpl.join(dir, candidate);
|
|
77
|
+
if (isExecutableFile(full)) return full;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
48
80
|
return null;
|
|
49
81
|
}
|
|
50
82
|
|
|
83
|
+
export function cloudflaredInstallPointer(platform = process.platform) {
|
|
84
|
+
if (platform === 'win32') return 'install it with: winget install --id Cloudflare.cloudflared';
|
|
85
|
+
if (platform === 'darwin') return 'install it with: brew install cloudflared';
|
|
86
|
+
return "install it from Cloudflare's package repository: https://pkg.cloudflare.com/";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The one sentence the tunnel state, the status dot and the CLI show. */
|
|
90
|
+
export function describeMissingCloudflaredBinary({ binary = '', binarySource = 'path', platform = process.platform } = {}) {
|
|
91
|
+
const pointer = cloudflaredInstallPointer(platform);
|
|
92
|
+
if (binarySource === 'config') {
|
|
93
|
+
return `cloudflared was not found as configured (${binary}) — correct cloudflaredTunnel.binary, or ${pointer}`;
|
|
94
|
+
}
|
|
95
|
+
return `cloudflared is not installed — ${pointer}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The tunnel in one line for a CLI status row. Takes the manager's state or
|
|
100
|
+
* the `cloudflaredTunnel` object of `/api/status`; the public address is the
|
|
101
|
+
* caller's to pass, the relay does not know it (the route lives in Cloudflare).
|
|
102
|
+
*/
|
|
103
|
+
export function describeTunnelState(state, { url = '' } = {}) {
|
|
104
|
+
const tunnel = state && typeof state === 'object' ? state : {};
|
|
105
|
+
const managed = toText(tunnel.mode).toLowerCase() === 'managed' || tunnel.enabled === true;
|
|
106
|
+
if (!managed) return 'off';
|
|
107
|
+
const reason = toText(tunnel.lastError);
|
|
108
|
+
if (tunnel.connected === true) {
|
|
109
|
+
const address = toText(url);
|
|
110
|
+
return address ? `running (${address})` : 'running';
|
|
111
|
+
}
|
|
112
|
+
if (tunnel.binaryMissing === true || tunnel.valid === false) return reason ? `off: ${reason}` : 'off';
|
|
113
|
+
return reason ? `not connected (${reason})` : 'not connected';
|
|
114
|
+
}
|
|
115
|
+
|
|
51
116
|
export function normalizeCloudflaredTunnelConfig(rawConfig = {}, {
|
|
52
117
|
env = process.env,
|
|
53
|
-
resolveBinary = resolveCloudflaredBinaryFromPackage,
|
|
54
118
|
configBaseDir = process.cwd(),
|
|
55
119
|
pathImpl = path,
|
|
56
120
|
} = {}) {
|
|
@@ -64,33 +128,20 @@ export function normalizeCloudflaredTunnelConfig(rawConfig = {}, {
|
|
|
64
128
|
const binaryInput = toText(env?.COPILOT_CLOUDFLARED_BINARY) || toText(raw.binary);
|
|
65
129
|
const extraArgs = normalizeExtraArgs(raw.extraArgs);
|
|
66
130
|
|
|
67
|
-
|
|
68
|
-
|
|
131
|
+
// The configured binary, else `cloudflared` from PATH. Whether it is really
|
|
132
|
+
// there is checked when the tunnel starts (locateCloudflaredBinary).
|
|
133
|
+
let binary = 'cloudflared';
|
|
134
|
+
let binarySource = 'path';
|
|
69
135
|
if (binaryInput) {
|
|
70
|
-
binary =
|
|
136
|
+
binary = hasDirectoryPart(binaryInput)
|
|
71
137
|
? pathImpl.resolve(configBaseDir, binaryInput)
|
|
72
138
|
: binaryInput;
|
|
73
139
|
binarySource = 'config';
|
|
74
|
-
} else {
|
|
75
|
-
let packageBinary = null;
|
|
76
|
-
try {
|
|
77
|
-
packageBinary = toText(typeof resolveBinary === 'function' ? resolveBinary() : '');
|
|
78
|
-
} catch {
|
|
79
|
-
packageBinary = null;
|
|
80
|
-
}
|
|
81
|
-
if (packageBinary) {
|
|
82
|
-
binary = packageBinary;
|
|
83
|
-
binarySource = 'package';
|
|
84
|
-
} else {
|
|
85
|
-
binary = 'cloudflared';
|
|
86
|
-
binarySource = 'path';
|
|
87
|
-
}
|
|
88
140
|
}
|
|
89
141
|
|
|
90
142
|
const errors = [];
|
|
91
143
|
if (mode === 'managed') {
|
|
92
144
|
if (!token) errors.push('cloudflaredTunnel.token is required when cloudflaredTunnel.mode is "managed"');
|
|
93
|
-
if (!binary) errors.push('cloudflaredTunnel.binary could not be resolved (install the "cloudflared" package or set cloudflaredTunnel.binary)');
|
|
94
145
|
}
|
|
95
146
|
|
|
96
147
|
return {
|
|
@@ -106,6 +157,28 @@ export function normalizeCloudflaredTunnelConfig(rawConfig = {}, {
|
|
|
106
157
|
};
|
|
107
158
|
}
|
|
108
159
|
|
|
160
|
+
/**
|
|
161
|
+
* The tunnel as `oar doctor` reports it, from the config alone (no relay
|
|
162
|
+
* needed): whether the built-in tunnel is on and whether it could start.
|
|
163
|
+
*/
|
|
164
|
+
export function describeConfiguredTunnel(rawConfig = {}, {
|
|
165
|
+
env = process.env,
|
|
166
|
+
platform = process.platform,
|
|
167
|
+
configBaseDir = process.cwd(),
|
|
168
|
+
pathImpl = path,
|
|
169
|
+
locateBinary = locateCloudflaredBinary,
|
|
170
|
+
} = {}) {
|
|
171
|
+
const tunnelConfig = normalizeCloudflaredTunnelConfig(rawConfig, { env, configBaseDir, pathImpl });
|
|
172
|
+
if (tunnelConfig.mode !== 'managed') return 'disabled';
|
|
173
|
+
if (!tunnelConfig.valid) return `managed, cannot start: ${tunnelConfig.errors[0]}`;
|
|
174
|
+
const located = locateBinary(tunnelConfig.binary, { platform, env });
|
|
175
|
+
if (!located) {
|
|
176
|
+
const { binary, binarySource } = tunnelConfig;
|
|
177
|
+
return `managed, cannot start: ${describeMissingCloudflaredBinary({ binary, binarySource, platform })}`;
|
|
178
|
+
}
|
|
179
|
+
return `managed (cloudflared: ${located})`;
|
|
180
|
+
}
|
|
181
|
+
|
|
109
182
|
export function buildCloudflaredArgs(tunnelConfig) {
|
|
110
183
|
return ['tunnel', 'run', '--token', tunnelConfig.token, ...tunnelConfig.extraArgs];
|
|
111
184
|
}
|
|
@@ -145,12 +218,11 @@ export function createCloudflaredTunnelManager({
|
|
|
145
218
|
clearTimeoutImpl = clearTimeout,
|
|
146
219
|
configBaseDir = process.cwd(),
|
|
147
220
|
env = process.env,
|
|
148
|
-
|
|
221
|
+
locateBinary = locateCloudflaredBinary,
|
|
149
222
|
pathImpl = path,
|
|
150
223
|
} = {}) {
|
|
151
224
|
const tunnelConfig = normalizeCloudflaredTunnelConfig(rawTunnelConfig, {
|
|
152
225
|
env,
|
|
153
|
-
resolveBinary,
|
|
154
226
|
configBaseDir,
|
|
155
227
|
pathImpl,
|
|
156
228
|
});
|
|
@@ -174,6 +246,7 @@ export function createCloudflaredTunnelManager({
|
|
|
174
246
|
lastError: tunnelConfig.errors[0] || null,
|
|
175
247
|
lastEventAt: nowIso(),
|
|
176
248
|
binary: tunnelConfig.binary || null,
|
|
249
|
+
binaryMissing: false,
|
|
177
250
|
proc: null,
|
|
178
251
|
backoffTimer: null,
|
|
179
252
|
};
|
|
@@ -189,6 +262,7 @@ export function createCloudflaredTunnelManager({
|
|
|
189
262
|
reconnectAttempts: state.reconnectAttempts,
|
|
190
263
|
connectedSince: state.connectedSince,
|
|
191
264
|
lastError: state.lastError,
|
|
265
|
+
binaryMissing: state.binaryMissing,
|
|
192
266
|
});
|
|
193
267
|
};
|
|
194
268
|
|
|
@@ -220,9 +294,33 @@ export function createCloudflaredTunnelManager({
|
|
|
220
294
|
return;
|
|
221
295
|
}
|
|
222
296
|
|
|
297
|
+
// OAR ships no cloudflared. Without one the tunnel stays off, the state
|
|
298
|
+
// says how to install it, and a look every minute starts the tunnel once
|
|
299
|
+
// it is there (no relay restart, as long as it lands on the relay's PATH).
|
|
300
|
+
let located = null;
|
|
301
|
+
try { located = toText(locateBinary(tunnelConfig.binary, { platform, env })); } catch {}
|
|
302
|
+
if (!located) {
|
|
303
|
+
if (!state.binaryMissing) {
|
|
304
|
+
state.binaryMissing = true;
|
|
305
|
+
state.lastError = describeMissingCloudflaredBinary({
|
|
306
|
+
binary: tunnelConfig.binary,
|
|
307
|
+
binarySource: tunnelConfig.binarySource,
|
|
308
|
+
platform,
|
|
309
|
+
});
|
|
310
|
+
warn(`Tunnel not started: ${state.lastError}`);
|
|
311
|
+
updateBlockingState();
|
|
312
|
+
emitStatus();
|
|
313
|
+
}
|
|
314
|
+
if (state.backoffTimer) clearTimeoutImpl(state.backoffTimer);
|
|
315
|
+
state.backoffTimer = setTimeoutImpl(spawnTunnel, BINARY_RECHECK_MS);
|
|
316
|
+
if (typeof state.backoffTimer?.unref === 'function') state.backoffTimer.unref();
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
state.binaryMissing = false;
|
|
320
|
+
|
|
223
321
|
const args = buildCloudflaredArgs(tunnelConfig);
|
|
224
|
-
log(`Spawning: ${
|
|
225
|
-
const proc = spawnImpl(
|
|
322
|
+
log(`Spawning: ${located} ${redactCloudflaredArgs(args).join(' ')}`);
|
|
323
|
+
const proc = spawnImpl(located, args, buildSpawnOptions(platform, ['ignore', 'pipe', 'pipe']));
|
|
226
324
|
state.proc = proc;
|
|
227
325
|
state.lastError = null;
|
|
228
326
|
updateBlockingState();
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// The one rule set for a file or folder name typed in the explorer: a single
|
|
2
|
+
// path segment, nothing that could leave the parent, and on Windows nothing
|
|
3
|
+
// the filesystem itself refuses. The Windows set applies only when the relay
|
|
4
|
+
// runs there; a Linux name such as `a:b` is legal and stays legal.
|
|
5
|
+
|
|
6
|
+
const MAX_NAME_BYTES = 255;
|
|
7
|
+
const WINDOWS_FORBIDDEN_CHARS = /[<>:"|?*\u0000-\u001f]/;
|
|
8
|
+
const WINDOWS_RESERVED_NAMES = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Validate a name. Returns `{ ok: true, name }` with the trimmed name, or
|
|
12
|
+
* `{ ok: false, error }` with a message fit for the UI.
|
|
13
|
+
*/
|
|
14
|
+
export function validateEntryName(rawName, { platform = process.platform } = {}) {
|
|
15
|
+
const name = String(rawName ?? '').trim();
|
|
16
|
+
if (!name) return { ok: false, error: 'Enter a name' };
|
|
17
|
+
if (name === '.' || name === '..') return { ok: false, error: 'That name is not allowed' };
|
|
18
|
+
if (/[/\\\0]/.test(name)) return { ok: false, error: 'A name cannot contain / or \\' };
|
|
19
|
+
if (Buffer.byteLength(name, 'utf8') > MAX_NAME_BYTES) return { ok: false, error: 'That name is too long' };
|
|
20
|
+
if (platform === 'win32') { // host-platform: Windows filesystem naming rules
|
|
21
|
+
if (WINDOWS_FORBIDDEN_CHARS.test(name)) return { ok: false, error: 'A name cannot contain < > : " | ? *' };
|
|
22
|
+
if (/[. ]$/.test(name)) return { ok: false, error: 'A name cannot end with a dot or a space' };
|
|
23
|
+
if (WINDOWS_RESERVED_NAMES.test(name)) return { ok: false, error: 'That name is reserved by Windows' };
|
|
24
|
+
}
|
|
25
|
+
return { ok: true, name };
|
|
26
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Filesystem changes made from the explorer: create a folder, rename an
|
|
2
|
+
// entry, delete an entry, and count what a delete would remove. The callers
|
|
3
|
+
// (the routes) have already resolved the absolute target inside a browsable
|
|
4
|
+
// root; this module only touches the filesystem and turns its errors into
|
|
5
|
+
// `{ error, statusCode }` answers the routes can send as they are.
|
|
6
|
+
//
|
|
7
|
+
// Symbolic links are refused everywhere. The listings skip them, so the user
|
|
8
|
+
// never saw one in the explorer, and a rename or delete through a link would
|
|
9
|
+
// act on something outside the folder they are looking at.
|
|
10
|
+
|
|
11
|
+
import fsDefault from 'node:fs';
|
|
12
|
+
import pathDefault from 'node:path';
|
|
13
|
+
|
|
14
|
+
export const DEFAULT_COUNT_CAP = 10000;
|
|
15
|
+
|
|
16
|
+
function failure(error, fallback, statusCode = 500) {
|
|
17
|
+
const code = String(error?.code || '');
|
|
18
|
+
if (code === 'ENOENT' || code === 'ENOTDIR') return { error: 'Not found', statusCode: 404 };
|
|
19
|
+
if (code === 'EEXIST' || code === 'ENOTEMPTY') return { error: 'That name is already taken', statusCode: 409 };
|
|
20
|
+
if (code === 'EACCES' || code === 'EPERM' || code === 'EBUSY') return { error: 'Permission denied', statusCode: 403 };
|
|
21
|
+
return { error: error?.message || fallback, statusCode };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function lstatOrNull(fs, target) {
|
|
25
|
+
try {
|
|
26
|
+
return fs.lstatSync(target);
|
|
27
|
+
} catch (error) {
|
|
28
|
+
if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return null;
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Create `target`; missing parents are created with it. */
|
|
34
|
+
export function createDirectory(target, { fs = fsDefault } = {}) {
|
|
35
|
+
try {
|
|
36
|
+
if (lstatOrNull(fs, target)) return { error: 'That name is already taken', statusCode: 409 };
|
|
37
|
+
fs.mkdirSync(target, { recursive: true });
|
|
38
|
+
return { ok: true };
|
|
39
|
+
} catch (error) {
|
|
40
|
+
return failure(error, 'Failed to create the folder');
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Rename `target` to `newName` inside its own parent. A name that differs
|
|
46
|
+
* only in case is allowed: on a case-insensitive filesystem the destination
|
|
47
|
+
* stat finds the source itself, which is not a collision.
|
|
48
|
+
*/
|
|
49
|
+
export function renameEntry(target, newName, { fs = fsDefault, path = pathDefault } = {}) {
|
|
50
|
+
const destination = path.join(path.dirname(target), newName);
|
|
51
|
+
try {
|
|
52
|
+
const source = lstatOrNull(fs, target);
|
|
53
|
+
if (!source) return { error: 'Not found', statusCode: 404 };
|
|
54
|
+
if (source.isSymbolicLink()) return { error: 'Links cannot be changed here', statusCode: 409 };
|
|
55
|
+
if (destination === target) return { ok: true, path: destination };
|
|
56
|
+
const existing = lstatOrNull(fs, destination);
|
|
57
|
+
const sameEntry = existing && existing.ino === source.ino && existing.dev === source.dev && existing.ino !== 0;
|
|
58
|
+
if (existing && !sameEntry) return { error: 'That name is already taken', statusCode: 409 };
|
|
59
|
+
fs.renameSync(target, destination);
|
|
60
|
+
return { ok: true, path: destination };
|
|
61
|
+
} catch (error) {
|
|
62
|
+
return failure(error, 'Failed to rename');
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Remove `target`; a folder goes with everything inside it. */
|
|
67
|
+
export function removeEntry(target, { fs = fsDefault } = {}) {
|
|
68
|
+
try {
|
|
69
|
+
const stat = lstatOrNull(fs, target);
|
|
70
|
+
if (!stat) return { error: 'Not found', statusCode: 404 };
|
|
71
|
+
if (stat.isSymbolicLink()) return { error: 'Links cannot be changed here', statusCode: 409 };
|
|
72
|
+
fs.rmSync(target, { recursive: true, force: false, maxRetries: 2 });
|
|
73
|
+
return { ok: true };
|
|
74
|
+
} catch (error) {
|
|
75
|
+
return failure(error, 'Failed to delete');
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* What a delete of `target` would remove: `{ kind, files, dirs, capped }`.
|
|
81
|
+
* `dirs` excludes the target itself. The walk stops once `files + dirs`
|
|
82
|
+
* reaches `cap`, so a huge tree answers quickly with `capped: true`. Links
|
|
83
|
+
* inside count as files and are not followed.
|
|
84
|
+
*/
|
|
85
|
+
export function countEntries(target, { fs = fsDefault, path = pathDefault, cap = DEFAULT_COUNT_CAP } = {}) {
|
|
86
|
+
let stat = null;
|
|
87
|
+
try {
|
|
88
|
+
stat = lstatOrNull(fs, target);
|
|
89
|
+
} catch (error) {
|
|
90
|
+
return failure(error, 'Failed to read the entry');
|
|
91
|
+
}
|
|
92
|
+
if (!stat) return { error: 'Not found', statusCode: 404 };
|
|
93
|
+
if (stat.isSymbolicLink()) return { error: 'Links cannot be changed here', statusCode: 409 };
|
|
94
|
+
if (!stat.isDirectory()) return { ok: true, kind: 'file', files: 0, dirs: 0, capped: false };
|
|
95
|
+
|
|
96
|
+
let files = 0;
|
|
97
|
+
let dirs = 0;
|
|
98
|
+
let capped = false;
|
|
99
|
+
const stack = [target];
|
|
100
|
+
while (stack.length && !capped) {
|
|
101
|
+
const dir = stack.pop();
|
|
102
|
+
let entries = [];
|
|
103
|
+
try {
|
|
104
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
105
|
+
} catch {
|
|
106
|
+
continue; // an unreadable branch is still removed by rm; it just stays uncounted
|
|
107
|
+
}
|
|
108
|
+
for (const entry of entries) {
|
|
109
|
+
if (entry.isDirectory() && !entry.isSymbolicLink()) {
|
|
110
|
+
dirs += 1;
|
|
111
|
+
stack.push(path.join(dir, entry.name));
|
|
112
|
+
} else {
|
|
113
|
+
files += 1;
|
|
114
|
+
}
|
|
115
|
+
if (files + dirs >= cap) {
|
|
116
|
+
capped = true;
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return { ok: true, kind: 'dir', files, dirs, capped };
|
|
122
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// Serving a file from disk so that what arrives is the file as it is now:
|
|
2
|
+
// one open, one fstat, and length, validators and bytes all from that handle.
|
|
3
|
+
// The headers tell every cache on the way, the browser's and a CDN's alike,
|
|
4
|
+
// to keep nothing.
|
|
5
|
+
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
|
|
9
|
+
import { applySafeServedContentHeaders } from './safe-served-content.mjs';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A short token that changes whenever the file's bytes may have changed
|
|
13
|
+
* (modification time or size). It goes into download URLs as `v=…`: the
|
|
14
|
+
* server ignores it, and a changed file gets a URL nothing has seen before.
|
|
15
|
+
*/
|
|
16
|
+
export function fileVersionToken({ mtimeMs, size } = {}) {
|
|
17
|
+
const modified = Math.floor(Number(mtimeMs));
|
|
18
|
+
const bytes = Number(size);
|
|
19
|
+
if (!Number.isFinite(modified) || !Number.isFinite(bytes) || modified < 0 || bytes < 0) return '';
|
|
20
|
+
return `${modified.toString(36)}-${Math.floor(bytes).toString(36)}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* `Cache-Control` speaks to browsers and to proxies that honour it. The two
|
|
25
|
+
* CDN headers bind the edge itself: Cloudflare obeys them ahead of
|
|
26
|
+
* `Cache-Control` and of a "cache everything" rule that would otherwise
|
|
27
|
+
* override the origin's wish.
|
|
28
|
+
*/
|
|
29
|
+
export function applyNoStoreHeaders(res) {
|
|
30
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
31
|
+
applyCdnNoStoreHeaders(res);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function applyCdnNoStoreHeaders(res) {
|
|
35
|
+
res.setHeader('CDN-Cache-Control', 'no-store');
|
|
36
|
+
res.setHeader('Cloudflare-CDN-Cache-Control', 'no-store');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The name a download is saved under, when the client asks for one (`as=…`).
|
|
41
|
+
* Accepted only as a plain file name with the real file's extension, so the
|
|
42
|
+
* request cannot change what kind of file the browser believes it gets.
|
|
43
|
+
*/
|
|
44
|
+
export function downloadNameOverride(requested, realName) {
|
|
45
|
+
const wanted = String(requested || '').trim();
|
|
46
|
+
if (!wanted || wanted.length > 200) return '';
|
|
47
|
+
if (/[\\/:*?"<>|\u0000-\u001f\u007f]/.test(wanted) || wanted === '.' || wanted === '..') return '';
|
|
48
|
+
const realExt = path.extname(String(realName || '')).toLowerCase();
|
|
49
|
+
if (path.extname(wanted).toLowerCase() !== realExt) return '';
|
|
50
|
+
return wanted;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function appendUrlParam(url, key, value) {
|
|
54
|
+
if (!value) return url;
|
|
55
|
+
return `${url}${url.includes('?') ? '&' : '?'}${key}=${encodeURIComponent(value)}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** `url` with the file's version token appended, or unchanged when there is none. */
|
|
59
|
+
export function withFileVersion(url, version) {
|
|
60
|
+
return appendUrlParam(String(url || ''), 'v', String(version || ''));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* `cacheControl` replaces the no-store default for content whose URL names
|
|
65
|
+
* its bytes (an upload addressed by its hash): such an answer may be kept.
|
|
66
|
+
*/
|
|
67
|
+
export function serveFileWithRangeSupport(req, res, filePath, meta, { safeName, cacheDelete = null, fsImpl = fs, cacheControl = '' } = {}) {
|
|
68
|
+
const realName = safeName || path.basename(filePath).replace(/"/g, '');
|
|
69
|
+
const name = downloadNameOverride(req.query?.as, realName) || realName;
|
|
70
|
+
const rangeHeader = req.headers['range'];
|
|
71
|
+
|
|
72
|
+
const fail = (error) => {
|
|
73
|
+
if (cacheDelete) cacheDelete(filePath);
|
|
74
|
+
if (res.headersSent) { res.destroy(error); return; }
|
|
75
|
+
if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR' || error?.code === 'EISDIR') {
|
|
76
|
+
res.status(404).json({ error: 'File not found' });
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
res.status(500).json({ error: 'Failed to read file' });
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// The caller's `meta` may be a moment old (it is cached). Size, validators
|
|
83
|
+
// and bytes all come from this one handle instead: a length taken from an
|
|
84
|
+
// older stat cut a file that had just grown down to its previous size.
|
|
85
|
+
let fd = null;
|
|
86
|
+
let stat = null;
|
|
87
|
+
try {
|
|
88
|
+
fd = fsImpl.openSync(filePath, 'r');
|
|
89
|
+
stat = fsImpl.fstatSync(fd);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
if (fd !== null) { try { fsImpl.closeSync(fd); } catch {} }
|
|
92
|
+
fail(error);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const closeFd = () => { try { fsImpl.closeSync(fd); } catch {} };
|
|
96
|
+
if (!stat.isFile()) {
|
|
97
|
+
closeFd();
|
|
98
|
+
fail(Object.assign(new Error('Not a file'), { code: 'EISDIR' }));
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const fileSize = Number(stat.size || 0);
|
|
102
|
+
|
|
103
|
+
// From here until the stream owns the handle, any throw (a header value
|
|
104
|
+
// `setHeader` refuses, say) must close the handle, or every such request
|
|
105
|
+
// would leak one.
|
|
106
|
+
let start = 0;
|
|
107
|
+
let end = fileSize - 1;
|
|
108
|
+
try {
|
|
109
|
+
res.setHeader('Accept-Ranges', 'bytes');
|
|
110
|
+
if (cacheControl) res.setHeader('Cache-Control', cacheControl);
|
|
111
|
+
else applyNoStoreHeaders(res);
|
|
112
|
+
// Validators cost nothing next to no-store, and they let a client or a
|
|
113
|
+
// person with curl see which version of the file an answer carries.
|
|
114
|
+
const version = fileVersionToken({ mtimeMs: stat.mtimeMs, size: fileSize });
|
|
115
|
+
if (version) res.setHeader('ETag', `W/"${version}"`);
|
|
116
|
+
if (Number.isFinite(stat.mtimeMs)) res.setHeader('Last-Modified', new Date(stat.mtimeMs).toUTCString());
|
|
117
|
+
// Neutralize browser-executable types (HTML/SVG/XML), force nosniff, and
|
|
118
|
+
// sandbox the response so a worker-written workspace file can't run as script
|
|
119
|
+
// on this origin. Media stays inline for preview; everything else downloads.
|
|
120
|
+
applySafeServedContentHeaders(res, meta?.contentType, { fileName: name });
|
|
121
|
+
|
|
122
|
+
if (rangeHeader) {
|
|
123
|
+
const match = /bytes=(\d+)-(\d*)/.exec(rangeHeader);
|
|
124
|
+
const from = match ? parseInt(match[1], 10) : NaN;
|
|
125
|
+
const to = match && match[2] ? parseInt(match[2], 10) : fileSize - 1;
|
|
126
|
+
if (!match || from > to || from >= fileSize || to >= fileSize) {
|
|
127
|
+
closeFd();
|
|
128
|
+
res.setHeader('Content-Range', `bytes */${fileSize}`);
|
|
129
|
+
res.status(416).end();
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
start = from;
|
|
133
|
+
end = to;
|
|
134
|
+
res.setHeader('Content-Range', `bytes ${start}-${end}/${fileSize}`);
|
|
135
|
+
res.status(206);
|
|
136
|
+
}
|
|
137
|
+
res.setHeader('Content-Length', String(Math.max(0, end - start + 1)));
|
|
138
|
+
} catch (error) {
|
|
139
|
+
closeFd();
|
|
140
|
+
fail(error);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (end - start + 1 <= 0) {
|
|
144
|
+
closeFd();
|
|
145
|
+
res.end();
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
// Bounded to the size just read, so a file that grows while it is sent
|
|
149
|
+
// still yields exactly the announced number of bytes.
|
|
150
|
+
const stream = fsImpl.createReadStream(null, { fd, start, end, autoClose: true });
|
|
151
|
+
stream.on('error', fail);
|
|
152
|
+
// A client that goes away mid-download would otherwise leave the handle open.
|
|
153
|
+
res.on?.('close', () => stream.destroy());
|
|
154
|
+
stream.pipe(res);
|
|
155
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The list of pinned messages a conversation carries: one short plain-text
|
|
4
|
+
// preview per pin, so the browser can show the list without loading (or
|
|
5
|
+
// rendering markdown for) messages outside its history page.
|
|
6
|
+
|
|
7
|
+
import { stripRelayPromptContext } from './relay-prompt-sanitizer.mjs';
|
|
8
|
+
|
|
9
|
+
export const MESSAGE_PIN_LIMIT = 100;
|
|
10
|
+
export const PIN_PREVIEW_MAX_LENGTH = 160;
|
|
11
|
+
|
|
12
|
+
function countAttachments(raw) {
|
|
13
|
+
if (Array.isArray(raw)) return raw.length;
|
|
14
|
+
if (typeof raw !== 'string' || !raw) return 0;
|
|
15
|
+
try {
|
|
16
|
+
const parsed = JSON.parse(raw);
|
|
17
|
+
return Array.isArray(parsed) ? parsed.length : 0;
|
|
18
|
+
} catch {
|
|
19
|
+
return 0;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Light on purpose: enough that a heading, a list or a link reads as a line of
|
|
24
|
+
// text. Single * and _ stay, they are more often part of a name than emphasis.
|
|
25
|
+
export function buildPinPreview(text, mode = '') {
|
|
26
|
+
const plain = String(stripRelayPromptContext(String(text || ''), mode) || '')
|
|
27
|
+
.replace(/```[^\n]*\n?/g, ' ')
|
|
28
|
+
.replace(/!\[([^\]]*)\]\([^)]*\)/g, (_match, alt) => (alt ? `🖼 ${alt}` : '🖼'))
|
|
29
|
+
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
|
|
30
|
+
.replace(/^\s{0,3}(?:#{1,6}|>+|[-*+]|\d+[.)])\s+/gm, '')
|
|
31
|
+
.replace(/\*\*|__|~~|`/g, '')
|
|
32
|
+
.replace(/\s+/g, ' ')
|
|
33
|
+
.trim();
|
|
34
|
+
// By code point, so an emoji at the cut is dropped whole.
|
|
35
|
+
const points = Array.from(plain);
|
|
36
|
+
if (points.length <= PIN_PREVIEW_MAX_LENGTH) return plain;
|
|
37
|
+
return `${points.slice(0, PIN_PREVIEW_MAX_LENGTH - 1).join('').trimEnd()}…`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function buildPinList(rows) {
|
|
41
|
+
return (Array.isArray(rows) ? rows : [])
|
|
42
|
+
.filter((row) => String(row?.id || '').trim() && row?.pinned_at)
|
|
43
|
+
.map((row) => ({
|
|
44
|
+
messageId: String(row.id).trim(),
|
|
45
|
+
role: String(row.role || '').trim(),
|
|
46
|
+
preview: buildPinPreview(row.text, row.mode),
|
|
47
|
+
timestamp: row.timestamp || null,
|
|
48
|
+
pinnedAt: row.pinned_at,
|
|
49
|
+
attachmentCount: countAttachments(row.attachments),
|
|
50
|
+
hiddenFromShares: Number(row.hidden_from_shares || 0) === 1,
|
|
51
|
+
}));
|
|
52
|
+
}
|