@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,552 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import { execFileSync, spawn } from 'child_process';
|
|
4
|
+
import {
|
|
5
|
+
isTmuxAvailable,
|
|
6
|
+
normalizeTmuxSessionName,
|
|
7
|
+
tmuxSessionExists,
|
|
8
|
+
} from './session-worker-launch-service.mjs';
|
|
9
|
+
|
|
10
|
+
function normalizeSessionId(value) {
|
|
11
|
+
const text = String(value || '').trim();
|
|
12
|
+
return text || '';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function normalizeWatcherId(value) {
|
|
16
|
+
const text = String(value || '').trim();
|
|
17
|
+
return text || '';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function normalizeLineCount(value, fallback = 1000) {
|
|
21
|
+
const n = Number(value);
|
|
22
|
+
if (!Number.isFinite(n)) return fallback;
|
|
23
|
+
const whole = Math.trunc(n);
|
|
24
|
+
if (whole < 50) return 50;
|
|
25
|
+
if (whole > 20_000) return 20_000;
|
|
26
|
+
return whole;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function normalizeIntervalMs(value, fallback = 700) {
|
|
30
|
+
const n = Number(value);
|
|
31
|
+
if (!Number.isFinite(n)) return fallback;
|
|
32
|
+
const whole = Math.trunc(n);
|
|
33
|
+
if (whole < 200) return 200;
|
|
34
|
+
if (whole > 5_000) return 5_000;
|
|
35
|
+
return whole;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function normalizeCols(value) {
|
|
39
|
+
const n = Number(value);
|
|
40
|
+
if (!Number.isFinite(n)) return null;
|
|
41
|
+
const whole = Math.trunc(n);
|
|
42
|
+
if (whole < 80 || whole > 400) return null;
|
|
43
|
+
return whole;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function normalizeRows(value) {
|
|
47
|
+
const n = Number(value);
|
|
48
|
+
if (!Number.isFinite(n)) return null;
|
|
49
|
+
const whole = Math.trunc(n);
|
|
50
|
+
if (whole < 24 || whole > 120) return null;
|
|
51
|
+
return whole;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function computeChunk(previousText, nextText) {
|
|
55
|
+
const previous = String(previousText || '');
|
|
56
|
+
const next = String(nextText || '');
|
|
57
|
+
if (next === previous) return null;
|
|
58
|
+
if (!previous) return { kind: 'snapshot', data: next };
|
|
59
|
+
if (next.startsWith(previous)) {
|
|
60
|
+
return { kind: 'delta', data: next.slice(previous.length) };
|
|
61
|
+
}
|
|
62
|
+
return { kind: 'reset', data: next };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function createTmuxCaptureReader({
|
|
66
|
+
execFileSyncImpl = execFileSync,
|
|
67
|
+
} = {}) {
|
|
68
|
+
return function readCapture(sessionName, {
|
|
69
|
+
historyLines = 1000,
|
|
70
|
+
} = {}) {
|
|
71
|
+
const output = execFileSyncImpl('tmux', [
|
|
72
|
+
'capture-pane',
|
|
73
|
+
'-p',
|
|
74
|
+
'-e',
|
|
75
|
+
'-J',
|
|
76
|
+
'-S',
|
|
77
|
+
`-${normalizeLineCount(historyLines, 1000)}`,
|
|
78
|
+
'-t',
|
|
79
|
+
sessionName,
|
|
80
|
+
], {
|
|
81
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
82
|
+
});
|
|
83
|
+
return String(output || '');
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function createTmuxInspectorStreamService({
|
|
88
|
+
platform = process.platform,
|
|
89
|
+
execFileSyncImpl = execFileSync,
|
|
90
|
+
spawnImpl = spawn,
|
|
91
|
+
tmuxAvailable = null,
|
|
92
|
+
historyLines = 1200,
|
|
93
|
+
pollIntervalMs = 700,
|
|
94
|
+
watchdogIntervalMs = 1000,
|
|
95
|
+
preferByteStream = platform !== 'win32',
|
|
96
|
+
isSessionAllowed = () => ({ ok: true }),
|
|
97
|
+
nowIso = () => new Date().toISOString(),
|
|
98
|
+
} = {}) {
|
|
99
|
+
const readCapture = createTmuxCaptureReader({ execFileSyncImpl });
|
|
100
|
+
const watchersBySession = new Map();
|
|
101
|
+
const sessionByWatcher = new Map();
|
|
102
|
+
const streamBySession = new Map();
|
|
103
|
+
const lines = normalizeLineCount(historyLines, 1200);
|
|
104
|
+
const intervalMs = normalizeIntervalMs(pollIntervalMs, 700);
|
|
105
|
+
const watchdogMs = normalizeIntervalMs(watchdogIntervalMs, 1000);
|
|
106
|
+
|
|
107
|
+
const tmuxReady = tmuxAvailable === null
|
|
108
|
+
? isTmuxAvailable({ platform, execFileSyncImpl })
|
|
109
|
+
: (tmuxAvailable === true);
|
|
110
|
+
|
|
111
|
+
function emitStatusToSession(sdkSessionId, payload = {}) {
|
|
112
|
+
const sid = normalizeSessionId(sdkSessionId);
|
|
113
|
+
if (!sid) return;
|
|
114
|
+
const watcherMap = watchersBySession.get(sid);
|
|
115
|
+
if (!watcherMap || watcherMap.size === 0) return;
|
|
116
|
+
for (const watcher of watcherMap.values()) {
|
|
117
|
+
try {
|
|
118
|
+
watcher?.onStatus?.({
|
|
119
|
+
type: 'tmux_inspector_status',
|
|
120
|
+
sdkSessionId: sid,
|
|
121
|
+
timestamp: nowIso(),
|
|
122
|
+
...payload,
|
|
123
|
+
});
|
|
124
|
+
} catch {
|
|
125
|
+
// watcher callbacks are isolated
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function emitChunkToSession(sdkSessionId, payload = {}) {
|
|
131
|
+
const sid = normalizeSessionId(sdkSessionId);
|
|
132
|
+
if (!sid) return;
|
|
133
|
+
const watcherMap = watchersBySession.get(sid);
|
|
134
|
+
if (!watcherMap || watcherMap.size === 0) return;
|
|
135
|
+
for (const watcher of watcherMap.values()) {
|
|
136
|
+
try {
|
|
137
|
+
watcher?.onChunk?.({
|
|
138
|
+
type: 'tmux_inspector_chunk',
|
|
139
|
+
sdkSessionId: sid,
|
|
140
|
+
timestamp: nowIso(),
|
|
141
|
+
...payload,
|
|
142
|
+
});
|
|
143
|
+
} catch {
|
|
144
|
+
// watcher callbacks are isolated
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function clearStream(sdkSessionId) {
|
|
150
|
+
const sid = normalizeSessionId(sdkSessionId);
|
|
151
|
+
if (!sid) return;
|
|
152
|
+
const stream = streamBySession.get(sid);
|
|
153
|
+
if (stream?.watchdogTimer) {
|
|
154
|
+
clearInterval(stream.watchdogTimer);
|
|
155
|
+
}
|
|
156
|
+
if (stream?.timer) {
|
|
157
|
+
clearInterval(stream.timer);
|
|
158
|
+
}
|
|
159
|
+
if (stream?.process && typeof stream.process.kill === 'function') {
|
|
160
|
+
stream.closing = true;
|
|
161
|
+
try {
|
|
162
|
+
stream.process.kill('SIGTERM');
|
|
163
|
+
} catch {}
|
|
164
|
+
}
|
|
165
|
+
streamBySession.delete(sid);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function stopWatchingSession(sdkSessionId, { reason = 'watch-stopped', code = null } = {}) {
|
|
169
|
+
const sid = normalizeSessionId(sdkSessionId);
|
|
170
|
+
if (!sid) return;
|
|
171
|
+
clearStream(sid);
|
|
172
|
+
const watcherMap = watchersBySession.get(sid);
|
|
173
|
+
if (!watcherMap || watcherMap.size === 0) return;
|
|
174
|
+
emitStatusToSession(sid, {
|
|
175
|
+
state: 'ended',
|
|
176
|
+
reason,
|
|
177
|
+
code,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function pollSession(sdkSessionId) {
|
|
182
|
+
const sid = normalizeSessionId(sdkSessionId);
|
|
183
|
+
if (!sid) return;
|
|
184
|
+
const watcherMap = watchersBySession.get(sid);
|
|
185
|
+
if (!watcherMap || watcherMap.size === 0) {
|
|
186
|
+
clearStream(sid);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const allowed = isSessionAllowed(sid);
|
|
191
|
+
if (!allowed?.ok) {
|
|
192
|
+
stopWatchingSession(sid, {
|
|
193
|
+
reason: allowed?.reason || 'session-not-allowed',
|
|
194
|
+
code: allowed?.code || 'session-not-allowed',
|
|
195
|
+
});
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const sessionName = normalizeTmuxSessionName(sid);
|
|
200
|
+
if (!tmuxSessionExists(sessionName, { execFileSyncImpl })) {
|
|
201
|
+
stopWatchingSession(sid, {
|
|
202
|
+
reason: 'tmux-session-not-found',
|
|
203
|
+
code: 'tmux-session-not-found',
|
|
204
|
+
});
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const stream = streamBySession.get(sid);
|
|
209
|
+
if (!stream) return;
|
|
210
|
+
|
|
211
|
+
let nextSnapshot = '';
|
|
212
|
+
try {
|
|
213
|
+
nextSnapshot = readCapture(sessionName, { historyLines: lines });
|
|
214
|
+
} catch {
|
|
215
|
+
stopWatchingSession(sid, {
|
|
216
|
+
reason: 'tmux-capture-failed',
|
|
217
|
+
code: 'tmux-capture-failed',
|
|
218
|
+
});
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const chunk = computeChunk(stream.lastSnapshot, nextSnapshot);
|
|
223
|
+
stream.lastSnapshot = nextSnapshot;
|
|
224
|
+
if (!chunk?.data) return;
|
|
225
|
+
emitChunkToSession(sid, {
|
|
226
|
+
chunkKind: chunk.kind,
|
|
227
|
+
data: chunk.data,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function watchSessionHealth(sdkSessionId) {
|
|
232
|
+
const sid = normalizeSessionId(sdkSessionId);
|
|
233
|
+
if (!sid) return false;
|
|
234
|
+
const watcherMap = watchersBySession.get(sid);
|
|
235
|
+
if (!watcherMap || watcherMap.size === 0) {
|
|
236
|
+
clearStream(sid);
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
const allowed = isSessionAllowed(sid);
|
|
240
|
+
if (!allowed?.ok) {
|
|
241
|
+
stopWatchingSession(sid, {
|
|
242
|
+
reason: allowed?.reason || 'session-not-allowed',
|
|
243
|
+
code: allowed?.code || 'session-not-allowed',
|
|
244
|
+
});
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
const sessionName = normalizeTmuxSessionName(sid);
|
|
248
|
+
if (!tmuxSessionExists(sessionName, { execFileSyncImpl })) {
|
|
249
|
+
stopWatchingSession(sid, {
|
|
250
|
+
reason: 'tmux-session-not-found',
|
|
251
|
+
code: 'tmux-session-not-found',
|
|
252
|
+
});
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
return true;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function ensureWatchdog(sdkSessionId) {
|
|
259
|
+
const sid = normalizeSessionId(sdkSessionId);
|
|
260
|
+
if (!sid) return;
|
|
261
|
+
const stream = streamBySession.get(sid);
|
|
262
|
+
if (!stream || stream.watchdogTimer) return;
|
|
263
|
+
const watchdogTimer = setInterval(() => {
|
|
264
|
+
watchSessionHealth(sid);
|
|
265
|
+
}, watchdogMs);
|
|
266
|
+
if (typeof watchdogTimer.unref === 'function') watchdogTimer.unref();
|
|
267
|
+
stream.watchdogTimer = watchdogTimer;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function ensureSessionByteStream(sdkSessionId, sessionName) {
|
|
271
|
+
const sid = normalizeSessionId(sdkSessionId);
|
|
272
|
+
if (!sid) return false;
|
|
273
|
+
if (streamBySession.has(sid)) return true;
|
|
274
|
+
if (!preferByteStream || platform === 'win32') return false;
|
|
275
|
+
const child = spawnImpl('script', [
|
|
276
|
+
'-q',
|
|
277
|
+
'-f',
|
|
278
|
+
'/dev/null',
|
|
279
|
+
'tmux',
|
|
280
|
+
'attach-session',
|
|
281
|
+
'-r',
|
|
282
|
+
'-t',
|
|
283
|
+
sessionName,
|
|
284
|
+
], {
|
|
285
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
286
|
+
});
|
|
287
|
+
const stream = {
|
|
288
|
+
mode: 'byte',
|
|
289
|
+
process: child,
|
|
290
|
+
closing: false,
|
|
291
|
+
watchdogTimer: null,
|
|
292
|
+
};
|
|
293
|
+
streamBySession.set(sid, stream);
|
|
294
|
+
ensureWatchdog(sid);
|
|
295
|
+
|
|
296
|
+
if (child.stdout) {
|
|
297
|
+
child.stdout.on('data', (chunk) => {
|
|
298
|
+
const data = String(chunk || '');
|
|
299
|
+
if (!data) return;
|
|
300
|
+
emitChunkToSession(sid, {
|
|
301
|
+
chunkKind: 'byte',
|
|
302
|
+
data,
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const onByteStreamEnded = () => {
|
|
308
|
+
const current = streamBySession.get(sid);
|
|
309
|
+
if (!current || current !== stream) return;
|
|
310
|
+
const hadWatchers = (watchersBySession.get(sid)?.size || 0) > 0;
|
|
311
|
+
clearStream(sid);
|
|
312
|
+
if (!hadWatchers) return;
|
|
313
|
+
const sessionStillHealthy = watchSessionHealth(sid);
|
|
314
|
+
if (!sessionStillHealthy) return;
|
|
315
|
+
// Byte stream failed while session remains healthy; degrade to snapshot polling.
|
|
316
|
+
ensureSessionPolling(sid);
|
|
317
|
+
emitStatusToSession(sid, {
|
|
318
|
+
state: 'stream-fallback',
|
|
319
|
+
reason: 'byte-stream-ended-falling-back-to-snapshot',
|
|
320
|
+
code: 'byte-stream-ended',
|
|
321
|
+
});
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
child.on('error', onByteStreamEnded);
|
|
325
|
+
child.on('exit', () => {
|
|
326
|
+
if (stream.closing) return;
|
|
327
|
+
onByteStreamEnded();
|
|
328
|
+
});
|
|
329
|
+
return true;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function ensureSessionPolling(sdkSessionId) {
|
|
333
|
+
const sid = normalizeSessionId(sdkSessionId);
|
|
334
|
+
if (!sid) return;
|
|
335
|
+
if (streamBySession.has(sid)) return;
|
|
336
|
+
const timer = setInterval(() => pollSession(sid), intervalMs);
|
|
337
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
338
|
+
streamBySession.set(sid, {
|
|
339
|
+
mode: 'snapshot',
|
|
340
|
+
timer,
|
|
341
|
+
lastSnapshot: '',
|
|
342
|
+
watchdogTimer: null,
|
|
343
|
+
});
|
|
344
|
+
ensureWatchdog(sid);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function attach({
|
|
348
|
+
sdkSessionId,
|
|
349
|
+
watcherId,
|
|
350
|
+
onChunk = () => {},
|
|
351
|
+
onStatus = () => {},
|
|
352
|
+
} = {}) {
|
|
353
|
+
const sid = normalizeSessionId(sdkSessionId);
|
|
354
|
+
const wid = normalizeWatcherId(watcherId);
|
|
355
|
+
if (!sid) return { ok: false, code: 'missing-session-id', reason: 'Missing sdkSessionId' };
|
|
356
|
+
if (!wid) return { ok: false, code: 'missing-watcher-id', reason: 'Missing watcher id' };
|
|
357
|
+
if (!tmuxReady) {
|
|
358
|
+
return { ok: false, code: 'tmux-unavailable', reason: 'tmux is unavailable on this relay host' };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const allowed = isSessionAllowed(sid);
|
|
362
|
+
if (!allowed?.ok) {
|
|
363
|
+
return {
|
|
364
|
+
ok: false,
|
|
365
|
+
code: allowed?.code || 'session-not-allowed',
|
|
366
|
+
reason: allowed?.reason || 'Session is not active in the relay',
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const sessionName = normalizeTmuxSessionName(sid);
|
|
371
|
+
if (!tmuxSessionExists(sessionName, { execFileSyncImpl })) {
|
|
372
|
+
return {
|
|
373
|
+
ok: false,
|
|
374
|
+
code: 'tmux-session-not-found',
|
|
375
|
+
reason: `tmux session "${sid}" was not found`,
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
let snapshot = '';
|
|
380
|
+
try {
|
|
381
|
+
snapshot = readCapture(sessionName, { historyLines: lines });
|
|
382
|
+
} catch (error) {
|
|
383
|
+
return {
|
|
384
|
+
ok: false,
|
|
385
|
+
code: 'tmux-capture-failed',
|
|
386
|
+
reason: String(error?.message || 'Failed to capture tmux pane'),
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const existingSessionId = sessionByWatcher.get(wid);
|
|
391
|
+
if (existingSessionId && existingSessionId !== sid) {
|
|
392
|
+
detach({ sdkSessionId: existingSessionId, watcherId: wid });
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
let watcherMap = watchersBySession.get(sid);
|
|
396
|
+
if (!watcherMap) {
|
|
397
|
+
watcherMap = new Map();
|
|
398
|
+
watchersBySession.set(sid, watcherMap);
|
|
399
|
+
}
|
|
400
|
+
watcherMap.set(wid, { onChunk, onStatus });
|
|
401
|
+
sessionByWatcher.set(wid, sid);
|
|
402
|
+
if (!ensureSessionByteStream(sid, sessionName)) {
|
|
403
|
+
ensureSessionPolling(sid);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const stream = streamBySession.get(sid);
|
|
407
|
+
if (stream?.mode === 'snapshot') {
|
|
408
|
+
stream.lastSnapshot = snapshot;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return {
|
|
412
|
+
ok: true,
|
|
413
|
+
sdkSessionId: sid,
|
|
414
|
+
snapshot,
|
|
415
|
+
watcherCount: watcherMap.size,
|
|
416
|
+
code: 'attached',
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function detach({
|
|
421
|
+
sdkSessionId,
|
|
422
|
+
watcherId,
|
|
423
|
+
} = {}) {
|
|
424
|
+
const sid = normalizeSessionId(sdkSessionId);
|
|
425
|
+
const wid = normalizeWatcherId(watcherId);
|
|
426
|
+
if (!sid || !wid) return { ok: false, removed: false };
|
|
427
|
+
const watcherMap = watchersBySession.get(sid);
|
|
428
|
+
if (!watcherMap) return { ok: true, removed: false };
|
|
429
|
+
const removed = watcherMap.delete(wid);
|
|
430
|
+
if (sessionByWatcher.get(wid) === sid) {
|
|
431
|
+
sessionByWatcher.delete(wid);
|
|
432
|
+
}
|
|
433
|
+
if (watcherMap.size === 0) {
|
|
434
|
+
watchersBySession.delete(sid);
|
|
435
|
+
clearStream(sid);
|
|
436
|
+
}
|
|
437
|
+
return {
|
|
438
|
+
ok: true,
|
|
439
|
+
removed,
|
|
440
|
+
watcherCount: watcherMap.size,
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function detachWatcher(watcherId) {
|
|
445
|
+
const wid = normalizeWatcherId(watcherId);
|
|
446
|
+
if (!wid) return 0;
|
|
447
|
+
const sid = sessionByWatcher.get(wid);
|
|
448
|
+
if (!sid) return 0;
|
|
449
|
+
const result = detach({ sdkSessionId: sid, watcherId: wid });
|
|
450
|
+
return result?.removed ? 1 : 0;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function isWatcherAttached({ sdkSessionId, watcherId } = {}) {
|
|
454
|
+
const sid = normalizeSessionId(sdkSessionId);
|
|
455
|
+
const wid = normalizeWatcherId(watcherId);
|
|
456
|
+
if (!sid || !wid) return false;
|
|
457
|
+
const watcherMap = watchersBySession.get(sid);
|
|
458
|
+
return watcherMap instanceof Map && watcherMap.has(wid);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function resizeSession({
|
|
462
|
+
sdkSessionId,
|
|
463
|
+
watcherId,
|
|
464
|
+
cols,
|
|
465
|
+
rows,
|
|
466
|
+
} = {}) {
|
|
467
|
+
const sid = normalizeSessionId(sdkSessionId);
|
|
468
|
+
const wid = normalizeWatcherId(watcherId);
|
|
469
|
+
const nextCols = normalizeCols(cols);
|
|
470
|
+
const nextRows = normalizeRows(rows);
|
|
471
|
+
if (!sid) return { ok: false, code: 'missing-session-id', reason: 'Missing sdkSessionId' };
|
|
472
|
+
if (!wid) return { ok: false, code: 'missing-watcher-id', reason: 'Missing watcher id' };
|
|
473
|
+
if (!nextCols || !nextRows) {
|
|
474
|
+
return { ok: false, code: 'invalid-size', reason: 'Invalid terminal size' };
|
|
475
|
+
}
|
|
476
|
+
if (!isWatcherAttached({ sdkSessionId: sid, watcherId: wid })) {
|
|
477
|
+
return { ok: false, code: 'not-attached', reason: 'Watcher is not attached to session' };
|
|
478
|
+
}
|
|
479
|
+
if (!tmuxReady) {
|
|
480
|
+
return { ok: false, code: 'tmux-unavailable', reason: 'tmux unavailable' };
|
|
481
|
+
}
|
|
482
|
+
const sessionName = normalizeTmuxSessionName(sid);
|
|
483
|
+
if (!tmuxSessionExists(sessionName, { execFileSyncImpl })) {
|
|
484
|
+
return { ok: false, code: 'tmux-session-not-found', reason: 'tmux session not found' };
|
|
485
|
+
}
|
|
486
|
+
try {
|
|
487
|
+
execFileSyncImpl('tmux', [
|
|
488
|
+
'resize-window',
|
|
489
|
+
'-t',
|
|
490
|
+
sessionName,
|
|
491
|
+
'-x',
|
|
492
|
+
String(nextCols),
|
|
493
|
+
'-y',
|
|
494
|
+
String(nextRows),
|
|
495
|
+
], {
|
|
496
|
+
stdio: ['ignore', 'ignore', 'ignore'],
|
|
497
|
+
});
|
|
498
|
+
return {
|
|
499
|
+
ok: true,
|
|
500
|
+
code: 'resized',
|
|
501
|
+
cols: nextCols,
|
|
502
|
+
rows: nextRows,
|
|
503
|
+
};
|
|
504
|
+
} catch (error) {
|
|
505
|
+
return {
|
|
506
|
+
ok: false,
|
|
507
|
+
code: 'tmux-resize-failed',
|
|
508
|
+
reason: String(error?.message || 'tmux resize failed'),
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function stopAll() {
|
|
514
|
+
for (const sid of streamBySession.keys()) {
|
|
515
|
+
clearStream(sid);
|
|
516
|
+
}
|
|
517
|
+
streamBySession.clear();
|
|
518
|
+
watchersBySession.clear();
|
|
519
|
+
sessionByWatcher.clear();
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function status() {
|
|
523
|
+
const sessions = [];
|
|
524
|
+
let watcherCount = 0;
|
|
525
|
+
for (const [sdkSessionId, watchers] of watchersBySession.entries()) {
|
|
526
|
+
const count = watchers.size;
|
|
527
|
+
watcherCount += count;
|
|
528
|
+
sessions.push({
|
|
529
|
+
sdkSessionId,
|
|
530
|
+
watcherCount: count,
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
return {
|
|
534
|
+
enabled: tmuxReady,
|
|
535
|
+
watchedSessionCount: sessions.length,
|
|
536
|
+
watcherCount,
|
|
537
|
+
sessions,
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
return {
|
|
542
|
+
attach,
|
|
543
|
+
detach,
|
|
544
|
+
detachWatcher,
|
|
545
|
+
isWatcherAttached,
|
|
546
|
+
resizeSession,
|
|
547
|
+
stopWatchingSession,
|
|
548
|
+
pollNow: (sdkSessionId) => pollSession(sdkSessionId),
|
|
549
|
+
stopAll,
|
|
550
|
+
status,
|
|
551
|
+
};
|
|
552
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import { URL } from 'url';
|
|
4
|
+
|
|
5
|
+
import { buildAcceptedWorkerPaths } from './session-worker-websocket-service.mjs';
|
|
6
|
+
|
|
7
|
+
// Cloudflare's edge stamps `cf-ray` on every proxied request and a remote client
|
|
8
|
+
// cannot strip it, so its presence means the request came in over the tunnel
|
|
9
|
+
// rather than from a local session worker on 127.0.0.1.
|
|
10
|
+
const DEFAULT_TUNNEL_MARKER_HEADERS = ['cf-ray'];
|
|
11
|
+
|
|
12
|
+
function normalizeHeaderName(value) {
|
|
13
|
+
return String(value || '').trim().toLowerCase();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function normalizeMarkerHeaders(extraHeaders) {
|
|
17
|
+
const names = [...DEFAULT_TUNNEL_MARKER_HEADERS];
|
|
18
|
+
const extras = Array.isArray(extraHeaders)
|
|
19
|
+
? extraHeaders
|
|
20
|
+
: String(extraHeaders || '').split(',');
|
|
21
|
+
for (const entry of extras) {
|
|
22
|
+
const name = normalizeHeaderName(entry);
|
|
23
|
+
if (name && !names.includes(name)) names.push(name);
|
|
24
|
+
}
|
|
25
|
+
return names;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function requestPathname(req) {
|
|
29
|
+
const rawUrl = String(req?.url || '/');
|
|
30
|
+
try {
|
|
31
|
+
return new URL(rawUrl, 'http://localhost').pathname;
|
|
32
|
+
} catch {
|
|
33
|
+
return rawUrl.split('?')[0] || '/';
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Blocks session-worker WebSocket paths when the request arrived through a
|
|
39
|
+
* public tunnel. The guard keys off *the path being a worker path*, never off
|
|
40
|
+
* "unauthenticated request bearing cf-ray" — shared conversations legitimately
|
|
41
|
+
* arrive over Cloudflare with no credentials and must keep working.
|
|
42
|
+
*/
|
|
43
|
+
export function createTunnelWorkerPathGuard({
|
|
44
|
+
pathPrefix = '',
|
|
45
|
+
extraMarkerHeaders = [],
|
|
46
|
+
logger = console,
|
|
47
|
+
} = {}) {
|
|
48
|
+
const workerPaths = new Set(buildAcceptedWorkerPaths(pathPrefix));
|
|
49
|
+
const markerHeaders = normalizeMarkerHeaders(extraMarkerHeaders);
|
|
50
|
+
|
|
51
|
+
function isWorkerPath(pathname) {
|
|
52
|
+
if (workerPaths.has(pathname)) return true;
|
|
53
|
+
// The prefix is stripped later in the express pipeline, so tolerate both forms.
|
|
54
|
+
for (const workerPath of workerPaths) {
|
|
55
|
+
if (pathname === workerPath || pathname === `${workerPath}/`) return true;
|
|
56
|
+
}
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function matchedMarkerHeader(headers) {
|
|
61
|
+
if (!headers) return null;
|
|
62
|
+
for (const name of markerHeaders) {
|
|
63
|
+
const value = headers[name];
|
|
64
|
+
if (typeof value === 'string' ? value.trim() : value) return name;
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function evaluate(req) {
|
|
70
|
+
const pathname = requestPathname(req);
|
|
71
|
+
if (!isWorkerPath(pathname)) return { blocked: false, pathname, marker: null };
|
|
72
|
+
const marker = matchedMarkerHeader(req?.headers);
|
|
73
|
+
if (!marker) return { blocked: false, pathname, marker: null };
|
|
74
|
+
return { blocked: true, pathname, marker };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Express middleware: 403 the request before any auth middleware runs.
|
|
78
|
+
function requestMiddleware(req, res, next) {
|
|
79
|
+
const result = evaluate(req);
|
|
80
|
+
if (!result.blocked) {
|
|
81
|
+
next();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
logger?.warn?.(`[tunnel-guard] Blocked session-worker path ${result.pathname} carrying ${result.marker}`);
|
|
85
|
+
res.status(403).json({ error: 'Session worker endpoints are not reachable through the tunnel' });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Raw upgrade guard: destroy the socket before the WebSocket handshake.
|
|
89
|
+
function handleUpgrade(req, socket) {
|
|
90
|
+
const result = evaluate(req);
|
|
91
|
+
if (!result.blocked) return false;
|
|
92
|
+
logger?.warn?.(`[tunnel-guard] Blocked session-worker upgrade ${result.pathname} carrying ${result.marker}`);
|
|
93
|
+
try {
|
|
94
|
+
socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
|
|
95
|
+
} catch {}
|
|
96
|
+
try { socket.destroy(); } catch {}
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
markerHeaders,
|
|
102
|
+
workerPaths: [...workerPaths],
|
|
103
|
+
evaluate,
|
|
104
|
+
requestMiddleware,
|
|
105
|
+
handleUpgrade,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Reclaims upload blobs that no reference will ever point at.
|
|
2
|
+
//
|
|
3
|
+
// Uploads are eager, so a blob can exist before anything references it: pasting
|
|
4
|
+
// into a conversation that is then abandoned, or switching conversations while
|
|
5
|
+
// an upload is in flight. Conversation-scoped sweeps only ever visit blobs that
|
|
6
|
+
// already have a reference, so these would otherwise stay on disk forever.
|
|
7
|
+
|
|
8
|
+
export const UNREFERENCED_UPLOAD_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
9
|
+
|
|
10
|
+
const SHA256_PATTERN = /^[a-f0-9]{64}$/;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Selects unreferenced blobs old enough that no upload can still be in flight
|
|
14
|
+
* for them. Rows without a usable timestamp are left alone rather than guessed
|
|
15
|
+
* at, since deleting a blob that is still being attached is far worse than
|
|
16
|
+
* keeping a stale one.
|
|
17
|
+
*/
|
|
18
|
+
export function collectUnreferencedUploads(rows = [], {
|
|
19
|
+
now = Date.now(),
|
|
20
|
+
maxAgeMs = UNREFERENCED_UPLOAD_MAX_AGE_MS,
|
|
21
|
+
} = {}) {
|
|
22
|
+
const cutoff = now - Math.max(0, Number(maxAgeMs) || 0);
|
|
23
|
+
const collected = [];
|
|
24
|
+
for (const row of (Array.isArray(rows) ? rows : [])) {
|
|
25
|
+
const sha256 = String(row?.sha256 || '').trim().toLowerCase();
|
|
26
|
+
if (!SHA256_PATTERN.test(sha256)) continue;
|
|
27
|
+
const createdAt = Date.parse(String(row?.created_at || ''));
|
|
28
|
+
if (!Number.isFinite(createdAt)) continue;
|
|
29
|
+
if (createdAt >= cutoff) continue;
|
|
30
|
+
collected.push(sha256);
|
|
31
|
+
}
|
|
32
|
+
return collected;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const UNREFERENCED_UPLOADS_QUERY = `
|
|
36
|
+
SELECT f.sha256, f.created_at
|
|
37
|
+
FROM uploaded_files f
|
|
38
|
+
LEFT JOIN upload_refs r ON r.file_sha256 = f.sha256
|
|
39
|
+
WHERE r.id IS NULL
|
|
40
|
+
`;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Runs one sweep. Never throws: a failed reclaim must not take the relay down.
|
|
44
|
+
*/
|
|
45
|
+
export function sweepUnreferencedUploads({
|
|
46
|
+
listUnreferenced,
|
|
47
|
+
deleteUploads,
|
|
48
|
+
now = Date.now(),
|
|
49
|
+
maxAgeMs = UNREFERENCED_UPLOAD_MAX_AGE_MS,
|
|
50
|
+
} = {}) {
|
|
51
|
+
try {
|
|
52
|
+
if (typeof listUnreferenced !== 'function' || typeof deleteUploads !== 'function') return 0;
|
|
53
|
+
const stale = collectUnreferencedUploads(listUnreferenced() || [], { now, maxAgeMs });
|
|
54
|
+
if (!stale.length) return 0;
|
|
55
|
+
deleteUploads(stale);
|
|
56
|
+
return stale.length;
|
|
57
|
+
} catch {
|
|
58
|
+
return 0;
|
|
59
|
+
}
|
|
60
|
+
}
|