@oar-sh/oar 0.9.8 → 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 (41) hide show
  1. package/CHANGELOG.md +231 -0
  2. package/README.md +53 -32
  3. package/bin/oar.js +814 -228
  4. package/package.json +4 -5
  5. package/server/README.md +20 -1
  6. package/server/db-schema.mjs +8 -0
  7. package/server/migrations/0008-message-pins.mjs +85 -0
  8. package/server/public/app/api-client.js +30 -0
  9. package/server/public/app/attachments-view.js +508 -125
  10. package/server/public/app/back-guard.mjs +58 -0
  11. package/server/public/app/bootstrap.js +46 -2
  12. package/server/public/app/conversation-view.js +110 -25
  13. package/server/public/app/external-link-policy.mjs +4 -1
  14. package/server/public/app/file-version.mjs +58 -0
  15. package/server/public/app/journal-view.js +16 -2
  16. package/server/public/app/pinned-messages-view.mjs +253 -0
  17. package/server/public/app/pwa-install.js +1 -1
  18. package/server/public/app/relay-dot-state.mjs +8 -1
  19. package/server/public/app/router.js +8 -2
  20. package/server/public/app/socket-handlers.js +6 -0
  21. package/server/public/app/store.js +6 -0
  22. package/server/public/app/viewer-chat-gallery.mjs +59 -0
  23. package/server/public/app/viewer-controls-state.mjs +59 -0
  24. package/server/public/app/viewer-gallery.mjs +46 -0
  25. package/server/public/app/viewer-gestures.mjs +105 -0
  26. package/server/public/app/viewer-history.mjs +43 -0
  27. package/server/public/app/viewer-tap.mjs +112 -0
  28. package/server/public/index.html +304 -54
  29. package/server/repositories/message-pin-statements.mjs +39 -0
  30. package/server/repositories/message-repository.mjs +5 -0
  31. package/server/routes/messages-routes.mjs +33 -61
  32. package/server/routes/sessions-routes.mjs +92 -24
  33. package/server/server-runtime.mjs +29 -0
  34. package/server/services/cloudflared-tunnel-service.mjs +139 -41
  35. package/server/services/file-serving.mjs +155 -0
  36. package/server/services/message-pin-list.mjs +52 -0
  37. package/server/services/oar-cli-helpers.mjs +139 -4
  38. package/server/services/oar-cli-menu.mjs +240 -0
  39. package/server/services/oar-cli-wsl.mjs +129 -0
  40. package/server/services/safe-served-content.mjs +61 -11
  41. package/server/services/session-history-refresh-service.mjs +24 -0
@@ -5,7 +5,7 @@ import path from 'path';
5
5
  import { randomUUID } from 'crypto';
6
6
  import { stripRelayPromptContext } from '../services/relay-prompt-sanitizer.mjs';
7
7
  import { resolveUploadMimeType } from '../services/mime-sniffer.mjs';
8
- import { applySafeServedContentHeaders } from '../services/safe-served-content.mjs';
8
+ import { fileVersionToken, serveFileWithRangeSupport, withFileVersion } from '../services/file-serving.mjs';
9
9
  import {
10
10
  shouldParkForRestart,
11
11
  parkPendingQueueForRestart,
@@ -71,6 +71,11 @@ const STRANDED_SESSION_HEARTBEAT_FRESH_MS = 15_000;
71
71
  const RELAY_QUESTION_FINALIZATION_HOLD_MS = 5_000;
72
72
  const AUTO_MODEL_SENTINEL = 'auto';
73
73
  const USAGE_FETCH_RETRY_DELAY_MS = 250;
74
+ // Preview kinds whose bytes travel in the preview JSON; media and PDF are
75
+ // fetched by the browser from the raw URL instead.
76
+ const TEXT_PREVIEW_KINDS = new Set(['markdown', 'code', 'text']);
77
+ // An upload's URL names its bytes (the sha256), so a kept answer stays right.
78
+ const UPLOAD_CONTENT_CACHE_CONTROL = 'private, max-age=31536000, immutable';
74
79
 
75
80
  export function resolveOpenAIReasoningEffort(explicitReasoningEffort = '', model = '') {
76
81
  const explicit = String(explicitReasoningEffort || '').trim().toLowerCase();
@@ -1610,57 +1615,6 @@ export function cleanupPersistedGeneratedImagesForAssistantResponse({
1610
1615
  }
1611
1616
  }
1612
1617
 
1613
- function serveFileWithRangeSupport(req, res, filePath, meta, { safeName, cacheDelete = null } = {}) {
1614
- const fileSize = meta.size;
1615
- const name = safeName || path.basename(filePath).replace(/"/g, '');
1616
- const rangeHeader = req.headers['range'];
1617
-
1618
- res.setHeader('Accept-Ranges', 'bytes');
1619
- res.setHeader('Cache-Control', 'no-store');
1620
- // Neutralize browser-executable types (HTML/SVG/XML), force nosniff, and
1621
- // sandbox the response so a worker-written workspace file can't run as script
1622
- // on this origin. Media stays inline for preview; everything else downloads.
1623
- applySafeServedContentHeaders(res, meta.contentType, { fileName: name });
1624
-
1625
- const onStreamError = (error) => {
1626
- if (cacheDelete) cacheDelete(filePath);
1627
- if (res.headersSent) { res.destroy(error); return; }
1628
- if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') {
1629
- res.status(404).json({ error: 'File not found' });
1630
- return;
1631
- }
1632
- res.status(500).json({ error: 'Failed to read file' });
1633
- };
1634
-
1635
- if (rangeHeader) {
1636
- const match = /bytes=(\d+)-(\d*)/.exec(rangeHeader);
1637
- if (!match) {
1638
- res.setHeader('Content-Range', `bytes */${fileSize}`);
1639
- res.status(416).end();
1640
- return;
1641
- }
1642
- const start = parseInt(match[1], 10);
1643
- const end = match[2] ? parseInt(match[2], 10) : fileSize - 1;
1644
- if (start > end || start >= fileSize || end >= fileSize) {
1645
- res.setHeader('Content-Range', `bytes */${fileSize}`);
1646
- res.status(416).end();
1647
- return;
1648
- }
1649
- const chunkSize = end - start + 1;
1650
- res.setHeader('Content-Range', `bytes ${start}-${end}/${fileSize}`);
1651
- res.setHeader('Content-Length', String(chunkSize));
1652
- res.status(206);
1653
- const stream = fs.createReadStream(filePath, { start, end });
1654
- stream.on('error', onStreamError);
1655
- stream.pipe(res);
1656
- } else {
1657
- res.setHeader('Content-Length', String(fileSize));
1658
- const stream = fs.createReadStream(filePath);
1659
- stream.on('error', onStreamError);
1660
- stream.pipe(res);
1661
- }
1662
- }
1663
-
1664
1618
  // Structural sanitizer for the workflow-progress digest a Claude session
1665
1619
  // worker attaches to its background-task rows (docs/plans/workflow-progress-tree.md,
1666
1620
  // Phase 2). The relay never trusts worker JSON: strings are trimmed and
@@ -1769,6 +1723,10 @@ export function registerMessagesRoutes(app, deps) {
1769
1723
  fetchLinuxDirectoryEntries,
1770
1724
  mapLinuxDirectoryEntry,
1771
1725
  readWorkspaceFileMeta,
1726
+ // The cache readWorkspaceFileMeta fills. The routes drop an entry when a
1727
+ // read shows it is out of date; the name was used here without ever
1728
+ // being handed in, so each of those paths threw a ReferenceError.
1729
+ workspaceFileMetaCache,
1772
1730
  resolveWorkspaceFilePath,
1773
1731
  normalizeWorkspaceRelativePath,
1774
1732
  previewLanguageForWorkspaceFile,
@@ -3408,9 +3366,10 @@ export function registerMessagesRoutes(app, deps) {
3408
3366
  if (!file) return res.status(404).json({ error: 'Not found' });
3409
3367
  const filePath = uploadPathForSha(sha256);
3410
3368
  if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Missing file on disk' });
3411
- res.setHeader('Cache-Control', 'private, max-age=31536000, immutable');
3412
- applySafeServedContentHeaders(res, file.mime_type, { fileName: file.name || file.file_name || '' });
3413
- fs.createReadStream(filePath).pipe(res);
3369
+ serveFileWithRangeSupport(req, res, filePath, { contentType: file.mime_type }, {
3370
+ safeName: String(file.name || file.file_name || '').replace(/"/g, ''),
3371
+ cacheControl: UPLOAD_CONTENT_CACHE_CONTROL,
3372
+ });
3414
3373
  });
3415
3374
 
3416
3375
  app.get('/api/generated-image/:conversationId/:messageId/:imageId/content', auth, (req, res) => {
@@ -3483,6 +3442,9 @@ export function registerMessagesRoutes(app, deps) {
3483
3442
  const filePath = resolveWorkspaceFilePath(requestedPath, rootOverride);
3484
3443
  if (!filePath || !normalizedPath) return res.status(400).json({ error: 'Invalid file path' });
3485
3444
 
3445
+ // The preview carries the version the download link is built from, so
3446
+ // it reads the file's metadata fresh, not from the short-lived cache.
3447
+ workspaceFileMetaCache.delete(filePath);
3486
3448
  let meta = null;
3487
3449
  try {
3488
3450
  meta = readWorkspaceFileMeta(filePath);
@@ -3538,10 +3500,12 @@ export function registerMessagesRoutes(app, deps) {
3538
3500
  size,
3539
3501
  truncated,
3540
3502
  previewBytes: contentBuffer.length,
3541
- rawUrl: `${remotePath}/api/files/${normalizedWebPath.split('/').map((part) => encodeURIComponent(part)).join('/')}${scopeSuffix}`,
3503
+ mtimeMs: meta.mtimeMs ?? null,
3504
+ version: fileVersionToken(meta),
3505
+ rawUrl: withFileVersion(`${remotePath}/api/files/${normalizedWebPath.split('/').map((part) => encodeURIComponent(part)).join('/')}${scopeSuffix}`, fileVersionToken(meta)),
3542
3506
  };
3543
3507
 
3544
- if (kind !== 'binary' && kind !== 'image' && kind !== 'video') {
3508
+ if (TEXT_PREVIEW_KINDS.has(kind)) {
3545
3509
  payload.content = contentBuffer.toString('utf8');
3546
3510
  }
3547
3511
 
@@ -3917,6 +3881,8 @@ export function registerMessagesRoutes(app, deps) {
3917
3881
  const filePath = normalizeLinuxAbsolutePath(requestedPath);
3918
3882
  if (!filePath) return res.status(400).json({ error: 'Invalid Linux file path' });
3919
3883
 
3884
+ // Fresh metadata: the download link's version comes from this answer.
3885
+ workspaceFileMetaCache.delete(filePath);
3920
3886
  let meta = null;
3921
3887
  try {
3922
3888
  meta = readWorkspaceFileMeta(filePath);
@@ -3969,10 +3935,12 @@ export function registerMessagesRoutes(app, deps) {
3969
3935
  size,
3970
3936
  truncated,
3971
3937
  previewBytes: contentBuffer.length,
3972
- rawUrl: `${remotePath}/api/drives/file?path=${encodeURIComponent(filePath)}`,
3938
+ mtimeMs: meta.mtimeMs ?? null,
3939
+ version: fileVersionToken(meta),
3940
+ rawUrl: withFileVersion(`${remotePath}/api/drives/file?path=${encodeURIComponent(filePath)}`, fileVersionToken(meta)),
3973
3941
  };
3974
3942
 
3975
- if (kind !== 'binary' && kind !== 'image' && kind !== 'video') {
3943
+ if (TEXT_PREVIEW_KINDS.has(kind)) {
3976
3944
  payload.content = contentBuffer.toString('utf8');
3977
3945
  }
3978
3946
 
@@ -3989,6 +3957,8 @@ export function registerMessagesRoutes(app, deps) {
3989
3957
  return res.status(400).json({ error: 'Invalid drive file path' });
3990
3958
  }
3991
3959
 
3960
+ // Fresh metadata: the download link's version comes from this answer.
3961
+ workspaceFileMetaCache.delete(filePath);
3992
3962
  let meta = null;
3993
3963
  try {
3994
3964
  meta = readWorkspaceFileMeta(filePath);
@@ -4043,10 +4013,12 @@ export function registerMessagesRoutes(app, deps) {
4043
4013
  size,
4044
4014
  truncated,
4045
4015
  previewBytes: contentBuffer.length,
4046
- rawUrl: `${remotePath}/api/drives/file?path=${encodeURIComponent(normalizedWebPath)}`,
4016
+ mtimeMs: meta.mtimeMs ?? null,
4017
+ version: fileVersionToken(meta),
4018
+ rawUrl: withFileVersion(`${remotePath}/api/drives/file?path=${encodeURIComponent(normalizedWebPath)}`, fileVersionToken(meta)),
4047
4019
  };
4048
4020
 
4049
- if (kind !== 'binary' && kind !== 'image' && kind !== 'video') {
4021
+ if (TEXT_PREVIEW_KINDS.has(kind)) {
4050
4022
  payload.content = contentBuffer.toString('utf8');
4051
4023
  }
4052
4024
 
@@ -7,8 +7,9 @@ import path from 'path';
7
7
  import { fileURLToPath } from 'url';
8
8
  import { randomUUID } from 'crypto';
9
9
  import { createSdkSessionSyncService } from '../services/sdk-session-sync-service.mjs';
10
- import { applySafeServedContentHeaders } from '../services/safe-served-content.mjs';
10
+ import { serveFileWithRangeSupport } from '../services/file-serving.mjs';
11
11
  import { stripRelayPromptContext } from '../services/relay-prompt-sanitizer.mjs';
12
+ import { MESSAGE_PIN_LIMIT, buildPinList } from '../services/message-pin-list.mjs';
12
13
  import { persistConversationPreferences } from '../services/conversation-preferences-service.mjs';
13
14
  import { parseAutoCompactWindow } from '../../shared/auto-compact-window.mjs';
14
15
  import { CLAUDE_ATTRIBUTION_MODES, DEFAULT_CLAUDE_ATTRIBUTION_MODE, claudeAttributionModelLabel, claudeAttributionTrailer, normalizeClaudeAttributionMode, resolveClaudeAttributionMode } from '../../shared/claude-attribution.mjs';
@@ -88,6 +89,9 @@ const SESSION_WORKER_STATUS_QUEUE_STATES = Object.freeze(['pending', 'processing
88
89
  // Registry statuses that mean "this worker is currently running" (same triple
89
90
  // as countActiveCliWorkers in server-runtime.mjs).
90
91
  const SESSION_WORKER_ACTIVE_STATUSES = Object.freeze(['starting', 'ready', 'processing']);
92
+ // A shared attachment's URL names its bytes (sha256 or a fixed generated-image
93
+ // path), so a kept answer stays right.
94
+ const SHARED_CONTENT_CACHE_CONTROL = 'private, max-age=31536000, immutable';
91
95
 
92
96
  function normalizeWorkerStatusText(value, fallback = null) {
93
97
  const text = String(value || '').trim();
@@ -1834,6 +1838,7 @@ export function buildConversationMessages({
1834
1838
  attachments: message?.attachments || [],
1835
1839
  mode: message?.mode || undefined,
1836
1840
  hiddenFromShares: Number(message?.hidden_from_shares || 0) === 1,
1841
+ pinned: !!message?.pinned_at,
1837
1842
  timestamp: message?.timestamp,
1838
1843
  sourceMessageId,
1839
1844
  executedProvider: message?.executed_provider || message?.executedProvider || undefined,
@@ -2218,6 +2223,23 @@ export function registerSessionsRoutes(app, deps) {
2218
2223
  AND (id = ? OR response_message_id = ?)
2219
2224
  LIMIT 1
2220
2225
  `);
2226
+ function listConversationPins(conversationId) {
2227
+ return buildPinList(stmts.listPinnedMessages?.all?.(conversationId) || []);
2228
+ }
2229
+ // Orders the pin lists a browser receives. A conversation load that left
2230
+ // before a pin change can arrive after the change's own socket event; with
2231
+ // the revision the browser can tell the older list from the newer one. In
2232
+ // memory only: a restart starts above every revision handed out before.
2233
+ const pinRevisionFloor = Date.now();
2234
+ const pinRevisionByConversation = new Map();
2235
+ function conversationPinRevision(conversationId) {
2236
+ return pinRevisionByConversation.get(conversationId) || pinRevisionFloor;
2237
+ }
2238
+ function bumpConversationPinRevision(conversationId) {
2239
+ const next = Math.max(Date.now(), conversationPinRevision(conversationId) + 1);
2240
+ pinRevisionByConversation.set(conversationId, next);
2241
+ return next;
2242
+ }
2221
2243
  const listSessionWorkerQueueRows = db.prepare(`
2222
2244
  SELECT id, conversation_id, runtime_session_id, owner_sdk_session_id, status
2223
2245
  FROM queue
@@ -2712,7 +2734,9 @@ export function registerSessionsRoutes(app, deps) {
2712
2734
  });
2713
2735
  messages = messages.map((message) => {
2714
2736
  const sourceMessageId = responseMessageToSourceId.get(String(message.id || '').trim()) || message.sourceMessageId || undefined;
2715
- const withSource = sourceMessageId ? { ...message, sourceMessageId } : message;
2737
+ // Pins are the owner's bookmarks: a public share never learns of them.
2738
+ const { pinned: _pinned, ...unpinnedMessage } = message;
2739
+ const withSource = sourceMessageId ? { ...unpinnedMessage, sourceMessageId } : unpinnedMessage;
2716
2740
  // A public share never shows another relay's address or ids.
2717
2741
  const nextMessage = withSource.origin
2718
2742
  ? { ...withSource, origin: publicRemoteRelayOrigin(withSource.origin) }
@@ -3600,6 +3624,9 @@ export function registerSessionsRoutes(app, deps) {
3600
3624
  cloud: conversationCloudPayload(conv, runtimeSession),
3601
3625
  messages: history.messages,
3602
3626
  pageInfo: history.pageInfo,
3627
+ // A re-import can move a pin onto a new message id.
3628
+ pins: listConversationPins(conversationId),
3629
+ pinsRevision: conversationPinRevision(conversationId),
3603
3630
  refreshed: true,
3604
3631
  });
3605
3632
  });
@@ -3777,20 +3804,14 @@ export function registerSessionsRoutes(app, deps) {
3777
3804
  if (!file) return res.status(404).json({ error: 'Shared attachment not found' });
3778
3805
  const filePath = uploadPathForSha(sha256);
3779
3806
  if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Missing file on disk' });
3780
- // Unauthenticated share viewers: neutralize executable types (HTML/SVG),
3781
- // force nosniff + sandbox so an attachment can't run as script on this origin.
3782
- res.setHeader('Cache-Control', 'private, max-age=31536000, immutable');
3807
+ // Unauthenticated share viewers: the serving helper neutralizes executable
3808
+ // types (HTML/SVG) and forces nosniff + sandbox so an attachment can't run
3809
+ // as script on this origin; ranges let a shared video seek.
3783
3810
  res.setHeader('X-Robots-Tag', 'noindex, nofollow, noarchive');
3784
- applySafeServedContentHeaders(res, file.mime_type, { fileName: file.name || file.file_name || '' });
3785
- const stream = fs.createReadStream(filePath);
3786
- stream.on('error', () => {
3787
- if (!res.headersSent) {
3788
- res.status(500).json({ error: 'Failed to stream shared attachment' });
3789
- return;
3790
- }
3791
- res.destroy();
3811
+ serveFileWithRangeSupport(req, res, filePath, { contentType: file.mime_type }, {
3812
+ safeName: String(file.name || file.file_name || '').replace(/"/g, ''),
3813
+ cacheControl: SHARED_CONTENT_CACHE_CONTROL,
3792
3814
  });
3793
- stream.pipe(res);
3794
3815
  });
3795
3816
 
3796
3817
  app.get('/api/shared/:token/generated-image/:messageId/:imageId/content', (req, res) => {
@@ -3832,18 +3853,10 @@ export function registerSessionsRoutes(app, deps) {
3832
3853
  return res.status(404).json({ error: 'Shared attachment not found' });
3833
3854
  }
3834
3855
  if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Shared attachment not found' });
3835
- res.setHeader('Cache-Control', 'private, max-age=31536000, immutable');
3836
3856
  res.setHeader('X-Robots-Tag', 'noindex, nofollow, noarchive');
3837
- applySafeServedContentHeaders(res, attachment.type);
3838
- const stream = fs.createReadStream(filePath);
3839
- stream.on('error', () => {
3840
- if (!res.headersSent) {
3841
- res.status(500).json({ error: 'Failed to stream shared attachment' });
3842
- return;
3843
- }
3844
- res.destroy();
3857
+ serveFileWithRangeSupport(req, res, filePath, { contentType: attachment.type }, {
3858
+ cacheControl: SHARED_CONTENT_CACHE_CONTROL,
3845
3859
  });
3846
- stream.pipe(res);
3847
3860
  });
3848
3861
 
3849
3862
  // GET /api/conversation/:id — get paginated conversation history
@@ -4000,6 +4013,9 @@ export function registerSessionsRoutes(app, deps) {
4000
4013
  backgroundTasks: backgroundTaskStore?.get?.(resolvedConversationId) || [],
4001
4014
  usageLimitPause: usageLimitPauseService?.getPause?.(resolvedConversationId) || null,
4002
4015
  previews: listConversationPreviews?.(resolvedConversationId) || [],
4016
+ // Every pin of the conversation, also those outside this history page.
4017
+ pins: listConversationPins(resolvedConversationId),
4018
+ pinsRevision: conversationPinRevision(resolvedConversationId),
4003
4019
  preferredRelayMode: preferences.preferredRelayMode,
4004
4020
  preferredModel: preferences.preferredModel,
4005
4021
  preferredReasoningEffort: preferences.preferredReasoningEffort,
@@ -4059,6 +4075,57 @@ export function registerSessionsRoutes(app, deps) {
4059
4075
  });
4060
4076
  });
4061
4077
 
4078
+ // Pin or unpin one message. Unlike share visibility this may change while
4079
+ // the message's turn runs (nothing public depends on it), and every open
4080
+ // client learns of it through the socket instead of reloading.
4081
+ app.patch('/api/conversation/:id/message/:messageId/pin', auth, (req, res) => {
4082
+ const conversationId = String(req.params.id || '').trim();
4083
+ const messageId = String(req.params.messageId || '').trim();
4084
+ if (!conversationId || !messageId) {
4085
+ return res.status(400).json({ error: 'Missing conversation or message id' });
4086
+ }
4087
+ if (typeof req.body?.pinned !== 'boolean') {
4088
+ return res.status(400).json({ error: 'pinned must be a boolean' });
4089
+ }
4090
+
4091
+ const conv = stmts.getConvAnyStatus.get(conversationId);
4092
+ if (!conv || String(conv.status || '').trim().toLowerCase() === 'deleted') {
4093
+ return res.status(404).json({ error: 'Conversation not found' });
4094
+ }
4095
+ const message = stmts.getMessageByConversation?.get(messageId, conversationId) || null;
4096
+ if (!message) return res.status(404).json({ error: 'Message not found' });
4097
+ if (!stmts.setMessagePinnedAt || !stmts.countPinnedMessages) {
4098
+ return res.status(503).json({ error: 'Pinning messages is unavailable until the relay restarts' });
4099
+ }
4100
+
4101
+ const pinned = req.body.pinned === true;
4102
+ const currentlyPinned = !!message.pinned_at;
4103
+ if (currentlyPinned !== pinned) {
4104
+ if (pinned && Number(stmts.countPinnedMessages.get(conversationId)?.cnt || 0) >= MESSAGE_PIN_LIMIT) {
4105
+ return res.status(409).json({
4106
+ error: `This conversation already has ${MESSAGE_PIN_LIMIT} pinned messages. Unpin one first.`,
4107
+ code: 'pin-limit',
4108
+ limit: MESSAGE_PIN_LIMIT,
4109
+ });
4110
+ }
4111
+ stmts.setMessagePinnedAt.run(pinned ? new Date().toISOString() : null, messageId, conversationId);
4112
+ }
4113
+
4114
+ const pins = listConversationPins(conversationId);
4115
+ if (currentlyPinned !== pinned) {
4116
+ const revision = bumpConversationPinRevision(conversationId);
4117
+ io?.emit?.('conversation_pins_updated', { conversationId, pins, revision });
4118
+ }
4119
+ return res.json({
4120
+ ok: true,
4121
+ conversationId,
4122
+ messageId,
4123
+ pinned,
4124
+ pins,
4125
+ pinsRevision: conversationPinRevision(conversationId),
4126
+ });
4127
+ });
4128
+
4062
4129
  app.patch('/api/conversation/:id', auth, (req, res) => {
4063
4130
  const conversationId = String(req.params.id || '').trim();
4064
4131
  const result = persistConversationTitle({
@@ -5461,6 +5528,7 @@ export function registerSessionsRoutes(app, deps) {
5461
5528
  reconnectAttempts: runtimeState.cloudflaredTunnelState?.reconnectAttempts ?? 0,
5462
5529
  connectedSince: runtimeState.cloudflaredTunnelState?.connectedSince ?? null,
5463
5530
  lastError: runtimeState.cloudflaredTunnelState?.lastError ?? null,
5531
+ binaryMissing: runtimeState.cloudflaredTunnelState?.binaryMissing ?? false,
5464
5532
  valid: runtimeState.cloudflaredTunnelState?.valid ?? true,
5465
5533
  },
5466
5534
  workerWebSocket: runtimeState.workerWebSocketStatus || null,
@@ -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';
@@ -1510,6 +1511,17 @@ const WORKSPACE_CONTENT_TYPES = Object.freeze({
1510
1511
  '.gif': 'image/gif',
1511
1512
  '.webp': 'image/webp',
1512
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',
1513
1525
  '.mp4': 'video/mp4',
1514
1526
  '.m4v': 'video/mp4',
1515
1527
  '.mov': 'video/quicktime',
@@ -1650,6 +1662,7 @@ const WORKSPACE_CODE_EXTENSIONS = new Set(Object.keys(WORKSPACE_PREVIEW_LANGUAGE
1650
1662
  .filter((ext) => !WORKSPACE_MARKDOWN_EXTENSIONS.has(ext)));
1651
1663
  const WORKSPACE_IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.ico', '.bmp', '.avif']);
1652
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']);
1653
1666
  const REPO_HEAVY_DIR_NAMES = new Set(['.git', 'node_modules']);
1654
1667
  const MAX_REPO_TREE_NODES = 20_000;
1655
1668
  const MAX_REPO_TREE_DEPTH = 64;
@@ -4148,6 +4161,7 @@ function readWorkspaceFileMeta(filePath) {
4148
4161
  return cacheWorkspaceFileMeta(filePath, {
4149
4162
  kind: 'file',
4150
4163
  size: Number(stat.size || 0),
4164
+ mtimeMs: Number(stat.mtimeMs || 0),
4151
4165
  contentType: workspaceContentType(filePath),
4152
4166
  });
4153
4167
  }
@@ -4214,6 +4228,8 @@ function workspacePreviewKindForMeta(filePath, contentType) {
4214
4228
  if (WORKSPACE_MARKDOWN_EXTENSIONS.has(normalizedExt)) return 'markdown';
4215
4229
  if (WORKSPACE_IMAGE_EXTENSIONS.has(normalizedExt) || normalizedType.startsWith('image/')) return 'image';
4216
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';
4217
4233
  if (WORKSPACE_CODE_EXTENSIONS.has(normalizedExt) || WORKSPACE_PREVIEW_LANGUAGE_BY_FILENAME[filename]) return 'code';
4218
4234
  if (isLikelyTextContentType(normalizedType)) return 'text';
4219
4235
  return 'binary';
@@ -6341,6 +6357,10 @@ app.use((req, _res, next) => {
6341
6357
  stripRequestPathPrefix(req, remotePath);
6342
6358
  next();
6343
6359
  });
6360
+ function isApiRequestPath(requestPath) {
6361
+ return /^\/api(?:\/|$)/.test(String(requestPath || ''));
6362
+ }
6363
+
6344
6364
  // Baseline security headers on every response. Served-file/attachment endpoints
6345
6365
  // layer a stricter sandbox CSP on top (see applySafeServedContentHeaders). A
6346
6366
  // global script-src CSP is intentionally omitted for now: the SPA relies on
@@ -6349,6 +6369,14 @@ app.use((req, res, next) => {
6349
6369
  res.setHeader('X-Content-Type-Options', 'nosniff');
6350
6370
  res.setHeader('X-Frame-Options', 'SAMEORIGIN');
6351
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');
6352
6380
  next();
6353
6381
  });
6354
6382
  app.get('/socket.io/socket.io.js', (req, res, next) => {
@@ -6552,6 +6580,7 @@ const sharedRouteDeps = {
6552
6580
  compareRepoDirEntries,
6553
6581
  shouldSkipRepoEntryName,
6554
6582
  readWorkspaceFileMeta,
6583
+ workspaceFileMetaCache,
6555
6584
  resolveWorkspaceFilePath,
6556
6585
  normalizeWorkspaceRelativePath,
6557
6586
  previewLanguageForWorkspaceFile,