@oar-sh/oar 0.9.0
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/.github/extensions/web-relay/README.md +190 -0
- package/.github/extensions/web-relay/extension.mjs +972 -0
- package/.github/extensions/web-relay/model-api/model-switching.mjs +338 -0
- package/.github/extensions/web-relay/polling/heartbeat.mjs +55 -0
- package/.github/extensions/web-relay/polling/polling-loop.mjs +1105 -0
- package/.github/extensions/web-relay/runtime/api-client.mjs +34 -0
- package/.github/extensions/web-relay/runtime/banner-state.mjs +126 -0
- package/.github/extensions/web-relay/runtime/config-loader.mjs +104 -0
- package/.github/extensions/web-relay/runtime/debug-log.mjs +27 -0
- package/.github/extensions/web-relay/runtime/send-and-wait-errors.mjs +170 -0
- package/.github/extensions/web-relay/runtime/session-binding.mjs +78 -0
- package/.github/extensions/web-relay/runtime/session-io.mjs +349 -0
- package/.github/extensions/web-relay/runtime/session-join-retry.mjs +44 -0
- package/.github/extensions/web-relay/runtime/session-registry.mjs +29 -0
- package/.github/extensions/web-relay/runtime/session-runtime-manager.mjs +156 -0
- package/.github/extensions/web-relay/runtime/session-sync-bridge.mjs +74 -0
- package/.github/extensions/web-relay/runtime/worker-websocket-link.mjs +325 -0
- package/.github/extensions/web-relay/runtime/workspace-root.mjs +55 -0
- package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +386 -0
- package/.github/extensions/web-relay/skills/prompt-context.mjs +160 -0
- package/.github/extensions/web-relay/skills/question-bridge.mjs +141 -0
- package/.github/extensions/web-relay/skills/question-routing-hooks.mjs +463 -0
- package/.github/extensions/web-relay/skills/reasoning-stream.mjs +285 -0
- package/.github/extensions/web-relay/skills/subagent-lifecycle.mjs +345 -0
- package/.github/extensions/web-relay/skills/tool-activity.mjs +243 -0
- package/.github/extensions/web-relay/utils/process-utils.mjs +25 -0
- package/.github/extensions/web-relay/utils/token-generation.mjs +58 -0
- package/CHANGELOG.md +25 -0
- package/LICENSE +21 -0
- package/README.md +717 -0
- package/bin/bat/oar.bat +46 -0
- package/bin/oar.js +665 -0
- package/package.json +68 -0
- package/server/README.md +1379 -0
- package/server/claude-worker/claude-attachments.mjs +134 -0
- package/server/claude-worker/claude-preview-tool.mjs +57 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +420 -0
- package/server/claude-worker/claude-session-process.mjs +2391 -0
- package/server/claude-worker/claude-session-test-harness.mjs +236 -0
- package/server/claude-worker/claude-session-worker.mjs +186 -0
- package/server/claude-worker/claude-transcript-relocator.mjs +189 -0
- package/server/claude-worker/claude-turn-publisher.mjs +291 -0
- package/server/claude-worker/fixtures/workflow-run-record.json +1 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +629 -0
- package/server/claude-worker/workflow-progress-digest.mjs +269 -0
- package/server/copilot-sdk-runtime.mjs +126 -0
- package/server/copilot-worker/copilot-attachments.mjs +97 -0
- package/server/copilot-worker/copilot-byok-provider.mjs +121 -0
- package/server/copilot-worker/copilot-continuation-signals.mjs +349 -0
- package/server/copilot-worker/copilot-plan-board.mjs +110 -0
- package/server/copilot-worker/copilot-prompt-context.mjs +86 -0
- package/server/copilot-worker/copilot-question-bridge.mjs +216 -0
- package/server/copilot-worker/copilot-sdk-adapter.mjs +494 -0
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +800 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +1729 -0
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +167 -0
- package/server/copilot-worker/copilot-sdk-test-harness.mjs +329 -0
- package/server/copilot-worker/fixtures/abort-turn.json +588 -0
- package/server/copilot-worker/fixtures/ask-user-turn.json +1154 -0
- package/server/copilot-worker/fixtures/background-timer-continuation.json +445 -0
- package/server/copilot-worker/fixtures/background-timer-turn.json +497 -0
- package/server/copilot-worker/fixtures/happy-turn.json +566 -0
- package/server/copilot-worker/fixtures/quota-turn.json +647 -0
- package/server/copilot-worker/fixtures/reasoning-turn.json +625 -0
- package/server/copilot-worker/fixtures/resume-turn.json +700 -0
- package/server/copilot-worker/fixtures/subagent-turn.json +1378 -0
- package/server/copilot-worker/fixtures/tool-permission-turn.json +1293 -0
- package/server/cursor-worker/cursor-ask-user-tool.mjs +83 -0
- package/server/cursor-worker/cursor-attachments.mjs +88 -0
- package/server/cursor-worker/cursor-context-usage.mjs +85 -0
- package/server/cursor-worker/cursor-preview-tool.mjs +44 -0
- package/server/cursor-worker/cursor-sdk-adapter.mjs +554 -0
- package/server/cursor-worker/cursor-session-worker.mjs +145 -0
- package/server/cursor-worker/cursor-subagent-roster.mjs +81 -0
- package/server/cursor-worker/cursor-turn-runner.mjs +717 -0
- package/server/cursor-worker/sdk-message-normalizer.mjs +485 -0
- package/server/db-schema.mjs +932 -0
- package/server/features.mjs +106 -0
- package/server/grok-worker/acp-client.mjs +434 -0
- package/server/grok-worker/acp-host-services.mjs +277 -0
- package/server/grok-worker/grok-context-usage.mjs +77 -0
- package/server/grok-worker/grok-sdk-adapter.mjs +334 -0
- package/server/grok-worker/grok-session-worker.mjs +145 -0
- package/server/grok-worker/grok-turn-runner.mjs +523 -0
- package/server/grok-worker/sdk-message-normalizer.mjs +311 -0
- package/server/migrations/0001-add-structured-answer.mjs +80 -0
- package/server/migrations/0002-recent-workspace-roots-path-key.mjs +121 -0
- package/server/migrations/0003-push-subscriptions.mjs +87 -0
- package/server/public/app/action-confirmations.js +267 -0
- package/server/public/app/activity-replay-state.mjs +149 -0
- package/server/public/app/api-client.js +1082 -0
- package/server/public/app/ask-user-view.js +545 -0
- package/server/public/app/attachments-view.js +2112 -0
- package/server/public/app/auto-compact-window-options.mjs +59 -0
- package/server/public/app/background-tasks-view.mjs +494 -0
- package/server/public/app/bootstrap.js +4347 -0
- package/server/public/app/claude-auth-ui.js +394 -0
- package/server/public/app/claude-thinking-options.mjs +38 -0
- package/server/public/app/cli-install-ui.js +455 -0
- package/server/public/app/code-copy.mjs +85 -0
- package/server/public/app/composer-attachment-cache.mjs +151 -0
- package/server/public/app/composer-control-state.mjs +107 -0
- package/server/public/app/composer-paste.mjs +132 -0
- package/server/public/app/context-tier-options.mjs +68 -0
- package/server/public/app/context-usage-view.mjs +338 -0
- package/server/public/app/conversation-draft-timestamp-utils.mjs +11 -0
- package/server/public/app/conversation-preferences.mjs +91 -0
- package/server/public/app/conversation-provider-indicator.mjs +88 -0
- package/server/public/app/conversation-view.js +2898 -0
- package/server/public/app/cwd-menu-placement.mjs +85 -0
- package/server/public/app/cwd-picker.js +605 -0
- package/server/public/app/emoji-view.js +101 -0
- package/server/public/app/external-link-policy.mjs +80 -0
- package/server/public/app/file-preview-navigation.mjs +51 -0
- package/server/public/app/font-scaling.js +262 -0
- package/server/public/app/git-changes-view.js +339 -0
- package/server/public/app/git-diff-model.mjs +109 -0
- package/server/public/app/grok-auth-ui.js +387 -0
- package/server/public/app/image-downscale.mjs +174 -0
- package/server/public/app/in-flight-snapshot.mjs +40 -0
- package/server/public/app/infinite-loader.js +164 -0
- package/server/public/app/journal-view.js +1141 -0
- package/server/public/app/known-cwd-options.mjs +41 -0
- package/server/public/app/launch-cwd-path.mjs +23 -0
- package/server/public/app/live-message-dedupe.mjs +57 -0
- package/server/public/app/message-search-view.js +242 -0
- package/server/public/app/model-selector-options.mjs +31 -0
- package/server/public/app/model-vision-support.mjs +56 -0
- package/server/public/app/new-conversation-model-choice.mjs +67 -0
- package/server/public/app/plan-usage-view.mjs +307 -0
- package/server/public/app/preview-cards.mjs +273 -0
- package/server/public/app/preview-command.mjs +103 -0
- package/server/public/app/push-settings.js +370 -0
- package/server/public/app/pwa-install.js +456 -0
- package/server/public/app/question-schema-view.mjs +71 -0
- package/server/public/app/reasoning-effort-labels.mjs +42 -0
- package/server/public/app/relay-board-view.js +117 -0
- package/server/public/app/relay-dot-state.mjs +69 -0
- package/server/public/app/relay-error-ctas.mjs +52 -0
- package/server/public/app/relay-prompt-sanitizer.mjs +114 -0
- package/server/public/app/relay-thoughts.mjs +98 -0
- package/server/public/app/relay-watchdog-policy.mjs +29 -0
- package/server/public/app/repo-browser-preferences.mjs +79 -0
- package/server/public/app/repo-browser-tree-state.mjs +97 -0
- package/server/public/app/router.js +657 -0
- package/server/public/app/selection-guard.mjs +93 -0
- package/server/public/app/settings-modal.js +1484 -0
- package/server/public/app/settings-tabs.js +129 -0
- package/server/public/app/slash-autocomplete.mjs +188 -0
- package/server/public/app/slash-commands.mjs +183 -0
- package/server/public/app/socket-handlers.js +703 -0
- package/server/public/app/status-store.mjs +242 -0
- package/server/public/app/status-view.mjs +315 -0
- package/server/public/app/store.js +1454 -0
- package/server/public/app/stream-state.mjs +73 -0
- package/server/public/app/streaming-dom-patch.mjs +23 -0
- package/server/public/app/sync-outbox.mjs +208 -0
- package/server/public/app/tap-actions.js +62 -0
- package/server/public/app/thread-order.mjs +54 -0
- package/server/public/app/tmux-inspector-view.js +422 -0
- package/server/public/app/transcript-separators.mjs +282 -0
- package/server/public/app-icon-192.png +0 -0
- package/server/public/app-icon-512.png +0 -0
- package/server/public/app-icon.svg +4 -0
- package/server/public/favicon.ico +0 -0
- package/server/public/index.html +5743 -0
- package/server/public/manifest.webmanifest +32 -0
- package/server/public/sw.js +307 -0
- package/server/relay-exit-codes.mjs +4 -0
- package/server/relay-self-restart.mjs +170 -0
- package/server/relay-tools.md +27 -0
- package/server/relay.mjs +1026 -0
- package/server/repositories/image-conversation-repository.mjs +317 -0
- package/server/repositories/message-repository.mjs +434 -0
- package/server/repositories/question-repository.mjs +161 -0
- package/server/repositories/session-repository.mjs +320 -0
- package/server/repositories/share-visibility-statements.mjs +24 -0
- package/server/routes/ask-user-routes.mjs +292 -0
- package/server/routes/cache-routes.mjs +44 -0
- package/server/routes/git-routes.mjs +92 -0
- package/server/routes/messages-routes-test-harness.mjs +111 -0
- package/server/routes/messages-routes.mjs +7107 -0
- package/server/routes/preview-routes.mjs +108 -0
- package/server/routes/push-routes.mjs +59 -0
- package/server/routes/relay-board-routes.mjs +268 -0
- package/server/routes/sessions-routes.mjs +6150 -0
- package/server/server-runtime.mjs +7463 -0
- package/server/server.js +24 -0
- package/server/services/ask-user-routing-service.mjs +117 -0
- package/server/services/cache-rebuild-service.mjs +236 -0
- package/server/services/claude-auth-service.mjs +578 -0
- package/server/services/claude-context-usage.mjs +240 -0
- package/server/services/claude-session-root-service.mjs +228 -0
- package/server/services/cli-install-service.mjs +1152 -0
- package/server/services/cli-process-runner.mjs +181 -0
- package/server/services/cloudflared-tunnel-service.mjs +358 -0
- package/server/services/context-snapshot-service.mjs +499 -0
- package/server/services/context-usage-view.mjs +160 -0
- package/server/services/conversation-preferences-service.mjs +208 -0
- package/server/services/cursor-dashboard-usage.mjs +217 -0
- package/server/services/cursor-session-root-service.mjs +62 -0
- package/server/services/delete-archive-service.mjs +154 -0
- package/server/services/drives-path-helpers.mjs +66 -0
- package/server/services/fixtures/claude-auth-stub.sh +62 -0
- package/server/services/fixtures/cli-install-stub.sh +75 -0
- package/server/services/fixtures/grok-stub.sh +86 -0
- package/server/services/generated-image-cleanup-service.mjs +114 -0
- package/server/services/git-changes-service.mjs +195 -0
- package/server/services/github-billing-usage.mjs +133 -0
- package/server/services/grok-auth-service.mjs +593 -0
- package/server/services/grok-billing-usage.mjs +120 -0
- package/server/services/image-context-service.mjs +106 -0
- package/server/services/image-operation-service.mjs +135 -0
- package/server/services/keyed-mutex-service.mjs +82 -0
- package/server/services/mime-sniffer.mjs +118 -0
- package/server/services/oar-cli-helpers.mjs +109 -0
- package/server/services/oar-state-migration-service.mjs +243 -0
- package/server/services/plan-usage-claude.mjs +387 -0
- package/server/services/plan-usage-contract.mjs +271 -0
- package/server/services/plan-usage-copilot.mjs +421 -0
- package/server/services/plan-usage-cursor.mjs +566 -0
- package/server/services/plan-usage-grok.mjs +436 -0
- package/server/services/plan-usage-service.mjs +487 -0
- package/server/services/preview-health-probe.mjs +82 -0
- package/server/services/preview-proxy-server.mjs +498 -0
- package/server/services/preview-registry-service.mjs +457 -0
- package/server/services/preview-static-handler.mjs +237 -0
- package/server/services/provider-model-selection.mjs +91 -0
- package/server/services/provider-reasoning-effort.mjs +94 -0
- package/server/services/push-active-device-service.mjs +57 -0
- package/server/services/push-dispatch-service.mjs +459 -0
- package/server/services/rate-limit-service.mjs +63 -0
- package/server/services/relay-bridge-owner-service.mjs +84 -0
- package/server/services/relay-cli-launcher-service.mjs +209 -0
- package/server/services/relay-prompt-sanitizer.mjs +137 -0
- package/server/services/relay-queue-gate-service.mjs +38 -0
- package/server/services/relay-restart-orchestrator-service.mjs +935 -0
- package/server/services/relay-singleton-guard.mjs +124 -0
- package/server/services/safe-served-content.mjs +57 -0
- package/server/services/sdk-session-import-service.mjs +271 -0
- package/server/services/sdk-session-sync-service.mjs +207 -0
- package/server/services/session-history-refresh-service.mjs +497 -0
- package/server/services/session-transcript-service.mjs +419 -0
- package/server/services/session-worker-launch-service.mjs +786 -0
- package/server/services/session-worker-process-service.mjs +319 -0
- package/server/services/session-worker-registry-service.mjs +194 -0
- package/server/services/session-worker-stop-service.mjs +141 -0
- package/server/services/session-worker-supervisor-service.mjs +898 -0
- package/server/services/session-worker-websocket-service.mjs +483 -0
- package/server/services/ssh-tunnel-manager-service.mjs +423 -0
- package/server/services/status-event-service.mjs +158 -0
- package/server/services/tmux-inspector-access-policy.mjs +77 -0
- package/server/services/tmux-inspector-socket-service.mjs +191 -0
- package/server/services/tmux-inspector-stream-service.mjs +552 -0
- package/server/services/tunnel-worker-path-guard.mjs +107 -0
- package/server/services/upload-sweep.mjs +60 -0
- package/server/services/usage-snapshot-helpers.mjs +209 -0
- package/server/services/windows-autostart-service.mjs +115 -0
- package/server/services/workspace-root-defaults-service.mjs +34 -0
- package/server/services/workspace-root-path-policy.mjs +196 -0
- package/server/services/workspace-root-relaunch-service.mjs +132 -0
- package/server/services/workspace-symlink-guard.mjs +54 -0
- package/server/tools/capture-proxy.mjs +173 -0
- package/server/tty-console-bootstrap.mjs +75 -0
- package/server/uploads/README.md +36 -0
- package/server/uploads/image_input.md +540 -0
- package/server/windows-terminal-launcher.mjs +29 -0
- package/server/workspace-root.mjs +87 -0
- package/shared/ask-user-bridge.mjs +131 -0
- package/shared/auto-compact-window.mjs +116 -0
- package/shared/background-task-timeout.mjs +52 -0
- package/shared/claude-thinking.mjs +101 -0
- package/shared/context-window-fallbacks.mjs +148 -0
- package/shared/control-poller.mjs +115 -0
- package/shared/empty-turn-completion.mjs +20 -0
- package/shared/model-catalog-freshness.mjs +11 -0
- package/shared/model-descriptors.mjs +118 -0
- package/shared/model-id.mjs +147 -0
- package/shared/model-refresh.mjs +24 -0
- package/shared/openai-reasoning.mjs +13 -0
- package/shared/plan-lines.mjs +13 -0
- package/shared/preview-instructions.mjs +77 -0
- package/shared/preview-tool-core.mjs +191 -0
- package/shared/provider-routing.mjs +29 -0
- package/shared/question-schema.mjs +315 -0
- package/shared/question-timeout.mjs +18 -0
- package/shared/stream-emit-gating.mjs +28 -0
- package/shared/subagent-run-id.mjs +17 -0
- package/shared/thought-cap.mjs +13 -0
- package/shared/tool-activity.mjs +98 -0
- package/shared/turn-ceiling.mjs +53 -0
- package/shared/worker-bootstrap.mjs +44 -0
- package/shared/worker-crash-guard.mjs +60 -0
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import crypto from 'crypto';
|
|
4
|
+
|
|
5
|
+
// Preview lane registry: the in-memory set of dev servers published through the
|
|
6
|
+
// dedicated preview listener, plus the config normalization and the startup
|
|
7
|
+
// interlocks that keep that listener a separate origin from the relay.
|
|
8
|
+
//
|
|
9
|
+
// This module is deliberately http-free. It owns *what is allowed*; the listener
|
|
10
|
+
// (preview-proxy-server.mjs) owns the wire. Nothing here can reach relay state.
|
|
11
|
+
|
|
12
|
+
export const PREVIEW_PATH_PREFIX = 'test_';
|
|
13
|
+
export const PREVIEW_TOKEN_BYTES = 16;
|
|
14
|
+
const PREVIEW_TOKEN_RE = /^[0-9a-f]{32}$/;
|
|
15
|
+
const PREVIEW_PATH_RE = /^\/test_([0-9a-f]{32})(\/.*)?$/;
|
|
16
|
+
|
|
17
|
+
const DEFAULT_MAX_LIVE = 8;
|
|
18
|
+
const MIN_TARGET_PORT = 1024;
|
|
19
|
+
const MAX_PORT = 65535;
|
|
20
|
+
|
|
21
|
+
// Hostnames that always resolve back to this machine. A preview target is one of
|
|
22
|
+
// these unless the operator has explicitly widened the allowlist.
|
|
23
|
+
const LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1', 'localhost']);
|
|
24
|
+
|
|
25
|
+
function toText(value) {
|
|
26
|
+
return String(value ?? '').trim();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function parsePort(value) {
|
|
30
|
+
if (typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= MAX_PORT) return value;
|
|
31
|
+
const text = toText(value);
|
|
32
|
+
if (!/^\d+$/.test(text)) return null;
|
|
33
|
+
const num = Number.parseInt(text, 10);
|
|
34
|
+
if (!Number.isInteger(num) || num < 0 || num > MAX_PORT) return null;
|
|
35
|
+
return num;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function normalizeHost(value) {
|
|
39
|
+
const host = toText(value).toLowerCase().replace(/^\[|\]$/g, '');
|
|
40
|
+
return host === 'localhost' ? '127.0.0.1' : host;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function isLoopbackHost(value) {
|
|
44
|
+
const host = toText(value).toLowerCase().replace(/^\[|\]$/g, '');
|
|
45
|
+
if (LOOPBACK_HOSTS.has(host)) return true;
|
|
46
|
+
// The whole 127.0.0.0/8 block is loopback, not just .0.1.
|
|
47
|
+
if (/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host)) {
|
|
48
|
+
return host.split('.').slice(1).every((part) => Number(part) >= 0 && Number(part) <= 255);
|
|
49
|
+
}
|
|
50
|
+
return host === '0:0:0:0:0:0:0:1';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function normalizeHostList(rawValue) {
|
|
54
|
+
const entries = Array.isArray(rawValue)
|
|
55
|
+
? rawValue
|
|
56
|
+
: toText(rawValue).split(',');
|
|
57
|
+
const out = [];
|
|
58
|
+
for (const entry of entries) {
|
|
59
|
+
const host = normalizeHost(entry);
|
|
60
|
+
if (host && !out.includes(host)) out.push(host);
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// The public origin is compared by *hostname*, never by port: cookies are scoped
|
|
66
|
+
// to a host and ignore the port, so relay.example.com:3333 and
|
|
67
|
+
// relay.example.com:3334 would still share the relay's auth cookie. Only a
|
|
68
|
+
// different hostname is a real boundary.
|
|
69
|
+
function parseHostname(rawUrl) {
|
|
70
|
+
const text = toText(rawUrl);
|
|
71
|
+
if (!text) return '';
|
|
72
|
+
try {
|
|
73
|
+
return new URL(text).hostname.toLowerCase();
|
|
74
|
+
} catch {
|
|
75
|
+
return '';
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function normalizeBaseUrl(rawUrl) {
|
|
80
|
+
const text = toText(rawUrl);
|
|
81
|
+
if (!text) return '';
|
|
82
|
+
let parsed;
|
|
83
|
+
try {
|
|
84
|
+
parsed = new URL(text);
|
|
85
|
+
} catch {
|
|
86
|
+
return '';
|
|
87
|
+
}
|
|
88
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return '';
|
|
89
|
+
// Preview URLs are built as `${base}/test_<token>/`, so a trailing slash on the
|
|
90
|
+
// configured base would double up.
|
|
91
|
+
return `${parsed.origin}${parsed.pathname.replace(/\/+$/, '')}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Normalizes the `previews` config block and runs the startup interlocks.
|
|
96
|
+
*
|
|
97
|
+
* Every interlock failure is collected rather than thrown: the caller keeps the
|
|
98
|
+
* relay running and leaves the preview lane down, because a misconfigured
|
|
99
|
+
* preview lane must never take the relay with it — but it must also never come
|
|
100
|
+
* up half-configured, since each interlock is load-bearing for the isolation
|
|
101
|
+
* argument.
|
|
102
|
+
*/
|
|
103
|
+
export function normalizePreviewsConfig(rawConfig = {}, {
|
|
104
|
+
env = process.env,
|
|
105
|
+
relayPort = null,
|
|
106
|
+
relayHostnames = [],
|
|
107
|
+
reservedPorts = [],
|
|
108
|
+
} = {}) {
|
|
109
|
+
const raw = rawConfig && typeof rawConfig === 'object' ? rawConfig : {};
|
|
110
|
+
const envEnabled = toText(env?.COPILOT_PREVIEWS_ENABLED).toLowerCase();
|
|
111
|
+
const enabled = envEnabled === '1' || envEnabled === 'true'
|
|
112
|
+
? true
|
|
113
|
+
: (envEnabled === '0' || envEnabled === 'false' ? false : raw.enabled === true);
|
|
114
|
+
|
|
115
|
+
const normalizedRelayPort = parsePort(relayPort);
|
|
116
|
+
const envPort = parsePort(env?.COPILOT_PREVIEWS_PORT);
|
|
117
|
+
const configPort = parsePort(raw.port);
|
|
118
|
+
// Default is relay port + 1: a front proxy (Caddy, a cloudflared public
|
|
119
|
+
// hostname) needs a fixed target, so the default has to be predictable.
|
|
120
|
+
const defaultPort = Number.isInteger(normalizedRelayPort) && normalizedRelayPort < MAX_PORT
|
|
121
|
+
? normalizedRelayPort + 1
|
|
122
|
+
: null;
|
|
123
|
+
const port = envPort ?? configPort ?? defaultPort;
|
|
124
|
+
|
|
125
|
+
const bindHost = normalizeHost(
|
|
126
|
+
toText(env?.COPILOT_PREVIEWS_BIND_HOST) || toText(raw.bindHost) || '127.0.0.1',
|
|
127
|
+
);
|
|
128
|
+
const allowPublicBind = raw.allowPublicBind === true;
|
|
129
|
+
|
|
130
|
+
const publicBaseUrl = normalizeBaseUrl(
|
|
131
|
+
toText(env?.COPILOT_PREVIEWS_PUBLIC_BASE_URL) || toText(raw.publicBaseUrl),
|
|
132
|
+
);
|
|
133
|
+
const publicHostname = parseHostname(publicBaseUrl);
|
|
134
|
+
|
|
135
|
+
const allowedTargetHosts = normalizeHostList(
|
|
136
|
+
env?.COPILOT_PREVIEWS_ALLOWED_TARGET_HOSTS ?? raw.allowedTargetHosts,
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
const maxLiveRaw = Number(raw.maxLive);
|
|
140
|
+
const maxLive = Number.isInteger(maxLiveRaw) && maxLiveRaw > 0 ? maxLiveRaw : DEFAULT_MAX_LIVE;
|
|
141
|
+
|
|
142
|
+
const normalizedRelayHostnames = normalizeHostList(relayHostnames);
|
|
143
|
+
const normalizedReservedPorts = (Array.isArray(reservedPorts) ? reservedPorts : [reservedPorts])
|
|
144
|
+
.map((entry) => parsePort(entry))
|
|
145
|
+
.filter((entry) => Number.isInteger(entry) && entry > 0);
|
|
146
|
+
|
|
147
|
+
const errors = [];
|
|
148
|
+
if (enabled) {
|
|
149
|
+
// Interlock 1 — a public base URL is mandatory and must not share the relay's
|
|
150
|
+
// hostname. Same hostname means shared cookies and same-origin fetch, which
|
|
151
|
+
// removes the entire browser-side boundary.
|
|
152
|
+
if (!publicBaseUrl) {
|
|
153
|
+
errors.push('previews.publicBaseUrl is required (an http(s) URL on a hostname of your own)');
|
|
154
|
+
} else if (publicHostname && normalizedRelayHostnames.includes(publicHostname)) {
|
|
155
|
+
errors.push(`previews.publicBaseUrl must use a different hostname than the relay (got ${publicHostname})`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Interlock 2 — the listener must not collide with the relay's own ports.
|
|
159
|
+
// Port 0 is an explicit opt-in to an ephemeral port.
|
|
160
|
+
if (!Number.isInteger(port)) {
|
|
161
|
+
errors.push('previews.port could not be resolved (set previews.port or config.port)');
|
|
162
|
+
} else if (port !== 0) {
|
|
163
|
+
if (port < MIN_TARGET_PORT) {
|
|
164
|
+
errors.push(`previews.port must be >= ${MIN_TARGET_PORT} (got ${port})`);
|
|
165
|
+
} else if (normalizedReservedPorts.includes(port) || port === normalizedRelayPort) {
|
|
166
|
+
errors.push(`previews.port ${port} collides with a relay port`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Interlock 3 — the front proxy is expected to be local. Binding a public
|
|
171
|
+
// interface by accident would put every preview on the LAN unfiltered.
|
|
172
|
+
if (!isLoopbackHost(bindHost) && !allowPublicBind) {
|
|
173
|
+
errors.push(`previews.bindHost ${bindHost} is not loopback; set previews.allowPublicBind to override`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Ports the relay owns: a preview must never be pointed at one of these, or it
|
|
178
|
+
// would republish the relay UI (login page and all) on the public preview host.
|
|
179
|
+
const deniedTargetPorts = [...new Set([
|
|
180
|
+
...normalizedReservedPorts,
|
|
181
|
+
...(Number.isInteger(normalizedRelayPort) ? [normalizedRelayPort] : []),
|
|
182
|
+
...(Number.isInteger(port) && port > 0 ? [port] : []),
|
|
183
|
+
])];
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
enabled: enabled && errors.length === 0,
|
|
187
|
+
requested: enabled,
|
|
188
|
+
port,
|
|
189
|
+
bindHost,
|
|
190
|
+
allowPublicBind,
|
|
191
|
+
publicBaseUrl,
|
|
192
|
+
publicHostname,
|
|
193
|
+
allowedTargetHosts,
|
|
194
|
+
deniedTargetPorts,
|
|
195
|
+
maxLive,
|
|
196
|
+
errors,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function createPreviewToken({ randomBytes = crypto.randomBytes } = {}) {
|
|
201
|
+
return Buffer.from(randomBytes(PREVIEW_TOKEN_BYTES)).toString('hex');
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function isPreviewToken(value) {
|
|
205
|
+
return PREVIEW_TOKEN_RE.test(toText(value));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Splits a preview request path into its token and the upstream path.
|
|
210
|
+
* Returns null for anything that is not a preview path at all, and
|
|
211
|
+
* `{ token, upstreamPath: null }` for a bare `/test_<token>` that needs the
|
|
212
|
+
* trailing-slash redirect (relative asset URLs resolve wrongly without it).
|
|
213
|
+
*/
|
|
214
|
+
export function parsePreviewPath(rawPath) {
|
|
215
|
+
const text = toText(rawPath);
|
|
216
|
+
if (!text.startsWith('/')) return null;
|
|
217
|
+
const [pathname] = text.split('#');
|
|
218
|
+
const queryIndex = pathname.indexOf('?');
|
|
219
|
+
const bare = queryIndex === -1 ? pathname : pathname.slice(0, queryIndex);
|
|
220
|
+
const query = queryIndex === -1 ? '' : pathname.slice(queryIndex);
|
|
221
|
+
const match = PREVIEW_PATH_RE.exec(bare);
|
|
222
|
+
if (!match) return null;
|
|
223
|
+
const [, token, rest] = match;
|
|
224
|
+
if (!rest) return { token, upstreamPath: null, query };
|
|
225
|
+
return { token, upstreamPath: `${rest}${query}`, query };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function previewBasePath(token) {
|
|
229
|
+
return `/${PREVIEW_PATH_PREFIX}${token}/`;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Validates an upstream target. Called at registration *and* re-checked by the
|
|
234
|
+
* listener against the stored record, so a stored value can never drift into
|
|
235
|
+
* something the policy would reject.
|
|
236
|
+
*/
|
|
237
|
+
export function validatePreviewTarget({ host, port } = {}, {
|
|
238
|
+
allowedTargetHosts = [],
|
|
239
|
+
deniedTargetPorts = [],
|
|
240
|
+
} = {}) {
|
|
241
|
+
const normalizedHost = normalizeHost(host || '127.0.0.1');
|
|
242
|
+
const normalizedPort = parsePort(port);
|
|
243
|
+
|
|
244
|
+
if (!normalizedHost) return { ok: false, error: 'Missing target host' };
|
|
245
|
+
if (!Number.isInteger(normalizedPort)) return { ok: false, error: 'Invalid target port' };
|
|
246
|
+
if (normalizedPort < MIN_TARGET_PORT || normalizedPort > MAX_PORT) {
|
|
247
|
+
return { ok: false, error: `Target port must be between ${MIN_TARGET_PORT} and ${MAX_PORT}` };
|
|
248
|
+
}
|
|
249
|
+
if (deniedTargetPorts.includes(normalizedPort)) {
|
|
250
|
+
return { ok: false, error: `Port ${normalizedPort} belongs to the relay and cannot be previewed` };
|
|
251
|
+
}
|
|
252
|
+
if (!isLoopbackHost(normalizedHost) && !allowedTargetHosts.includes(normalizedHost)) {
|
|
253
|
+
return { ok: false, error: `Target host ${normalizedHost} is not loopback and not in previews.allowedTargetHosts` };
|
|
254
|
+
}
|
|
255
|
+
return { ok: true, host: normalizedHost, port: normalizedPort };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* The live preview set. In-memory by design: a relay restart drops every
|
|
260
|
+
* preview, which is the agreed lifecycle (nothing expires on its own, so a
|
|
261
|
+
* restart is the only implicit cleanup).
|
|
262
|
+
*/
|
|
263
|
+
export function createPreviewRegistry({
|
|
264
|
+
config,
|
|
265
|
+
now = () => Date.now(),
|
|
266
|
+
randomBytes = crypto.randomBytes,
|
|
267
|
+
onChange = null,
|
|
268
|
+
} = {}) {
|
|
269
|
+
const entries = new Map();
|
|
270
|
+
const settings = config || normalizePreviewsConfig({});
|
|
271
|
+
|
|
272
|
+
function toPublicRecord(entry) {
|
|
273
|
+
return {
|
|
274
|
+
token: entry.token,
|
|
275
|
+
conversationId: entry.conversationId,
|
|
276
|
+
label: entry.label,
|
|
277
|
+
mode: entry.mode,
|
|
278
|
+
targetHost: entry.targetHost,
|
|
279
|
+
targetPort: entry.targetPort,
|
|
280
|
+
rootDir: entry.rootDir,
|
|
281
|
+
url: entry.url,
|
|
282
|
+
basePath: entry.basePath,
|
|
283
|
+
createdAt: entry.createdAt,
|
|
284
|
+
online: entry.online,
|
|
285
|
+
lastSeenOnline: entry.lastSeenOnline,
|
|
286
|
+
hits: entry.hits,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function emitChange(reason, entry) {
|
|
291
|
+
if (typeof onChange !== 'function') return;
|
|
292
|
+
try {
|
|
293
|
+
onChange({ reason, preview: entry ? toPublicRecord(entry) : null, previews: list() });
|
|
294
|
+
} catch {}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function list() {
|
|
298
|
+
return [...entries.values()]
|
|
299
|
+
.sort((a, b) => a.createdAt - b.createdAt)
|
|
300
|
+
.map(toPublicRecord);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function listForConversation(conversationId) {
|
|
304
|
+
const id = toText(conversationId);
|
|
305
|
+
if (!id) return [];
|
|
306
|
+
return list().filter((entry) => entry.conversationId === id);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function insertEntry(fields) {
|
|
310
|
+
const token = createPreviewToken({ randomBytes });
|
|
311
|
+
const basePath = previewBasePath(token);
|
|
312
|
+
const entry = {
|
|
313
|
+
token,
|
|
314
|
+
basePath,
|
|
315
|
+
url: `${settings.publicBaseUrl}${basePath}`,
|
|
316
|
+
createdAt: now(),
|
|
317
|
+
lastSeenOnline: null,
|
|
318
|
+
hits: 0,
|
|
319
|
+
...fields,
|
|
320
|
+
};
|
|
321
|
+
entries.set(token, entry);
|
|
322
|
+
emitChange('created', entry);
|
|
323
|
+
return { ok: true, preview: toPublicRecord(entry) };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function checkCreatable() {
|
|
327
|
+
if (!settings.enabled) {
|
|
328
|
+
return { ok: false, status: 503, error: 'Preview lane is disabled' };
|
|
329
|
+
}
|
|
330
|
+
if (entries.size >= settings.maxLive) {
|
|
331
|
+
return { ok: false, status: 429, error: `Too many live previews (max ${settings.maxLive})` };
|
|
332
|
+
}
|
|
333
|
+
return { ok: true };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function create({ conversationId, port, host, label } = {}) {
|
|
337
|
+
const creatable = checkCreatable();
|
|
338
|
+
if (!creatable.ok) return creatable;
|
|
339
|
+
const target = validatePreviewTarget({ host, port }, settings);
|
|
340
|
+
if (!target.ok) return { ok: false, status: 400, error: target.error };
|
|
341
|
+
|
|
342
|
+
return insertEntry({
|
|
343
|
+
mode: 'port',
|
|
344
|
+
conversationId: toText(conversationId) || null,
|
|
345
|
+
label: toText(label).slice(0, 120) || `localhost:${target.port}`,
|
|
346
|
+
targetHost: target.host,
|
|
347
|
+
targetPort: target.port,
|
|
348
|
+
rootDir: null,
|
|
349
|
+
// null = never probed yet; the card shows no badge until the first probe.
|
|
350
|
+
online: null,
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Static mode. `rootDir` must arrive already jailed (realpath inside the
|
|
355
|
+
// workspace root) — the route owns that check because it has fs access and
|
|
356
|
+
// the workspace resolver; the registry stays fs-free.
|
|
357
|
+
function createStatic({ conversationId, rootDir, label } = {}) {
|
|
358
|
+
const creatable = checkCreatable();
|
|
359
|
+
if (!creatable.ok) return creatable;
|
|
360
|
+
const root = toText(rootDir);
|
|
361
|
+
if (!root) return { ok: false, status: 400, error: 'Missing rootDir' };
|
|
362
|
+
|
|
363
|
+
return insertEntry({
|
|
364
|
+
mode: 'static',
|
|
365
|
+
conversationId: toText(conversationId) || null,
|
|
366
|
+
label: toText(label).slice(0, 120) || root.split(/[\\/]/).filter(Boolean).pop() || 'static preview',
|
|
367
|
+
targetHost: null,
|
|
368
|
+
targetPort: null,
|
|
369
|
+
rootDir: root,
|
|
370
|
+
// There is no upstream to die: a static preview is online by definition,
|
|
371
|
+
// and the health probe skips it.
|
|
372
|
+
online: true,
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Hot path (every proxied request): returns the raw entry, re-validated
|
|
377
|
+
// against current policy so a config reload cannot leave a stale grant live.
|
|
378
|
+
function resolve(token) {
|
|
379
|
+
if (!settings.enabled || !isPreviewToken(token)) return null;
|
|
380
|
+
const entry = entries.get(token);
|
|
381
|
+
if (!entry) return null;
|
|
382
|
+
// Static entries have no upstream target; their per-request policy is the
|
|
383
|
+
// path jail inside the static handler.
|
|
384
|
+
if (entry.mode !== 'static') {
|
|
385
|
+
const target = validatePreviewTarget(
|
|
386
|
+
{ host: entry.targetHost, port: entry.targetPort },
|
|
387
|
+
settings,
|
|
388
|
+
);
|
|
389
|
+
if (!target.ok) return null;
|
|
390
|
+
}
|
|
391
|
+
return entry;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function recordHit(token, at = now()) {
|
|
395
|
+
const entry = entries.get(toText(token));
|
|
396
|
+
if (!entry) return;
|
|
397
|
+
entry.hits += 1;
|
|
398
|
+
entry.lastHitAt = at;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function markHealth(token, online, at = now()) {
|
|
402
|
+
const entry = entries.get(toText(token));
|
|
403
|
+
if (!entry) return false;
|
|
404
|
+
const next = online === true;
|
|
405
|
+
if (next) entry.lastSeenOnline = at;
|
|
406
|
+
if (entry.online === next) return false;
|
|
407
|
+
entry.online = next;
|
|
408
|
+
emitChange('health', entry);
|
|
409
|
+
return true;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function close(token) {
|
|
413
|
+
const entry = entries.get(toText(token));
|
|
414
|
+
if (!entry) return { ok: false, status: 404, error: 'Unknown preview' };
|
|
415
|
+
entries.delete(entry.token);
|
|
416
|
+
emitChange('closed', entry);
|
|
417
|
+
return { ok: true, preview: toPublicRecord(entry) };
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function closeForConversation(conversationId) {
|
|
421
|
+
const id = toText(conversationId);
|
|
422
|
+
if (!id) return 0;
|
|
423
|
+
let closed = 0;
|
|
424
|
+
for (const entry of [...entries.values()]) {
|
|
425
|
+
if (entry.conversationId !== id) continue;
|
|
426
|
+
entries.delete(entry.token);
|
|
427
|
+
closed += 1;
|
|
428
|
+
}
|
|
429
|
+
if (closed > 0) emitChange('closed', null);
|
|
430
|
+
return closed;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function clear() {
|
|
434
|
+
const had = entries.size > 0;
|
|
435
|
+
entries.clear();
|
|
436
|
+
if (had) emitChange('cleared', null);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
return {
|
|
440
|
+
settings,
|
|
441
|
+
get size() { return entries.size; },
|
|
442
|
+
create,
|
|
443
|
+
createStatic,
|
|
444
|
+
resolve,
|
|
445
|
+
recordHit,
|
|
446
|
+
markHealth,
|
|
447
|
+
close,
|
|
448
|
+
closeForConversation,
|
|
449
|
+
clear,
|
|
450
|
+
list,
|
|
451
|
+
listForConversation,
|
|
452
|
+
get: (token) => {
|
|
453
|
+
const entry = entries.get(toText(token));
|
|
454
|
+
return entry ? toPublicRecord(entry) : null;
|
|
455
|
+
},
|
|
456
|
+
};
|
|
457
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
|
|
6
|
+
// Static mode for the preview lane: "show me this file/build output" without a
|
|
7
|
+
// dev server. The lane serves a registered directory in-process — a request
|
|
8
|
+
// handler, not a child process, so the "relay never spawns or supervises
|
|
9
|
+
// servers" rule stands.
|
|
10
|
+
//
|
|
11
|
+
// Everything here is jail-first. The registered root is realpath-resolved and
|
|
12
|
+
// must sit inside the conversation's workspace root at registration; every
|
|
13
|
+
// request re-resolves and re-checks containment, so a symlink created after
|
|
14
|
+
// registration cannot walk out either.
|
|
15
|
+
|
|
16
|
+
// A static preview shows a build output, not the whole working tree. Dotfile
|
|
17
|
+
// segments (.git, .env, .ssh, …) are denied wholesale; these extensions and
|
|
18
|
+
// name patterns catch key material living outside dot-directories.
|
|
19
|
+
const DENIED_EXTENSIONS = new Set(['.pem', '.key']);
|
|
20
|
+
const DENIED_NAME_RE = /^id_(rsa|ed25519|ecdsa|dsa)/i;
|
|
21
|
+
|
|
22
|
+
const CONTENT_TYPES = {
|
|
23
|
+
'.html': 'text/html; charset=utf-8',
|
|
24
|
+
'.htm': 'text/html; charset=utf-8',
|
|
25
|
+
'.css': 'text/css; charset=utf-8',
|
|
26
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
27
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
28
|
+
'.json': 'application/json; charset=utf-8',
|
|
29
|
+
'.map': 'application/json; charset=utf-8',
|
|
30
|
+
'.svg': 'image/svg+xml',
|
|
31
|
+
'.png': 'image/png',
|
|
32
|
+
'.jpg': 'image/jpeg',
|
|
33
|
+
'.jpeg': 'image/jpeg',
|
|
34
|
+
'.gif': 'image/gif',
|
|
35
|
+
'.webp': 'image/webp',
|
|
36
|
+
'.avif': 'image/avif',
|
|
37
|
+
'.ico': 'image/x-icon',
|
|
38
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
39
|
+
'.md': 'text/plain; charset=utf-8',
|
|
40
|
+
'.xml': 'application/xml; charset=utf-8',
|
|
41
|
+
'.pdf': 'application/pdf',
|
|
42
|
+
'.wasm': 'application/wasm',
|
|
43
|
+
'.woff': 'font/woff',
|
|
44
|
+
'.woff2': 'font/woff2',
|
|
45
|
+
'.ttf': 'font/ttf',
|
|
46
|
+
'.otf': 'font/otf',
|
|
47
|
+
'.mp4': 'video/mp4',
|
|
48
|
+
'.webm': 'video/webm',
|
|
49
|
+
'.mp3': 'audio/mpeg',
|
|
50
|
+
'.wav': 'audio/wav',
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
function toText(value) {
|
|
54
|
+
return String(value ?? '').trim();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isWithin(childPath, parentPath, pathImpl = path) {
|
|
58
|
+
const relative = pathImpl.relative(parentPath, childPath);
|
|
59
|
+
return relative === '' || (!relative.startsWith('..') && !pathImpl.isAbsolute(relative));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function contentTypeForFile(filePath) {
|
|
63
|
+
return CONTENT_TYPES[path.extname(String(filePath || '')).toLowerCase()]
|
|
64
|
+
|| 'application/octet-stream';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function isDeniedName(fileName) {
|
|
68
|
+
const name = toText(fileName);
|
|
69
|
+
if (!name) return true;
|
|
70
|
+
if (name.startsWith('.')) return true;
|
|
71
|
+
if (DENIED_EXTENSIONS.has(path.extname(name).toLowerCase())) return true;
|
|
72
|
+
return DENIED_NAME_RE.test(name);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Registration-time jail: resolve `dir` (absolute, or relative to the
|
|
77
|
+
* workspace root), realpath it, and require it to live inside the workspace
|
|
78
|
+
* root's realpath. Returns `{ ok, rootDir }` or `{ ok:false, error }` with a
|
|
79
|
+
* message that names exactly what was refused.
|
|
80
|
+
*/
|
|
81
|
+
export function validateStaticRoot(dir, { workspaceRoot, fsImpl = fs, pathImpl = path } = {}) {
|
|
82
|
+
const dirInput = toText(dir);
|
|
83
|
+
if (!dirInput) return { ok: false, error: 'Missing dir' };
|
|
84
|
+
const workspaceInput = toText(workspaceRoot);
|
|
85
|
+
if (!workspaceInput) {
|
|
86
|
+
return { ok: false, error: 'No workspace root is known for this conversation; static previews need one to scope what may be served' };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let workspaceReal;
|
|
90
|
+
try {
|
|
91
|
+
workspaceReal = fsImpl.realpathSync(pathImpl.resolve(workspaceInput));
|
|
92
|
+
} catch {
|
|
93
|
+
return { ok: false, error: `Workspace root does not exist: ${workspaceInput}` };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const resolved = pathImpl.resolve(workspaceReal, dirInput);
|
|
97
|
+
let rootReal;
|
|
98
|
+
try {
|
|
99
|
+
rootReal = fsImpl.realpathSync(resolved);
|
|
100
|
+
} catch {
|
|
101
|
+
return { ok: false, error: `Directory not found: ${dirInput}` };
|
|
102
|
+
}
|
|
103
|
+
let stats;
|
|
104
|
+
try {
|
|
105
|
+
stats = fsImpl.statSync(rootReal);
|
|
106
|
+
} catch {
|
|
107
|
+
return { ok: false, error: `Directory not found: ${dirInput}` };
|
|
108
|
+
}
|
|
109
|
+
if (!stats.isDirectory()) {
|
|
110
|
+
return { ok: false, error: `Not a directory: ${dirInput}` };
|
|
111
|
+
}
|
|
112
|
+
if (!isWithin(rootReal, workspaceReal, pathImpl)) {
|
|
113
|
+
return { ok: false, error: `dir must live inside the workspace root (${workspaceReal})` };
|
|
114
|
+
}
|
|
115
|
+
return { ok: true, rootDir: rootReal };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Request-time resolution inside a registered root. Returns one of:
|
|
120
|
+
* { kind:'file', filePath, contentType, size }
|
|
121
|
+
* { kind:'redirect', location } — directory hit without a trailing slash
|
|
122
|
+
* { kind:'not-found' }
|
|
123
|
+
*/
|
|
124
|
+
export function resolveStaticFile(rootDir, upstreamPath, { fsImpl = fs, pathImpl = path } = {}) {
|
|
125
|
+
const root = toText(rootDir);
|
|
126
|
+
if (!root) return { kind: 'not-found' };
|
|
127
|
+
|
|
128
|
+
const [rawPath] = toText(upstreamPath || '/').split('?');
|
|
129
|
+
let decoded;
|
|
130
|
+
try {
|
|
131
|
+
decoded = decodeURIComponent(rawPath);
|
|
132
|
+
} catch {
|
|
133
|
+
return { kind: 'not-found' };
|
|
134
|
+
}
|
|
135
|
+
// NUL would truncate paths at the fs layer; nothing legitimate contains it.
|
|
136
|
+
if (decoded.includes('\0')) return { kind: 'not-found' };
|
|
137
|
+
|
|
138
|
+
const segments = decoded.split('/').filter(Boolean);
|
|
139
|
+
for (const segment of segments) {
|
|
140
|
+
// Traversal is refused on the raw segments BEFORE any fs resolution, so a
|
|
141
|
+
// `..` can never even be handed to path.join; dotfiles ride the same check.
|
|
142
|
+
if (segment === '..' || segment === '.') return { kind: 'not-found' };
|
|
143
|
+
if (isDeniedName(segment)) return { kind: 'not-found' };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const joined = pathImpl.join(root, ...segments);
|
|
147
|
+
if (!isWithin(joined, root, pathImpl)) return { kind: 'not-found' };
|
|
148
|
+
|
|
149
|
+
// realpath catches symlinks pointing outside the root — including ones
|
|
150
|
+
// created after registration.
|
|
151
|
+
let real;
|
|
152
|
+
try {
|
|
153
|
+
real = fsImpl.realpathSync(joined);
|
|
154
|
+
} catch {
|
|
155
|
+
return { kind: 'not-found' };
|
|
156
|
+
}
|
|
157
|
+
if (!isWithin(real, root, pathImpl)) return { kind: 'not-found' };
|
|
158
|
+
|
|
159
|
+
let stats;
|
|
160
|
+
try {
|
|
161
|
+
stats = fsImpl.statSync(real);
|
|
162
|
+
} catch {
|
|
163
|
+
return { kind: 'not-found' };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (stats.isDirectory()) {
|
|
167
|
+
// A directory URL without the trailing slash would make the browser
|
|
168
|
+
// resolve relative assets against the parent — redirect like the lane
|
|
169
|
+
// does for the bare token path.
|
|
170
|
+
if (segments.length > 0 && !decoded.endsWith('/')) {
|
|
171
|
+
return { kind: 'redirect', location: `${decoded}/` };
|
|
172
|
+
}
|
|
173
|
+
const indexPath = pathImpl.join(real, 'index.html');
|
|
174
|
+
let indexStats;
|
|
175
|
+
try {
|
|
176
|
+
indexStats = fsImpl.statSync(indexPath);
|
|
177
|
+
} catch {
|
|
178
|
+
return { kind: 'not-found' };
|
|
179
|
+
}
|
|
180
|
+
if (!indexStats.isFile()) return { kind: 'not-found' };
|
|
181
|
+
return {
|
|
182
|
+
kind: 'file',
|
|
183
|
+
filePath: indexPath,
|
|
184
|
+
contentType: contentTypeForFile(indexPath),
|
|
185
|
+
size: indexStats.size,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (!stats.isFile()) return { kind: 'not-found' };
|
|
190
|
+
return {
|
|
191
|
+
kind: 'file',
|
|
192
|
+
filePath: real,
|
|
193
|
+
contentType: contentTypeForFile(real),
|
|
194
|
+
size: stats.size,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Streams a resolved static request onto an http response. `sendNotFound` is
|
|
200
|
+
* the lane's shared 404 page so static and proxy misses look identical.
|
|
201
|
+
*/
|
|
202
|
+
export function serveStaticPreview(entry, upstreamPath, req, res, {
|
|
203
|
+
fsImpl = fs,
|
|
204
|
+
pathImpl = path,
|
|
205
|
+
sendNotFound = () => {},
|
|
206
|
+
} = {}) {
|
|
207
|
+
const resolved = resolveStaticFile(entry.rootDir, upstreamPath, { fsImpl, pathImpl });
|
|
208
|
+
|
|
209
|
+
if (resolved.kind === 'redirect') {
|
|
210
|
+
const basePrefix = entry.basePath.replace(/\/$/, '');
|
|
211
|
+
res.writeHead(301, { location: `${basePrefix}${resolved.location}`, 'cache-control': 'no-store' });
|
|
212
|
+
res.end();
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (resolved.kind !== 'file') {
|
|
216
|
+
sendNotFound(res);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const headers = {
|
|
221
|
+
'content-type': resolved.contentType,
|
|
222
|
+
'content-length': resolved.size,
|
|
223
|
+
// Static previews exist for iteration; a cached stale index defeats them.
|
|
224
|
+
'cache-control': 'no-store',
|
|
225
|
+
'x-robots-tag': 'noindex, nofollow, noarchive',
|
|
226
|
+
};
|
|
227
|
+
if (req.method === 'HEAD') {
|
|
228
|
+
res.writeHead(200, headers);
|
|
229
|
+
res.end();
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
res.writeHead(200, headers);
|
|
233
|
+
const stream = fsImpl.createReadStream(resolved.filePath);
|
|
234
|
+
stream.on('error', () => { try { res.destroy(); } catch {} });
|
|
235
|
+
res.on('close', () => { try { stream.destroy(); } catch {} });
|
|
236
|
+
stream.pipe(res);
|
|
237
|
+
}
|