@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
@@ -0,0 +1,30 @@
1
+ // The installed app's icon and launch screen follow the Theme setting: the
2
+ // page links the manifest with `?theme=light` in Day mode, and the manifest
3
+ // then names the light icon set and a white background.
4
+
5
+ export const PWA_LIGHT_BACKGROUND = '#ffffff';
6
+
7
+ export function normalizePwaTheme(value) {
8
+ return String(value || '').trim().toLowerCase() === 'light' ? 'light' : 'dark';
9
+ }
10
+
11
+ function lightIconSrc(src) {
12
+ return String(src || '').replace(/(^|\/)app-icon(?=[-.])/, '$1app-icon-light');
13
+ }
14
+
15
+ /**
16
+ * The manifest for one theme. Dark is the template as it stands; light swaps
17
+ * every `app-icon*` source for its `app-icon-light*` twin and whitens the
18
+ * launch background and the toolbar colour.
19
+ */
20
+ export function applyPwaManifestTheme(manifest, theme) {
21
+ if (normalizePwaTheme(theme) !== 'light') return manifest;
22
+ return {
23
+ ...manifest,
24
+ background_color: PWA_LIGHT_BACKGROUND,
25
+ theme_color: PWA_LIGHT_BACKGROUND,
26
+ icons: Array.isArray(manifest?.icons)
27
+ ? manifest.icons.map((icon) => ({ ...icon, src: lightIconSrc(icon?.src) }))
28
+ : manifest?.icons,
29
+ };
30
+ }
@@ -3,15 +3,20 @@
3
3
  //
4
4
  // The threat: a semi-trusted producer (an AI worker writing a workspace file, or
5
5
  // an uploaded attachment) can supply content whose MIME type the browser will
6
- // execute as markup/script on THIS origin — `text/html`, `image/svg+xml`, XML
7
- // with embedded script. Served inline, that is stored XSS against the app (and,
8
- // via the unauthenticated /api/shared/* routes, against share viewers). We
9
- // neutralize such types to `text/plain` and always send `nosniff`, and we attach
10
- // a locked-down CSP + `sandbox` so anything that still reaches the browser as a
6
+ // execute as markup/script on THIS origin — `text/html`, XML with embedded
7
+ // script. Served inline, that is stored XSS against the app (and, via the
8
+ // unauthenticated /api/shared/* routes, against share viewers). We neutralize
9
+ // such types to `text/plain` and always send `nosniff`, and we attach a
10
+ // locked-down CSP + `sandbox` so anything that still reaches the browser as a
11
11
  // document can neither run script nor navigate.
12
+ //
13
+ // SVG keeps its type: as an `<img>` it never runs script, and opened as a
14
+ // document the `sandbox` CSP denies it script and an origin, so the viewer
15
+ // and the file browser's thumbnails can show it.
12
16
 
13
17
  // Types the browser renders as active documents on this origin.
14
- const ACTIVE_MARKUP_PATTERN = /^text\/html\b|svg|xhtml|(^|\/)xml\b|\+xml\b/i;
18
+ const ACTIVE_MARKUP_PATTERN = /^text\/html\b|xhtml|(^|\/)xml\b|\+xml\b/i;
19
+ const SVG_TYPE = 'image/svg+xml';
15
20
 
16
21
  // Types safe to preview inline with their real Content-Type.
17
22
  const INLINE_SAFE_PREFIXES = ['image/', 'video/', 'audio/'];
@@ -25,7 +30,7 @@ const INLINE_SAFE_EXACT = new Set(['application/pdf']);
25
30
  export function safeServedContentType(mimeType) {
26
31
  const type = String(mimeType || '').trim().toLowerCase().split(';')[0].slice(0, 127)
27
32
  || 'application/octet-stream';
28
- if (ACTIVE_MARKUP_PATTERN.test(type)) {
33
+ if (type !== SVG_TYPE && ACTIVE_MARKUP_PATTERN.test(type)) {
29
34
  // Render markup/script content as its own source text instead of executing it.
30
35
  return { contentType: 'text/plain; charset=utf-8', inlineSafe: false, neutralized: true };
31
36
  }
@@ -34,6 +39,45 @@ export function safeServedContentType(mimeType) {
34
39
  return { contentType: type, inlineSafe, neutralized: false };
35
40
  }
36
41
 
42
+ // Printable ASCII, the only characters a quoted `filename="…"` may hold once
43
+ // quotes and backslashes are gone. Anything else goes into `filename*`.
44
+ const NON_ASCII_PATTERN = /[^\x20-\x7e]/g;
45
+ // A surrogate without its partner: not a character, and encodeURIComponent
46
+ // throws on it.
47
+ const LONE_SURROGATE_PATTERN = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
48
+
49
+ /** RFC 5987 `ext-value` encoding: UTF-8 percent-encoded, attr-char kept. */
50
+ function encodeRfc5987(value) {
51
+ return encodeURIComponent(value)
52
+ // encodeURIComponent leaves these alone, but RFC 5987 does not allow them bare.
53
+ .replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
54
+ }
55
+
56
+ /**
57
+ * The `Content-Disposition` value for `fileName`, RFC 6266 style: a quoted
58
+ * `filename` every client can read, built from the name's ASCII characters
59
+ * (never empty: `file` plus the extension when nothing is left), and, when the
60
+ * name has characters outside ASCII, `filename*=UTF-8''…` carrying the whole
61
+ * name percent-encoded. The result holds nothing a header may not: no
62
+ * control characters, no quotes inside the quoted string, and no character
63
+ * outside Latin-1, which Node's `setHeader` rejects (and which used to make a
64
+ * file with a Japanese or emoji name unservable).
65
+ */
66
+ export function contentDispositionValue(disposition, fileName) {
67
+ const name = String(fileName || '')
68
+ .replace(LONE_SURROGATE_PATTERN, '')
69
+ .replace(/[\u0000-\u001f\u007f"\\]/g, '')
70
+ .slice(0, 255);
71
+ if (!name) return disposition;
72
+ const ascii = name.replace(NON_ASCII_PATTERN, '').trim();
73
+ if (ascii === name) return `${disposition}; filename="${name}"`;
74
+ const dot = name.lastIndexOf('.');
75
+ const stem = (dot > 0 ? name.slice(0, dot) : name).replace(NON_ASCII_PATTERN, '').replace(/^[\s.]+|[\s.]+$/g, '') || 'file';
76
+ const ext = (dot > 0 ? name.slice(dot) : '').replace(NON_ASCII_PATTERN, '').trim();
77
+ const fallback = `${stem}${ext.length > 1 ? ext : ''}`;
78
+ return `${disposition}; filename="${fallback}"; filename*=UTF-8''${encodeRfc5987(name)}`;
79
+ }
80
+
37
81
  /**
38
82
  * Apply the safe Content-Type plus the hardening headers to a response that is
39
83
  * about to stream stored bytes. Returns the resolved descriptor so callers can
@@ -46,12 +90,18 @@ export function applySafeServedContentHeaders(res, mimeType, { fileName = '' } =
46
90
  // Belt-and-braces: a served file is never part of the app UI, so forbid it
47
91
  // from loading anything or being framed, and sandbox it so it cannot run
48
92
  // script or navigate even if a browser would otherwise treat it as active.
49
- res.setHeader('Content-Security-Policy', "default-src 'none'; sandbox; frame-ancestors 'none'");
50
- const safeName = String(fileName || '').replace(/[\r\n"\\]/g, '').slice(0, 255);
51
- const disposition = resolved.inlineSafe ? 'inline' : 'attachment';
93
+ // A PDF is the exception: browsers refuse to show a sandboxed PDF in their
94
+ // built-in viewer, so it gets no `sandbox` and may be framed by this origin.
95
+ // PDF viewers do not run document script, so the sandbox bought nothing there.
96
+ res.setHeader(
97
+ 'Content-Security-Policy',
98
+ resolved.contentType === 'application/pdf'
99
+ ? "default-src 'none'; frame-ancestors 'self'"
100
+ : "default-src 'none'; sandbox; frame-ancestors 'none'",
101
+ );
52
102
  res.setHeader(
53
103
  'Content-Disposition',
54
- safeName ? `${disposition}; filename="${safeName}"` : disposition,
104
+ contentDispositionValue(resolved.inlineSafe ? 'inline' : 'attachment', fileName),
55
105
  );
56
106
  return resolved;
57
107
  }
@@ -144,6 +144,22 @@ export function createSessionHistoryRefreshService({
144
144
  WHERE conversation_id = ? AND hidden_from_shares = 1
145
145
  `)
146
146
  : null;
147
+ // Pins are carried across the replace the same way as hidden marks.
148
+ const messagesSupportPins = messageColumns.has('pinned_at');
149
+ const listPinnedMessageMarks = messagesSupportPins
150
+ ? db.prepare(`
151
+ SELECT id, role, text, timestamp, pinned_at
152
+ FROM messages
153
+ WHERE conversation_id = ? AND pinned_at IS NOT NULL
154
+ `)
155
+ : null;
156
+ const restoreMessagePin = messagesSupportPins
157
+ ? db.prepare(`
158
+ UPDATE messages
159
+ SET pinned_at = ?
160
+ WHERE conversation_id = ? AND id = ?
161
+ `)
162
+ : null;
147
163
  const restoreHiddenMessageVisibility = messagesSupportShareVisibility
148
164
  ? db.prepare(`
149
165
  UPDATE messages
@@ -418,6 +434,7 @@ export function createSessionHistoryRefreshService({
418
434
 
419
435
  const replaceRetrievableHistoryTx = db.transaction((conversationId, messages = []) => {
420
436
  const hiddenMessages = listHiddenMessageVisibility?.all(conversationId) || [];
437
+ const pinnedMessages = listPinnedMessageMarks?.all(conversationId) || [];
421
438
  const attachmentCandidates = collectAttachmentCandidates(conversationId);
422
439
  deleteConversationMessages.run(conversationId);
423
440
  deleteConversationActivity.run(conversationId);
@@ -437,6 +454,13 @@ export function createSessionHistoryRefreshService({
437
454
  resolvedId,
438
455
  );
439
456
  }
457
+ // Its own consumed set: a message can be both hidden and pinned.
458
+ const consumedPinnedIds = new Set();
459
+ for (const pinnedMessage of pinnedMessages) {
460
+ const resolvedId = resolveHiddenMessageId(pinnedMessage, rebuiltMessages, consumedPinnedIds);
461
+ if (!resolvedId) continue;
462
+ restoreMessagePin.run(pinnedMessage.pinned_at, conversationId, resolvedId);
463
+ }
440
464
  });
441
465
 
442
466
  function evaluateRefreshIdleState(conversationId) {
@@ -82,6 +82,19 @@ function normalizeSteeringCancellableIds(raw) {
82
82
  return ids;
83
83
  }
84
84
 
85
+ // The Claude worker's running CLI: the compaction window it was spawned with
86
+ // (a token count, null = Auto) and whether background work holds it, which
87
+ // blocks the respawn that applies a changed window. Null when no CLI runs or
88
+ // the worker does not report it.
89
+ function normalizeSteeringAutoCompactWindow(raw) {
90
+ if (!raw || typeof raw !== 'object') return null;
91
+ const active = Number(raw.active);
92
+ return {
93
+ active: Number.isFinite(active) && active > 0 ? Math.round(active) : null,
94
+ backgroundWork: raw.backgroundWork === true,
95
+ };
96
+ }
97
+
85
98
  // The worker's composer-facing steering snapshot, published on its heartbeat.
86
99
  // Callers follow the registry convention of spreading the existing entry into
87
100
  // upserts, so an update without `steering` keeps the last known snapshot.
@@ -98,7 +111,7 @@ export function normalizeWorkerSteeringSnapshot(raw) {
98
111
  if (!raw || typeof raw !== 'object') return null;
99
112
  const holdReason = String(raw.holdReason || '').trim().toLowerCase();
100
113
  const messageId = String(raw.messageId || '').trim().slice(0, STEERING_MESSAGE_ID_MAX_LENGTH);
101
- return {
114
+ const snapshot = {
102
115
  turnActive: raw.turnActive === true,
103
116
  canSteer: raw.canSteer === true,
104
117
  holdReason: STEERING_HOLD_REASONS.has(holdReason) ? holdReason : null,
@@ -106,6 +119,10 @@ export function normalizeWorkerSteeringSnapshot(raw) {
106
119
  supported: raw.supported === true,
107
120
  cancellableIds: normalizeSteeringCancellableIds(raw.cancellableIds),
108
121
  };
122
+ // Only a snapshot that reports a running CLI carries the key.
123
+ const autoCompactWindow = normalizeSteeringAutoCompactWindow(raw.autoCompactWindow);
124
+ if (autoCompactWindow) snapshot.autoCompactWindow = autoCompactWindow;
125
+ return snapshot;
109
126
  }
110
127
 
111
128
  function sanitizeState(raw = null) {
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  import { spawn as defaultSpawn } from 'child_process';
4
+ import fs from 'fs';
4
5
 
5
6
  import {
6
7
  CLI_SPAWN_DISABLED_ERROR,
@@ -10,6 +11,7 @@ import {
10
11
  stripTerminalEscapes,
11
12
  tailOf,
12
13
  } from './cli-process-runner.mjs';
14
+ import { NPM_ALLOW_SCRIPTS_ARG, resolveNpmInvocation } from './oar-cli-helpers.mjs';
13
15
  import { compareSemverIsh, parseSemverIsh } from '../../shared/update-semver.mjs';
14
16
 
15
17
  // Applies an OAR self-update: `npm i -g @oar-sh/oar@<version>` with the log
@@ -99,6 +101,9 @@ export function readUpdateOutcome(readSetting) {
99
101
  export function createUpdateInstallService({
100
102
  runningVersion,
101
103
  installMethod = 'npm-global',
104
+ packageRoot = '',
105
+ execPath = process.execPath,
106
+ existsImpl = fs.existsSync,
102
107
  spawnImpl = defaultSpawn,
103
108
  env = process.env,
104
109
  platform = process.platform,
@@ -204,12 +209,13 @@ export function createUpdateInstallService({
204
209
 
205
210
  // npm itself is a shim on Windows; spawn npm.cmd through a shell there
206
211
  // (args are fixed strings + a validated semver, so no injection surface).
207
- const command = platform === 'win32' ? 'npm.cmd' : 'npm';
208
- const args = ['install', '-g', `${UPDATE_PACKAGE_NAME}@${target}`];
212
+ const npm = resolveNpmInvocation({ execPath, packageRoot, platform, env, existsImpl });
213
+ const command = npm.command;
214
+ const args = ['install', '-g', ...npm.prefixArgs, NPM_ALLOW_SCRIPTS_ARG, `${UPDATE_PACKAGE_NAME}@${target}`];
209
215
  const result = await runToCompletion(
210
216
  () => spawnImpl(command, args, {
211
217
  stdio: ['ignore', 'pipe', 'pipe'],
212
- env: { ...env },
218
+ env: npm.env,
213
219
  ...(platform === 'win32' ? { windowsHide: true, shell: true } : { detached: true }),
214
220
  }),
215
221
  {