@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.
Files changed (107) hide show
  1. package/CHANGELOG.md +478 -0
  2. package/README.md +129 -39
  3. package/bin/bat/oar.bat +46 -46
  4. package/bin/oar.js +814 -228
  5. package/package.json +5 -6
  6. package/server/README.md +306 -4
  7. package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
  8. package/server/claude-worker/claude-session-process.mjs +35 -1
  9. package/server/claude-worker/claude-turn-publisher.mjs +3 -0
  10. package/server/claude-worker/sdk-message-normalizer.mjs +31 -4
  11. package/server/claude-worker/subagent-timeline-recorder.mjs +572 -0
  12. package/server/copilot-worker/copilot-sdk-session-process.mjs +3 -1
  13. package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
  14. package/server/db-schema.mjs +44 -0
  15. package/server/migrations/0008-message-pins.mjs +85 -0
  16. package/server/migrations/0009-subagent-timeline.mjs +135 -0
  17. package/server/migrations/0010-subagent-timeline-pruning.mjs +89 -0
  18. package/server/migrations/0011-relay-event-subscriptions.mjs +125 -0
  19. package/server/migrations/0012-cross-relay-event-subscriptions.mjs +116 -0
  20. package/server/public/app/agent-exchange-fold-view.mjs +248 -0
  21. package/server/public/app/api-client.js +91 -0
  22. package/server/public/app/archive-confirm.mjs +28 -0
  23. package/server/public/app/ask-user-view.js +3 -0
  24. package/server/public/app/attachments-view.js +1024 -131
  25. package/server/public/app/back-guard.mjs +58 -0
  26. package/server/public/app/background-tasks-view.mjs +18 -26
  27. package/server/public/app/bootstrap.js +230 -35
  28. package/server/public/app/chat-actions-menu-state.mjs +116 -0
  29. package/server/public/app/claude-cloud-conversation-ui.js +10 -22
  30. package/server/public/app/composer-control-state.mjs +16 -0
  31. package/server/public/app/composer-keyboard.mjs +54 -0
  32. package/server/public/app/conversation-view.js +471 -110
  33. package/server/public/app/explorer-edit-state.mjs +68 -0
  34. package/server/public/app/external-link-policy.mjs +4 -1
  35. package/server/public/app/file-version.mjs +58 -0
  36. package/server/public/app/host-suspend-ui.js +6 -3
  37. package/server/public/app/journal-view.js +161 -27
  38. package/server/public/app/no-conversation-state.mjs +58 -0
  39. package/server/public/app/pinned-messages-view.mjs +253 -0
  40. package/server/public/app/provider-readiness.mjs +120 -0
  41. package/server/public/app/pwa-install.js +1 -1
  42. package/server/public/app/relay-board-view.js +3 -0
  43. package/server/public/app/relay-dot-state.mjs +8 -1
  44. package/server/public/app/relay-event-subscriptions-ui.js +184 -0
  45. package/server/public/app/relay-event-view.mjs +234 -0
  46. package/server/public/app/router.js +8 -2
  47. package/server/public/app/settings-modal.js +53 -1
  48. package/server/public/app/socket-handlers.js +65 -28
  49. package/server/public/app/store.js +25 -2
  50. package/server/public/app/subagent-row.mjs +138 -0
  51. package/server/public/app/subagent-timeline-view.mjs +186 -0
  52. package/server/public/app/subagent-viewer-watch.mjs +50 -0
  53. package/server/public/app/subagent-viewer.mjs +534 -0
  54. package/server/public/app/task-row-format.mjs +29 -0
  55. package/server/public/app/thinking-follow.js +4 -0
  56. package/server/public/app/transcript-jump-bottom-view.mjs +290 -0
  57. package/server/public/app/transcript-jump-bottom.mjs +99 -0
  58. package/server/public/app/transcript-reload-freshness.mjs +25 -0
  59. package/server/public/app/update-ui.js +1 -1
  60. package/server/public/app/viewer-chat-gallery.mjs +59 -0
  61. package/server/public/app/viewer-controls-state.mjs +59 -0
  62. package/server/public/app/viewer-gallery.mjs +46 -0
  63. package/server/public/app/viewer-gestures.mjs +105 -0
  64. package/server/public/app/viewer-history.mjs +45 -0
  65. package/server/public/app/viewer-tap.mjs +112 -0
  66. package/server/public/index.html +700 -59
  67. package/server/repositories/message-pin-statements.mjs +39 -0
  68. package/server/repositories/message-repository.mjs +34 -3
  69. package/server/repositories/question-repository.mjs +9 -3
  70. package/server/repositories/remote-relay-repository.mjs +6 -2
  71. package/server/repositories/subagent-timeline-statements.mjs +170 -0
  72. package/server/routes/ask-user-routes.mjs +3 -0
  73. package/server/routes/messages-routes.mjs +548 -111
  74. package/server/routes/relay-event-routes.mjs +157 -0
  75. package/server/routes/remote-relay-routes.mjs +15 -0
  76. package/server/routes/sessions-routes.mjs +164 -24
  77. package/server/server-runtime.mjs +274 -62
  78. package/server/services/cloudflared-tunnel-service.mjs +139 -41
  79. package/server/services/explorer-entry-name.mjs +26 -0
  80. package/server/services/explorer-mutations.mjs +122 -0
  81. package/server/services/file-serving.mjs +155 -0
  82. package/server/services/message-pin-list.mjs +52 -0
  83. package/server/services/oar-cli-helpers.mjs +139 -4
  84. package/server/services/oar-cli-menu.mjs +240 -0
  85. package/server/services/oar-cli-wsl.mjs +129 -0
  86. package/server/services/preview-proxy-server.mjs +41 -23
  87. package/server/services/push-dispatch-service.mjs +18 -0
  88. package/server/services/relay-event-link-service.mjs +289 -0
  89. package/server/services/relay-event-service.mjs +1935 -0
  90. package/server/services/relay-shutdown-gate.mjs +31 -0
  91. package/server/services/remote-relay-caller-context.mjs +52 -7
  92. package/server/services/remote-relay-dispatcher.mjs +494 -2
  93. package/server/services/remote-relay-registry-service.mjs +11 -1
  94. package/server/services/safe-served-content.mjs +61 -11
  95. package/server/services/session-activity-tracker.mjs +487 -0
  96. package/server/services/session-history-refresh-service.mjs +31 -0
  97. package/server/services/stale-attempt-log-limiter.mjs +96 -0
  98. package/server/services/stale-recovery-clock-guard.mjs +57 -0
  99. package/server/services/stale-subagent-run-sweep.mjs +109 -0
  100. package/server/services/subagent-record-retention.mjs +164 -0
  101. package/server/services/subagent-timeline-service.mjs +328 -0
  102. package/server/services/subagent-viewer-rooms.mjs +119 -0
  103. package/server/services/upgrade-socket-guard.mjs +16 -0
  104. package/shared/relay-events.mjs +371 -0
  105. package/shared/remote-relay-contract.mjs +157 -5
  106. package/shared/subagent-record-retention.mjs +31 -0
  107. 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
- // The npm `cloudflared` package downloads the official binary on demand; it is an
33
- // optional dependency, so absence must degrade to a config error, never a crash.
34
- // `requireImpl` is a seam: the package is optional, so a suite running under
35
- // `npm ci --omit=optional` (or after a failed postinstall download) must still be
36
- // able to exercise this logic instead of silently testing the host's install state.
37
- export function resolveCloudflaredBinaryFromPackage({
38
- existsSync = fs.existsSync,
39
- requireImpl = requireFromHere,
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
- try {
42
- const mod = requireImpl('cloudflared');
43
- const bin = toText(mod?.bin);
44
- // `bin` is where the package *will* place the binary; it only exists after
45
- // the on-demand download has run, so an un-downloaded path is not usable.
46
- if (bin && existsSync(bin)) return bin;
47
- } catch {}
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
- let binary = '';
68
- let binarySource = null;
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 = binaryInput.includes('/') || binaryInput.includes('\\')
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
- resolveBinary = resolveCloudflaredBinaryFromPackage,
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: ${tunnelConfig.binary} ${redactCloudflaredArgs(args).join(' ')}`);
225
- const proc = spawnImpl(tunnelConfig.binary, args, buildSpawnOptions(platform, ['ignore', 'pipe', 'pipe']));
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
+ }