@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
|
@@ -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: '#
|
|
5602
|
-
theme_color: '#
|
|
5618
|
+
background_color: '#0d1117',
|
|
5619
|
+
theme_color: '#0d1117',
|
|
5603
5620
|
icons: [
|
|
5604
|
-
{ src: 'app-icon.svg?v=
|
|
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
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
68
|
-
|
|
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 =
|
|
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
|
-
|
|
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: ${
|
|
225
|
-
const proc = spawnImpl(
|
|
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
|
+
}
|