@oar-sh/oar 0.9.7 → 0.9.9

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 (64) hide show
  1. package/CHANGELOG.md +344 -0
  2. package/README.md +89 -40
  3. package/bin/oar.js +1023 -135
  4. package/package.json +4 -5
  5. package/server/README.md +20 -1
  6. package/server/claude-worker/claude-sdk-adapter.mjs +0 -11
  7. package/server/claude-worker/claude-session-process.mjs +196 -28
  8. package/server/claude-worker/sdk-message-normalizer.mjs +28 -0
  9. package/server/db-schema.mjs +8 -0
  10. package/server/migrations/0008-message-pins.mjs +85 -0
  11. package/server/public/app/activity-replay-state.mjs +68 -5
  12. package/server/public/app/api-client.js +36 -2
  13. package/server/public/app/attachments-view.js +508 -125
  14. package/server/public/app/back-guard.mjs +58 -0
  15. package/server/public/app/bootstrap.js +170 -59
  16. package/server/public/app/context-usage-view.mjs +127 -8
  17. package/server/public/app/conversation-view.js +172 -32
  18. package/server/public/app/external-link-policy.mjs +4 -1
  19. package/server/public/app/file-version.mjs +58 -0
  20. package/server/public/app/journal-view.js +16 -2
  21. package/server/public/app/pinned-messages-view.mjs +253 -0
  22. package/server/public/app/pwa-install.js +6 -1
  23. package/server/public/app/relay-dot-state.mjs +8 -1
  24. package/server/public/app/router.js +8 -2
  25. package/server/public/app/scrollbar-hover.mjs +78 -0
  26. package/server/public/app/settings-modal.js +4 -1
  27. package/server/public/app/socket-handlers.js +8 -1
  28. package/server/public/app/store.js +32 -0
  29. package/server/public/app/transcript-separators.mjs +48 -11
  30. package/server/public/app/viewer-chat-gallery.mjs +59 -0
  31. package/server/public/app/viewer-controls-state.mjs +59 -0
  32. package/server/public/app/viewer-gallery.mjs +46 -0
  33. package/server/public/app/viewer-gestures.mjs +105 -0
  34. package/server/public/app/viewer-history.mjs +43 -0
  35. package/server/public/app/viewer-tap.mjs +112 -0
  36. package/server/public/app-icon-192.png +0 -0
  37. package/server/public/app-icon-512.png +0 -0
  38. package/server/public/app-icon-light-192.png +0 -0
  39. package/server/public/app-icon-light-512.png +0 -0
  40. package/server/public/app-icon-light-maskable-512.png +0 -0
  41. package/server/public/app-icon-light.svg +4 -0
  42. package/server/public/app-icon-maskable-512.png +0 -0
  43. package/server/public/app-icon.svg +3 -3
  44. package/server/public/favicon.ico +0 -0
  45. package/server/public/index.html +487 -67
  46. package/server/public/manifest.webmanifest +9 -3
  47. package/server/public/sw.js +6 -1
  48. package/server/repositories/message-pin-statements.mjs +39 -0
  49. package/server/repositories/message-repository.mjs +5 -0
  50. package/server/routes/messages-routes.mjs +36 -62
  51. package/server/routes/sessions-routes.mjs +107 -26
  52. package/server/server-runtime.mjs +39 -8
  53. package/server/services/claude-context-usage.mjs +51 -3
  54. package/server/services/cloudflared-tunnel-service.mjs +139 -41
  55. package/server/services/file-serving.mjs +155 -0
  56. package/server/services/message-pin-list.mjs +52 -0
  57. package/server/services/oar-cli-helpers.mjs +205 -4
  58. package/server/services/oar-cli-menu.mjs +240 -0
  59. package/server/services/oar-cli-wsl.mjs +129 -0
  60. package/server/services/pwa-manifest-theme.mjs +30 -0
  61. package/server/services/safe-served-content.mjs +61 -11
  62. package/server/services/session-history-refresh-service.mjs +24 -0
  63. package/server/services/session-worker-registry-service.mjs +18 -1
  64. package/server/services/update-install-service.mjs +9 -3
@@ -24,6 +24,7 @@ import {
24
24
  normalizeWorkspaceRootKey,
25
25
  } from './services/workspace-root-path-policy.mjs';
26
26
  import { stopSessionWorkerProcesses } from './services/session-worker-stop-service.mjs';
27
+ import { applyCdnNoStoreHeaders } from './services/file-serving.mjs';
27
28
  import { pickLiveTurnRowId } from './services/live-turn-picker.mjs';
28
29
  import { buildSteerSettleFailure, steerAgentLabelForProvider } from '../shared/steer-settle-failure.mjs';
29
30
  import { sanitizeRelayQuestionContext as sanitizeRelayQuestionContextWith } from './services/relay-question-context.mjs';
@@ -176,6 +177,7 @@ import {
176
177
  isSafeProviderModelId,
177
178
  } from '../shared/model-id.mjs';
178
179
  import { createModelVariantCatalogService } from './services/model-variant-catalog-service.mjs';
180
+ import { applyPwaManifestTheme, normalizePwaTheme } from './services/pwa-manifest-theme.mjs';
179
181
 
180
182
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
181
183
  const REPO_ROOT = path.resolve(__dirname, '..');
@@ -1509,6 +1511,17 @@ const WORKSPACE_CONTENT_TYPES = Object.freeze({
1509
1511
  '.gif': 'image/gif',
1510
1512
  '.webp': 'image/webp',
1511
1513
  '.ico': 'image/x-icon',
1514
+ '.bmp': 'image/bmp',
1515
+ '.avif': 'image/avif',
1516
+ '.mp3': 'audio/mpeg',
1517
+ '.wav': 'audio/wav',
1518
+ '.m4a': 'audio/mp4',
1519
+ '.aac': 'audio/aac',
1520
+ '.ogg': 'audio/ogg',
1521
+ '.oga': 'audio/ogg',
1522
+ '.opus': 'audio/ogg',
1523
+ '.flac': 'audio/flac',
1524
+ '.weba': 'audio/webm',
1512
1525
  '.mp4': 'video/mp4',
1513
1526
  '.m4v': 'video/mp4',
1514
1527
  '.mov': 'video/quicktime',
@@ -1649,6 +1662,7 @@ const WORKSPACE_CODE_EXTENSIONS = new Set(Object.keys(WORKSPACE_PREVIEW_LANGUAGE
1649
1662
  .filter((ext) => !WORKSPACE_MARKDOWN_EXTENSIONS.has(ext)));
1650
1663
  const WORKSPACE_IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.ico', '.bmp', '.avif']);
1651
1664
  const WORKSPACE_VIDEO_EXTENSIONS = new Set(['.mp4', '.m4v', '.mov', '.webm', '.mkv', '.avi', '.ogv', '.wmv']);
1665
+ const WORKSPACE_AUDIO_EXTENSIONS = new Set(['.mp3', '.wav', '.m4a', '.aac', '.ogg', '.oga', '.opus', '.flac', '.weba']);
1652
1666
  const REPO_HEAVY_DIR_NAMES = new Set(['.git', 'node_modules']);
1653
1667
  const MAX_REPO_TREE_NODES = 20_000;
1654
1668
  const MAX_REPO_TREE_DEPTH = 64;
@@ -2992,6 +3006,7 @@ const updateCheckService = updateCheckKilled ? null : createUpdateCheckService({
2992
3006
  const updateInstallService = createUpdateInstallService({
2993
3007
  runningVersion: RUNNING_VERSION,
2994
3008
  installMethod: INSTALL_METHOD,
3009
+ packageRoot: PACKAGE_ROOT,
2995
3010
  readSetting: readAppSettingValue,
2996
3011
  writeSetting: (key, value) => stmts.upsertAppSetting.run(key, value, new Date().toISOString()),
2997
3012
  deleteSetting: (key) => stmts.deleteAppSetting.run(key),
@@ -4146,6 +4161,7 @@ function readWorkspaceFileMeta(filePath) {
4146
4161
  return cacheWorkspaceFileMeta(filePath, {
4147
4162
  kind: 'file',
4148
4163
  size: Number(stat.size || 0),
4164
+ mtimeMs: Number(stat.mtimeMs || 0),
4149
4165
  contentType: workspaceContentType(filePath),
4150
4166
  });
4151
4167
  }
@@ -4212,6 +4228,8 @@ function workspacePreviewKindForMeta(filePath, contentType) {
4212
4228
  if (WORKSPACE_MARKDOWN_EXTENSIONS.has(normalizedExt)) return 'markdown';
4213
4229
  if (WORKSPACE_IMAGE_EXTENSIONS.has(normalizedExt) || normalizedType.startsWith('image/')) return 'image';
4214
4230
  if (WORKSPACE_VIDEO_EXTENSIONS.has(normalizedExt) || normalizedType.startsWith('video/')) return 'video';
4231
+ if (WORKSPACE_AUDIO_EXTENSIONS.has(normalizedExt) || normalizedType.startsWith('audio/')) return 'audio';
4232
+ if (normalizedExt === '.pdf' || normalizedType === 'application/pdf') return 'pdf';
4215
4233
  if (WORKSPACE_CODE_EXTENSIONS.has(normalizedExt) || WORKSPACE_PREVIEW_LANGUAGE_BY_FILENAME[filename]) return 'code';
4216
4234
  if (isLikelyTextContentType(normalizedType)) return 'text';
4217
4235
  return 'binary';
@@ -5525,8 +5543,7 @@ function shouldIncludeInPwaVersion(relativePath) {
5525
5543
  const normalized = String(relativePath).replace(/\\/g, '/').replace(/^\/+/, '');
5526
5544
  if (!normalized || normalized.startsWith('.')) return false;
5527
5545
  if (normalized === 'index.html' || normalized === 'sw.js' || normalized === 'manifest.webmanifest') return true;
5528
- if (/^app-icon(?:-\d+)?\.png$/i.test(normalized)) return true;
5529
- if (/^app-icon\.svg$/i.test(normalized)) return true;
5546
+ if (/^app-icon(?:-light)?(?:-maskable)?(?:-\d+)?\.(?:png|svg)$/i.test(normalized)) return true;
5530
5547
  if (/^favicon\.ico$/i.test(normalized)) return true;
5531
5548
  if (normalized.startsWith('app/')) {
5532
5549
  const ext = path.extname(normalized).toLowerCase();
@@ -5598,12 +5615,13 @@ function loadPwaManifestTemplate() {
5598
5615
  description: 'OAR — Open Agent Relay: drive your local coding agents from any browser.',
5599
5616
  display_override: ['standalone'],
5600
5617
  display: 'standalone',
5601
- background_color: '#161b22',
5602
- theme_color: '#161b22',
5618
+ background_color: '#0d1117',
5619
+ theme_color: '#0d1117',
5603
5620
  icons: [
5604
- { src: 'app-icon.svg?v=25', sizes: 'any', type: 'image/svg+xml', purpose: 'any' },
5621
+ { src: 'app-icon.svg?v=26', sizes: 'any', type: 'image/svg+xml', purpose: 'any' },
5605
5622
  { src: 'app-icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
5606
5623
  { src: 'app-icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
5624
+ { src: 'app-icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
5607
5625
  ],
5608
5626
  };
5609
5627
  try {
@@ -5614,8 +5632,8 @@ function loadPwaManifestTemplate() {
5614
5632
  return fallback;
5615
5633
  }
5616
5634
 
5617
- function buildScopedPwaManifest({ shared = false } = {}) {
5618
- const manifest = loadPwaManifestTemplate();
5635
+ function buildScopedPwaManifest({ shared = false, theme = 'dark' } = {}) {
5636
+ const manifest = applyPwaManifestTheme(loadPwaManifestTemplate(), theme);
5619
5637
  // The custom name must be baked into every served manifest: Android's WebAPK
5620
5638
  // update check re-fetches this route outside any page session, so a name the
5621
5639
  // route doesn't know reverts on the phone with an accept-or-uninstall prompt.
@@ -6339,6 +6357,10 @@ app.use((req, _res, next) => {
6339
6357
  stripRequestPathPrefix(req, remotePath);
6340
6358
  next();
6341
6359
  });
6360
+ function isApiRequestPath(requestPath) {
6361
+ return /^\/api(?:\/|$)/.test(String(requestPath || ''));
6362
+ }
6363
+
6342
6364
  // Baseline security headers on every response. Served-file/attachment endpoints
6343
6365
  // layer a stricter sandbox CSP on top (see applySafeServedContentHeaders). A
6344
6366
  // global script-src CSP is intentionally omitted for now: the SPA relies on
@@ -6347,6 +6369,14 @@ app.use((req, res, next) => {
6347
6369
  res.setHeader('X-Content-Type-Options', 'nosniff');
6348
6370
  res.setHeader('X-Frame-Options', 'SAMEORIGIN');
6349
6371
  res.setHeader('Referrer-Policy', 'no-referrer');
6372
+ // Nothing this relay answers belongs in a CDN's cache: it is all behind a
6373
+ // login. These two headers bind the edge itself, whatever cache rule the
6374
+ // zone in front of a tunnel has.
6375
+ applyCdnNoStoreHeaders(res);
6376
+ // An API answer is never stored by default, errors included (a 404 or a
6377
+ // 401 for a file must not outlive the file's absence). A route that
6378
+ // serves content-addressed bytes sets its own, longer-lived value.
6379
+ if (isApiRequestPath(req.path)) res.setHeader('Cache-Control', 'no-store');
6350
6380
  next();
6351
6381
  });
6352
6382
  app.get('/socket.io/socket.io.js', (req, res, next) => {
@@ -6358,7 +6388,7 @@ app.get('/socket.io/socket.io.js', (req, res, next) => {
6358
6388
  app.get('/manifest.webmanifest', (req, res, next) => {
6359
6389
  try {
6360
6390
  const shared = String(req.query?.shared || '').trim() === '1';
6361
- const manifest = buildScopedPwaManifest({ shared });
6391
+ const manifest = buildScopedPwaManifest({ shared, theme: normalizePwaTheme(req.query?.theme) });
6362
6392
  res.setHeader('Cache-Control', 'no-store');
6363
6393
  res.type('application/manifest+json').send(JSON.stringify(manifest, null, 2));
6364
6394
  } catch (error) {
@@ -6550,6 +6580,7 @@ const sharedRouteDeps = {
6550
6580
  compareRepoDirEntries,
6551
6581
  shouldSkipRepoEntryName,
6552
6582
  readWorkspaceFileMeta,
6583
+ workspaceFileMetaCache,
6553
6584
  resolveWorkspaceFilePath,
6554
6585
  normalizeWorkspaceRelativePath,
6555
6586
  previewLanguageForWorkspaceFile,
@@ -209,26 +209,40 @@ export function buildClaudeContextSnapshot({
209
209
  };
210
210
  }
211
211
 
212
+ /**
213
+ * The model's own context limit in tokens, from the turn result's
214
+ * `modelUsage[model].contextWindow`. Unlike the usage response's maxTokens
215
+ * (which follows the active auto-compact window), this is what the model can
216
+ * hold. Null when the snapshot does not carry it.
217
+ */
218
+ export function resolveModelContextLimit({ contextUsage = null, modelUsage = null, model = '' } = {}) {
219
+ const usage = normalizeClaudeContextUsage(contextUsage);
220
+ const entry = resolveModelUsageEntry(modelUsage, model || usage?.model);
221
+ const limit = toNullableInt(entry?.contextWindow);
222
+ return limit !== null && limit > 0 ? limit : null;
223
+ }
224
+
212
225
  /**
213
226
  * Parse the JSON blob persisted on `runtime_sessions` and rebuild both the
214
227
  * snapshot and the normalized usage the view layer renders.
215
228
  */
216
229
  export function readStoredClaudeContextUsage(row) {
217
230
  const rawJson = normalizeText(row?.context_usage_json);
218
- if (!rawJson) return { snapshot: null, contextUsage: null };
231
+ if (!rawJson) return { snapshot: null, contextUsage: null, modelContextLimit: null };
219
232
 
220
233
  let parsed = null;
221
234
  try {
222
235
  parsed = JSON.parse(rawJson);
223
236
  } catch {
224
- return { snapshot: null, contextUsage: null };
237
+ return { snapshot: null, contextUsage: null, modelContextLimit: null };
225
238
  }
226
239
 
227
240
  const capturedAt = normalizeText(row?.context_usage_captured_at) || null;
241
+ const model = parsed?.model || row?.provider_model || row?.model || '';
228
242
  const snapshot = buildClaudeContextSnapshot({
229
243
  contextUsage: parsed?.contextUsage,
230
244
  modelUsage: parsed?.modelUsage,
231
- model: parsed?.model || row?.provider_model || row?.model || '',
245
+ model,
232
246
  runtimeSessionId: row?.id || null,
233
247
  sdkSessionId: row?.sdk_session_id || null,
234
248
  capturedAt,
@@ -236,5 +250,39 @@ export function readStoredClaudeContextUsage(row) {
236
250
  return {
237
251
  snapshot,
238
252
  contextUsage: normalizeClaudeContextUsage(parsed?.contextUsage),
253
+ modelContextLimit: resolveModelContextLimit({
254
+ contextUsage: parsed?.contextUsage,
255
+ modelUsage: parsed?.modelUsage,
256
+ model,
257
+ }),
239
258
  };
240
259
  }
260
+
261
+ /**
262
+ * Where the conversation's stored compaction window stands against the
263
+ * running Claude CLI, for the context-usage modal. `steering` is the worker's
264
+ * heartbeat snapshot; its `autoCompactWindow` ({active, backgroundWork}) is
265
+ * present only while a CLI runs, so an older worker or a stopped one yields
266
+ * nulls.
267
+ *
268
+ * - `activeAutoCompactWindow`: the window the running CLI was spawned with.
269
+ * - `autoCompactWindowDeferred`: why a stored window that differs from it is
270
+ * not applied yet — 'turn' (a turn is running), 'background' (idle, but
271
+ * background agents/shells hold the process) or 'next-message' (the next
272
+ * message respawns the CLI). Null when the two agree or no CLI runs.
273
+ */
274
+ export function resolveAutoCompactWindowStatus({ storedWindow = null, steering = null } = {}) {
275
+ const running = steering?.autoCompactWindow;
276
+ if (!running || typeof running !== 'object') {
277
+ return { activeAutoCompactWindow: null, autoCompactWindowDeferred: null };
278
+ }
279
+ const active = toNullableInt(running.active);
280
+ const stored = toNullableInt(storedWindow);
281
+ let deferred = null;
282
+ if (stored !== active) {
283
+ if (steering.turnActive === true) deferred = 'turn';
284
+ else if (running.backgroundWork === true) deferred = 'background';
285
+ else deferred = 'next-message';
286
+ }
287
+ return { activeAutoCompactWindow: active, autoCompactWindowDeferred: deferred };
288
+ }
@@ -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,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
+ }