@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.
- package/CHANGELOG.md +344 -0
- package/README.md +89 -40
- package/bin/oar.js +1023 -135
- package/package.json +4 -5
- package/server/README.md +20 -1
- package/server/claude-worker/claude-sdk-adapter.mjs +0 -11
- package/server/claude-worker/claude-session-process.mjs +196 -28
- package/server/claude-worker/sdk-message-normalizer.mjs +28 -0
- package/server/db-schema.mjs +8 -0
- package/server/migrations/0008-message-pins.mjs +85 -0
- package/server/public/app/activity-replay-state.mjs +68 -5
- package/server/public/app/api-client.js +36 -2
- package/server/public/app/attachments-view.js +508 -125
- package/server/public/app/back-guard.mjs +58 -0
- package/server/public/app/bootstrap.js +170 -59
- package/server/public/app/context-usage-view.mjs +127 -8
- package/server/public/app/conversation-view.js +172 -32
- package/server/public/app/external-link-policy.mjs +4 -1
- package/server/public/app/file-version.mjs +58 -0
- package/server/public/app/journal-view.js +16 -2
- package/server/public/app/pinned-messages-view.mjs +253 -0
- package/server/public/app/pwa-install.js +6 -1
- package/server/public/app/relay-dot-state.mjs +8 -1
- package/server/public/app/router.js +8 -2
- package/server/public/app/scrollbar-hover.mjs +78 -0
- package/server/public/app/settings-modal.js +4 -1
- package/server/public/app/socket-handlers.js +8 -1
- package/server/public/app/store.js +32 -0
- package/server/public/app/transcript-separators.mjs +48 -11
- package/server/public/app/viewer-chat-gallery.mjs +59 -0
- package/server/public/app/viewer-controls-state.mjs +59 -0
- package/server/public/app/viewer-gallery.mjs +46 -0
- package/server/public/app/viewer-gestures.mjs +105 -0
- package/server/public/app/viewer-history.mjs +43 -0
- package/server/public/app/viewer-tap.mjs +112 -0
- package/server/public/app-icon-192.png +0 -0
- package/server/public/app-icon-512.png +0 -0
- package/server/public/app-icon-light-192.png +0 -0
- package/server/public/app-icon-light-512.png +0 -0
- package/server/public/app-icon-light-maskable-512.png +0 -0
- package/server/public/app-icon-light.svg +4 -0
- package/server/public/app-icon-maskable-512.png +0 -0
- package/server/public/app-icon.svg +3 -3
- package/server/public/favicon.ico +0 -0
- package/server/public/index.html +487 -67
- package/server/public/manifest.webmanifest +9 -3
- package/server/public/sw.js +6 -1
- package/server/repositories/message-pin-statements.mjs +39 -0
- package/server/repositories/message-repository.mjs +5 -0
- package/server/routes/messages-routes.mjs +36 -62
- package/server/routes/sessions-routes.mjs +107 -26
- package/server/server-runtime.mjs +39 -8
- package/server/services/claude-context-usage.mjs +51 -3
- package/server/services/cloudflared-tunnel-service.mjs +139 -41
- package/server/services/file-serving.mjs +155 -0
- package/server/services/message-pin-list.mjs +52 -0
- package/server/services/oar-cli-helpers.mjs +205 -4
- package/server/services/oar-cli-menu.mjs +240 -0
- package/server/services/oar-cli-wsl.mjs +129 -0
- package/server/services/pwa-manifest-theme.mjs +30 -0
- package/server/services/safe-served-content.mjs +61 -11
- package/server/services/session-history-refresh-service.mjs +24 -0
- package/server/services/session-worker-registry-service.mjs +18 -1
- 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`,
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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|
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
208
|
-
const
|
|
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:
|
|
218
|
+
env: npm.env,
|
|
213
219
|
...(platform === 'win32' ? { windowsHide: true, shell: true } : { detached: true }),
|
|
214
220
|
}),
|
|
215
221
|
{
|