@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,423 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { spawn } from 'child_process';
|
|
6
|
+
|
|
7
|
+
const MAX_CONSECUTIVE_FAST_RETRIES = 3;
|
|
8
|
+
|
|
9
|
+
function toText(value) {
|
|
10
|
+
return String(value || '').trim();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function parsePositivePort(value) {
|
|
14
|
+
if (typeof value === 'number' && Number.isInteger(value) && value > 0 && value <= 65535) return value;
|
|
15
|
+
if (typeof value !== 'string') return null;
|
|
16
|
+
const num = Number.parseInt(value.trim(), 10);
|
|
17
|
+
if (!Number.isInteger(num) || num <= 0 || num > 65535) return null;
|
|
18
|
+
return num;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function normalizeTunnelMode(raw = {}) {
|
|
22
|
+
const mode = toText(raw.mode).toLowerCase();
|
|
23
|
+
if (mode === 'disabled' || mode === 'managed') return mode;
|
|
24
|
+
if (raw.enabled === true) return 'managed';
|
|
25
|
+
return 'disabled';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function normalizeRemoteBind(rawValue) {
|
|
29
|
+
return toText(rawValue).toLowerCase() === 'public' ? 'public' : 'loopback';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function normalizeIdentityFile(rawValue) {
|
|
33
|
+
const value = toText(rawValue);
|
|
34
|
+
if (!value) return null;
|
|
35
|
+
return value.replace(/^~/, os.homedir());
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function normalizeSshTunnelConfig(rawConfig = {}, {
|
|
39
|
+
defaultCommand = 'ssh',
|
|
40
|
+
configBaseDir = process.cwd(),
|
|
41
|
+
pathImpl = path,
|
|
42
|
+
} = {}) {
|
|
43
|
+
const raw = rawConfig && typeof rawConfig === 'object' ? rawConfig : {};
|
|
44
|
+
const mode = normalizeTunnelMode(raw);
|
|
45
|
+
const required = raw.required === true;
|
|
46
|
+
const user = toText(raw.user);
|
|
47
|
+
const host = toText(raw.host);
|
|
48
|
+
const remotePort = parsePositivePort(raw.remotePort);
|
|
49
|
+
const remoteBindMode = normalizeRemoteBind(raw.remoteBind);
|
|
50
|
+
const autoReclaimPort = raw.autoReclaimPort !== false;
|
|
51
|
+
const reclaimStaleSshSessions = raw.reclaimStaleSshSessions === true;
|
|
52
|
+
const remoteCleanupCommand = toText(raw.remoteCleanupCommand);
|
|
53
|
+
const identityFile = normalizeIdentityFile(raw.identityFile);
|
|
54
|
+
const commandInput = toText(raw.command || defaultCommand);
|
|
55
|
+
const command = commandInput.includes('/') || commandInput.includes('\\')
|
|
56
|
+
? pathImpl.resolve(configBaseDir, commandInput)
|
|
57
|
+
: commandInput;
|
|
58
|
+
|
|
59
|
+
const errors = [];
|
|
60
|
+
if (!command) errors.push('sshTunnel.command is required');
|
|
61
|
+
if (mode === 'managed') {
|
|
62
|
+
if (!user) errors.push('sshTunnel.user is required when sshTunnel.mode is "managed"');
|
|
63
|
+
if (!host) errors.push('sshTunnel.host is required when sshTunnel.mode is "managed"');
|
|
64
|
+
if (!remotePort) errors.push('sshTunnel.remotePort must be a positive integer when sshTunnel.mode is "managed"');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
mode,
|
|
69
|
+
enabled: mode === 'managed',
|
|
70
|
+
valid: errors.length === 0,
|
|
71
|
+
errors,
|
|
72
|
+
required,
|
|
73
|
+
user,
|
|
74
|
+
host,
|
|
75
|
+
remotePort,
|
|
76
|
+
remoteBindMode,
|
|
77
|
+
autoReclaimPort,
|
|
78
|
+
reclaimStaleSshSessions,
|
|
79
|
+
remoteCleanupCommand,
|
|
80
|
+
identityFile,
|
|
81
|
+
command,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function buildTunnelRemoteForwardSpec({ remoteBindMode, remotePort, localPort }) {
|
|
86
|
+
const localTargetHost = '127.0.0.1';
|
|
87
|
+
if (remoteBindMode === 'public') return `*:${remotePort}:${localTargetHost}:${localPort}`;
|
|
88
|
+
return `${remotePort}:${localTargetHost}:${localPort}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function buildTunnelSpawnOptions(platform, stdio) {
|
|
92
|
+
if (platform === 'win32') {
|
|
93
|
+
return { stdio, windowsHide: true };
|
|
94
|
+
}
|
|
95
|
+
return { stdio };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function buildTunnelCommandArgs(tunnelConfig, localPort) {
|
|
99
|
+
const args = [
|
|
100
|
+
'-N',
|
|
101
|
+
'-o', 'ServerAliveInterval=30',
|
|
102
|
+
'-o', 'ServerAliveCountMax=3',
|
|
103
|
+
'-o', 'StrictHostKeyChecking=accept-new',
|
|
104
|
+
'-o', 'BatchMode=yes',
|
|
105
|
+
'-o', 'ExitOnForwardFailure=yes',
|
|
106
|
+
'-R', buildTunnelRemoteForwardSpec({
|
|
107
|
+
remoteBindMode: tunnelConfig.remoteBindMode,
|
|
108
|
+
remotePort: tunnelConfig.remotePort,
|
|
109
|
+
localPort,
|
|
110
|
+
}),
|
|
111
|
+
];
|
|
112
|
+
if (tunnelConfig.identityFile) args.push('-i', tunnelConfig.identityFile);
|
|
113
|
+
args.push(`${tunnelConfig.user}@${tunnelConfig.host}`);
|
|
114
|
+
return args;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function buildCleanupCommand(tunnelConfig) {
|
|
118
|
+
if (tunnelConfig.remoteCleanupCommand) return tunnelConfig.remoteCleanupCommand;
|
|
119
|
+
const port = tunnelConfig.remotePort;
|
|
120
|
+
// A stale `ssh -R` listener is owned by an sshd session that has dropped
|
|
121
|
+
// privileges, which sets dumpable=0 and leaves /proc/<pid>/fd owned by root.
|
|
122
|
+
// lsof and fuser therefore report nothing even though the socket's uid is
|
|
123
|
+
// ours, so the port state has to be read from /proc/net/tcp instead, and the
|
|
124
|
+
// command must exit non-zero when the port is still bound.
|
|
125
|
+
return [
|
|
126
|
+
`PORT=${port};`,
|
|
127
|
+
'port_bound() {',
|
|
128
|
+
' if command -v ss >/dev/null 2>&1; then',
|
|
129
|
+
' ss -ltn 2>/dev/null | grep -qE "[:.]$PORT[[:space:]]";',
|
|
130
|
+
' else',
|
|
131
|
+
' hex=$(printf "%04X" "$PORT");',
|
|
132
|
+
' grep -qiE "^ *[0-9]+: [0-9A-F]+:$hex [0-9A-F]+:[0-9A-F]+ 0A" /proc/net/tcp /proc/net/tcp6 2>/dev/null;',
|
|
133
|
+
' fi;',
|
|
134
|
+
'};',
|
|
135
|
+
'port_bound || exit 0;',
|
|
136
|
+
'if command -v lsof >/dev/null 2>&1; then',
|
|
137
|
+
' pids=$(lsof -tiTCP:$PORT -sTCP:LISTEN 2>/dev/null || true);',
|
|
138
|
+
'elif command -v fuser >/dev/null 2>&1; then',
|
|
139
|
+
' pids=$(fuser -n tcp "$PORT" 2>/dev/null || true);',
|
|
140
|
+
'else',
|
|
141
|
+
' pids="";',
|
|
142
|
+
'fi;',
|
|
143
|
+
'if [ -n "$pids" ]; then kill $pids 2>/dev/null || true; sleep 1; fi;',
|
|
144
|
+
'port_bound || exit 0;',
|
|
145
|
+
...(tunnelConfig.reclaimStaleSshSessions ? [
|
|
146
|
+
// Last resort: the holder is an invisible sshd forward session. Sweep our
|
|
147
|
+
// own childless `@notty` sessions, which is what `ssh -N -R` leaves
|
|
148
|
+
// behind; sessions running a shell, an IDE server or this very command
|
|
149
|
+
// all have children and are skipped.
|
|
150
|
+
'self=$PPID;',
|
|
151
|
+
'for p in $(pgrep -u "$(id -u)" -f "^sshd(-session)?: " 2>/dev/null); do',
|
|
152
|
+
' [ "$p" = "$self" ] && continue;',
|
|
153
|
+
' [ -n "$(pgrep -P "$p" 2>/dev/null)" ] && continue;',
|
|
154
|
+
' kill "$p" 2>/dev/null || true;',
|
|
155
|
+
'done;',
|
|
156
|
+
'sleep 1;',
|
|
157
|
+
'port_bound || exit 0;',
|
|
158
|
+
] : []),
|
|
159
|
+
'exit 3',
|
|
160
|
+
].join(' ');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function shellSingleQuote(value) {
|
|
164
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function createSshTunnelManager({
|
|
168
|
+
tunnelConfig: rawTunnelConfig = {},
|
|
169
|
+
localPort = 3333,
|
|
170
|
+
runtimeLogPrefix = () => '',
|
|
171
|
+
io = null,
|
|
172
|
+
logger = console,
|
|
173
|
+
runtimeShutdownRef = () => false,
|
|
174
|
+
platform = process.platform,
|
|
175
|
+
spawnImpl = spawn,
|
|
176
|
+
nowIso = () => new Date().toISOString(),
|
|
177
|
+
setTimeoutImpl = setTimeout,
|
|
178
|
+
clearTimeoutImpl = clearTimeout,
|
|
179
|
+
configBaseDir = process.cwd(),
|
|
180
|
+
pathImpl = path,
|
|
181
|
+
} = {}) {
|
|
182
|
+
const tunnelConfig = normalizeSshTunnelConfig(rawTunnelConfig, { configBaseDir, pathImpl });
|
|
183
|
+
const log = (msg) => logger.log(`${runtimeLogPrefix()}[ssh-tunnel] ${msg}`);
|
|
184
|
+
const warn = (msg) => logger.warn(`${runtimeLogPrefix()}[ssh-tunnel] ${msg}`);
|
|
185
|
+
|
|
186
|
+
for (const error of tunnelConfig.errors) {
|
|
187
|
+
warn(error);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const state = {
|
|
191
|
+
mode: tunnelConfig.mode,
|
|
192
|
+
enabled: tunnelConfig.enabled && tunnelConfig.valid,
|
|
193
|
+
valid: tunnelConfig.valid,
|
|
194
|
+
required: tunnelConfig.required,
|
|
195
|
+
connected: false,
|
|
196
|
+
host: tunnelConfig.host || null,
|
|
197
|
+
remotePort: tunnelConfig.remotePort || null,
|
|
198
|
+
remoteBindMode: tunnelConfig.remoteBindMode,
|
|
199
|
+
reconnectAttempts: 0,
|
|
200
|
+
fastRetries: 0,
|
|
201
|
+
connectedSince: null,
|
|
202
|
+
blocking: tunnelConfig.required && tunnelConfig.mode === 'managed',
|
|
203
|
+
lastError: tunnelConfig.errors[0] || null,
|
|
204
|
+
lastEventAt: nowIso(),
|
|
205
|
+
proc: null,
|
|
206
|
+
backoffTimer: null,
|
|
207
|
+
cleanupInFlight: false,
|
|
208
|
+
command: tunnelConfig.command || 'ssh',
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const emitStatus = () => {
|
|
212
|
+
state.lastEventAt = nowIso();
|
|
213
|
+
io?.emit?.('ssh_tunnel_status', {
|
|
214
|
+
connected: state.connected,
|
|
215
|
+
host: state.host,
|
|
216
|
+
remotePort: state.remotePort,
|
|
217
|
+
mode: state.mode,
|
|
218
|
+
enabled: state.enabled,
|
|
219
|
+
required: state.required,
|
|
220
|
+
blocking: state.blocking,
|
|
221
|
+
reconnectAttempts: state.reconnectAttempts,
|
|
222
|
+
connectedSince: state.connectedSince,
|
|
223
|
+
lastError: state.lastError,
|
|
224
|
+
});
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const updateBlockingState = () => {
|
|
228
|
+
state.blocking = state.required && state.mode === 'managed' && !state.connected;
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const scheduleReconnect = (spawnTunnel, delayOverrideMs = null) => {
|
|
232
|
+
if (runtimeShutdownRef()) return;
|
|
233
|
+
if (state.backoffTimer) {
|
|
234
|
+
clearTimeoutImpl(state.backoffTimer);
|
|
235
|
+
state.backoffTimer = null;
|
|
236
|
+
}
|
|
237
|
+
const backoffSteps = [5_000, 10_000, 20_000, 40_000, 60_000];
|
|
238
|
+
const computedDelay = backoffSteps[Math.min(state.reconnectAttempts, backoffSteps.length - 1)];
|
|
239
|
+
const delay = Number.isFinite(delayOverrideMs) && delayOverrideMs >= 0
|
|
240
|
+
? Number(delayOverrideMs)
|
|
241
|
+
: computedDelay;
|
|
242
|
+
state.reconnectAttempts += 1;
|
|
243
|
+
log(`Reconnecting in ${delay / 1000}s (attempt ${state.reconnectAttempts})...`);
|
|
244
|
+
state.backoffTimer = setTimeoutImpl(spawnTunnel, delay);
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
const reclaimRemoteTunnelPort = async () => {
|
|
248
|
+
if (!state.enabled || runtimeShutdownRef() || !tunnelConfig.autoReclaimPort) return false;
|
|
249
|
+
if (state.cleanupInFlight) return false;
|
|
250
|
+
state.cleanupInFlight = true;
|
|
251
|
+
const args = [
|
|
252
|
+
'-o', 'StrictHostKeyChecking=accept-new',
|
|
253
|
+
'-o', 'BatchMode=yes',
|
|
254
|
+
];
|
|
255
|
+
if (tunnelConfig.identityFile) args.push('-i', tunnelConfig.identityFile);
|
|
256
|
+
const remoteCleanupCommand = `sh -lc ${shellSingleQuote(buildCleanupCommand(tunnelConfig))}`;
|
|
257
|
+
args.push(`${tunnelConfig.user}@${tunnelConfig.host}`, remoteCleanupCommand);
|
|
258
|
+
|
|
259
|
+
log(`Attempting remote reclaim for listen port ${tunnelConfig.remotePort}...`);
|
|
260
|
+
try {
|
|
261
|
+
const ok = await new Promise((resolve) => {
|
|
262
|
+
const proc = spawnImpl(tunnelConfig.command, args, buildTunnelSpawnOptions(platform, ['ignore', 'pipe', 'pipe']));
|
|
263
|
+
proc.stdout.on('data', (d) => log(`cleanup stdout: ${d.toString().trim()}`));
|
|
264
|
+
proc.stderr.on('data', (d) => log(`cleanup stderr: ${d.toString().trim()}`));
|
|
265
|
+
proc.on('close', (code) => {
|
|
266
|
+
const freed = code === 0;
|
|
267
|
+
log(freed
|
|
268
|
+
? `Remote reclaim finished: listen port ${tunnelConfig.remotePort} is free.`
|
|
269
|
+
: `Remote reclaim finished: listen port ${tunnelConfig.remotePort} is still held (code=${code ?? 'null'}).`);
|
|
270
|
+
resolve(freed);
|
|
271
|
+
});
|
|
272
|
+
proc.on('error', (error) => {
|
|
273
|
+
log(`Remote reclaim error: ${error?.message || error}`);
|
|
274
|
+
resolve(false);
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
return ok;
|
|
278
|
+
} finally {
|
|
279
|
+
state.cleanupInFlight = false;
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
const spawnTunnel = () => {
|
|
284
|
+
if (!state.enabled || runtimeShutdownRef()) return;
|
|
285
|
+
if (state.proc && state.proc.exitCode === null) {
|
|
286
|
+
log('Spawn skipped: existing tunnel process is still running.');
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const args = buildTunnelCommandArgs(tunnelConfig, localPort);
|
|
291
|
+
log(`Spawning: ${tunnelConfig.command} ${args.join(' ')}`);
|
|
292
|
+
const proc = spawnImpl(tunnelConfig.command, args, buildTunnelSpawnOptions(platform, ['ignore', 'pipe', 'pipe']));
|
|
293
|
+
state.proc = proc;
|
|
294
|
+
state.lastError = null;
|
|
295
|
+
updateBlockingState();
|
|
296
|
+
emitStatus();
|
|
297
|
+
|
|
298
|
+
const connectedAt = Date.now();
|
|
299
|
+
let readinessTimer = null;
|
|
300
|
+
let forwardingFailed = false;
|
|
301
|
+
|
|
302
|
+
const markConnected = () => {
|
|
303
|
+
if (state.connected) return;
|
|
304
|
+
state.connected = true;
|
|
305
|
+
state.connectedSince = nowIso();
|
|
306
|
+
state.lastError = null;
|
|
307
|
+
state.fastRetries = 0;
|
|
308
|
+
updateBlockingState();
|
|
309
|
+
log(`Tunnel up to ${tunnelConfig.user}@${tunnelConfig.host} remote port ${tunnelConfig.remotePort}`);
|
|
310
|
+
emitStatus();
|
|
311
|
+
};
|
|
312
|
+
const clearReadinessTimer = () => {
|
|
313
|
+
if (!readinessTimer) return;
|
|
314
|
+
clearTimeoutImpl(readinessTimer);
|
|
315
|
+
readinessTimer = null;
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
proc.stdout.on('data', (d) => log(`stdout: ${d.toString().trim()}`));
|
|
319
|
+
proc.stderr.on('data', (d) => {
|
|
320
|
+
const text = d.toString().trim();
|
|
321
|
+
log(`stderr: ${text}`);
|
|
322
|
+
if (/remote port forwarding failed for listen port/i.test(text)) {
|
|
323
|
+
forwardingFailed = true;
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
proc.on('spawn', () => {
|
|
327
|
+
clearReadinessTimer();
|
|
328
|
+
readinessTimer = setTimeoutImpl(() => {
|
|
329
|
+
readinessTimer = null;
|
|
330
|
+
if (runtimeShutdownRef() || forwardingFailed) return;
|
|
331
|
+
if (state.proc !== proc) return;
|
|
332
|
+
if (proc.exitCode !== null) return;
|
|
333
|
+
markConnected();
|
|
334
|
+
}, 1200);
|
|
335
|
+
if (typeof readinessTimer.unref === 'function') readinessTimer.unref();
|
|
336
|
+
});
|
|
337
|
+
proc.on('error', (error) => {
|
|
338
|
+
clearReadinessTimer();
|
|
339
|
+
state.lastError = error?.message || String(error);
|
|
340
|
+
updateBlockingState();
|
|
341
|
+
log(`Error: ${state.lastError}`);
|
|
342
|
+
emitStatus();
|
|
343
|
+
});
|
|
344
|
+
proc.on('close', (code) => {
|
|
345
|
+
clearReadinessTimer();
|
|
346
|
+
const wasConnected = state.connected;
|
|
347
|
+
state.connected = false;
|
|
348
|
+
state.connectedSince = null;
|
|
349
|
+
state.proc = null;
|
|
350
|
+
if (wasConnected && Date.now() - connectedAt > 30_000) {
|
|
351
|
+
state.reconnectAttempts = 0;
|
|
352
|
+
}
|
|
353
|
+
if (runtimeShutdownRef()) {
|
|
354
|
+
log(`Process exited (code=${code}) during shutdown.`);
|
|
355
|
+
updateBlockingState();
|
|
356
|
+
emitStatus();
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
if (forwardingFailed) {
|
|
360
|
+
state.lastError = `remote-forward-bind-failed:${code ?? 'null'}`;
|
|
361
|
+
log(`Process exited (code=${code}) after remote forward bind failure.`);
|
|
362
|
+
updateBlockingState();
|
|
363
|
+
emitStatus();
|
|
364
|
+
if (tunnelConfig.autoReclaimPort) {
|
|
365
|
+
void reclaimRemoteTunnelPort().then((reclaimed) => {
|
|
366
|
+
// Only retry immediately when the reclaim actually freed the port,
|
|
367
|
+
// and only a few times in a row, so a port we cannot free falls
|
|
368
|
+
// back to exponential backoff instead of respawning ssh every 1s.
|
|
369
|
+
const fastRetry = reclaimed && state.fastRetries < MAX_CONSECUTIVE_FAST_RETRIES;
|
|
370
|
+
state.fastRetries = fastRetry ? state.fastRetries + 1 : 0;
|
|
371
|
+
scheduleReconnect(spawnTunnel, fastRetry ? 1_000 : null);
|
|
372
|
+
});
|
|
373
|
+
} else {
|
|
374
|
+
scheduleReconnect(spawnTunnel);
|
|
375
|
+
}
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
state.lastError = `exit:${code ?? 'null'}`;
|
|
379
|
+
log(`Process exited (code=${code}). Scheduling reconnect...`);
|
|
380
|
+
updateBlockingState();
|
|
381
|
+
emitStatus();
|
|
382
|
+
scheduleReconnect(spawnTunnel);
|
|
383
|
+
});
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
const start = () => {
|
|
387
|
+
if (state.mode === 'disabled') {
|
|
388
|
+
state.lastError = null;
|
|
389
|
+
state.blocking = false;
|
|
390
|
+
emitStatus();
|
|
391
|
+
log('Tunnel mode disabled; running direct relay only.');
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
if (!state.enabled) {
|
|
395
|
+
state.lastError = state.lastError || 'invalid-config';
|
|
396
|
+
updateBlockingState();
|
|
397
|
+
emitStatus();
|
|
398
|
+
log('Managed tunnel mode requested but configuration is invalid; tunnel not started.');
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
log(`SSH tunnel enabled (${state.remoteBindMode}) to ${tunnelConfig.user}@${tunnelConfig.host}:${tunnelConfig.remotePort}`);
|
|
402
|
+
spawnTunnel();
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
const stop = () => {
|
|
406
|
+
if (state.backoffTimer) {
|
|
407
|
+
clearTimeoutImpl(state.backoffTimer);
|
|
408
|
+
state.backoffTimer = null;
|
|
409
|
+
}
|
|
410
|
+
if (state.proc) {
|
|
411
|
+
try { state.proc.kill('SIGTERM'); } catch {}
|
|
412
|
+
state.proc = null;
|
|
413
|
+
}
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
return {
|
|
417
|
+
state,
|
|
418
|
+
config: tunnelConfig,
|
|
419
|
+
start,
|
|
420
|
+
stop,
|
|
421
|
+
emitStatus,
|
|
422
|
+
};
|
|
423
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'crypto';
|
|
2
|
+
|
|
3
|
+
const DEFAULT_MAX_EVENTS = 1_000;
|
|
4
|
+
const DEFAULT_SHARED_ACCESS_DEDUPE_TTL_MS = 60_000;
|
|
5
|
+
const DEFAULT_MAX_SHARED_ACCESS_DEDUPE_KEYS = 4_096;
|
|
6
|
+
|
|
7
|
+
function normalizeTimestamp(value) {
|
|
8
|
+
const timestamp = Number(value);
|
|
9
|
+
return Number.isFinite(timestamp) && timestamp > 0 ? Math.trunc(timestamp) : Date.now();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function normalizeLimit(value, fallback = 40) {
|
|
13
|
+
const limit = Number(value);
|
|
14
|
+
if (!Number.isFinite(limit)) return fallback;
|
|
15
|
+
return Math.max(1, Math.min(100, Math.trunc(limit)));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function formatStatusEventRow(row) {
|
|
19
|
+
let details = {};
|
|
20
|
+
try {
|
|
21
|
+
const parsed = JSON.parse(String(row?.payload_json || '{}'));
|
|
22
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) details = parsed;
|
|
23
|
+
} catch {}
|
|
24
|
+
return {
|
|
25
|
+
id: String(row?.id || ''),
|
|
26
|
+
timestamp: normalizeTimestamp(row?.timestamp),
|
|
27
|
+
type: String(row?.type || 'event'),
|
|
28
|
+
source: 'server',
|
|
29
|
+
details,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function shareIdentifier(token) {
|
|
34
|
+
return createHash('sha256').update(String(token || '')).digest('hex').slice(0, 12);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function createStatusEventService(db, {
|
|
38
|
+
maxEvents = DEFAULT_MAX_EVENTS,
|
|
39
|
+
sharedAccessDedupeTtlMs = DEFAULT_SHARED_ACCESS_DEDUPE_TTL_MS,
|
|
40
|
+
maxSharedAccessDedupeKeys = DEFAULT_MAX_SHARED_ACCESS_DEDUPE_KEYS,
|
|
41
|
+
} = {}) {
|
|
42
|
+
const normalizedMaxEvents = Math.max(1, Math.trunc(Number(maxEvents) || DEFAULT_MAX_EVENTS));
|
|
43
|
+
const normalizedDedupeTtlMs = Math.max(1, Math.trunc(Number(sharedAccessDedupeTtlMs) || DEFAULT_SHARED_ACCESS_DEDUPE_TTL_MS));
|
|
44
|
+
const normalizedMaxDedupeKeys = Math.max(1, Math.trunc(Number(maxSharedAccessDedupeKeys) || DEFAULT_MAX_SHARED_ACCESS_DEDUPE_KEYS));
|
|
45
|
+
const sharedAccessDedupe = new Map();
|
|
46
|
+
const insertEvent = db.prepare(`
|
|
47
|
+
INSERT INTO status_events (id, timestamp, type, source, payload_json)
|
|
48
|
+
VALUES (?, ?, ?, 'server', ?)
|
|
49
|
+
`);
|
|
50
|
+
const deleteExpiredEvents = db.prepare(`
|
|
51
|
+
DELETE FROM status_events
|
|
52
|
+
WHERE id IN (
|
|
53
|
+
SELECT id
|
|
54
|
+
FROM status_events
|
|
55
|
+
ORDER BY timestamp DESC, id DESC
|
|
56
|
+
LIMIT -1 OFFSET ?
|
|
57
|
+
)
|
|
58
|
+
`);
|
|
59
|
+
const listEvents = db.prepare(`
|
|
60
|
+
SELECT id, timestamp, type, source, payload_json
|
|
61
|
+
FROM status_events
|
|
62
|
+
WHERE (
|
|
63
|
+
@beforeTimestamp IS NULL
|
|
64
|
+
OR timestamp < @beforeTimestamp
|
|
65
|
+
OR (timestamp = @beforeTimestamp AND id < @beforeId)
|
|
66
|
+
)
|
|
67
|
+
ORDER BY timestamp DESC, id DESC
|
|
68
|
+
LIMIT @limit
|
|
69
|
+
`);
|
|
70
|
+
|
|
71
|
+
function pruneSharedAccessDedupe(now = Date.now()) {
|
|
72
|
+
for (const [key, lastSeenAt] of sharedAccessDedupe.entries()) {
|
|
73
|
+
if ((now - lastSeenAt) > normalizedDedupeTtlMs) sharedAccessDedupe.delete(key);
|
|
74
|
+
}
|
|
75
|
+
if (sharedAccessDedupe.size <= normalizedMaxDedupeKeys) return;
|
|
76
|
+
const oldest = Array.from(sharedAccessDedupe.entries())
|
|
77
|
+
.sort((left, right) => left[1] - right[1])
|
|
78
|
+
.slice(0, sharedAccessDedupe.size - normalizedMaxDedupeKeys);
|
|
79
|
+
for (const [key] of oldest) sharedAccessDedupe.delete(key);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function recordSharedAccess({
|
|
83
|
+
shareToken,
|
|
84
|
+
viewerIp,
|
|
85
|
+
conversationId,
|
|
86
|
+
timestamp = Date.now(),
|
|
87
|
+
} = {}) {
|
|
88
|
+
const now = normalizeTimestamp(timestamp);
|
|
89
|
+
const token = String(shareToken || '').trim();
|
|
90
|
+
const ip = String(viewerIp || '').trim() || 'unknown';
|
|
91
|
+
const normalizedConversationId = String(conversationId || '').trim();
|
|
92
|
+
const dedupeKey = `${token}:${ip}`;
|
|
93
|
+
const previousSeenAt = sharedAccessDedupe.get(dedupeKey);
|
|
94
|
+
sharedAccessDedupe.set(dedupeKey, now);
|
|
95
|
+
pruneSharedAccessDedupe(now);
|
|
96
|
+
if (Number.isFinite(previousSeenAt) && (now - previousSeenAt) <= normalizedDedupeTtlMs) {
|
|
97
|
+
return { event: null, deduped: true };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const event = {
|
|
101
|
+
id: `status-${randomUUID()}`,
|
|
102
|
+
timestamp: now,
|
|
103
|
+
type: 'shared-access-opened',
|
|
104
|
+
source: 'server',
|
|
105
|
+
details: {
|
|
106
|
+
shareId: shareIdentifier(token),
|
|
107
|
+
viewerIp: ip,
|
|
108
|
+
conversationId: normalizedConversationId || undefined,
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
const persist = db.transaction(() => {
|
|
112
|
+
insertEvent.run(event.id, event.timestamp, event.type, JSON.stringify(event.details));
|
|
113
|
+
deleteExpiredEvents.run(normalizedMaxEvents);
|
|
114
|
+
});
|
|
115
|
+
persist();
|
|
116
|
+
return { event, deduped: false };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function recordEvent(type, details = {}, timestamp = Date.now()) {
|
|
120
|
+
const event = {
|
|
121
|
+
id: `status-${randomUUID()}`,
|
|
122
|
+
timestamp: normalizeTimestamp(timestamp),
|
|
123
|
+
type: String(type || 'event'),
|
|
124
|
+
source: 'server',
|
|
125
|
+
details: details && typeof details === 'object' ? details : {},
|
|
126
|
+
};
|
|
127
|
+
const persist = db.transaction(() => {
|
|
128
|
+
insertEvent.run(event.id, event.timestamp, event.type, JSON.stringify(event.details));
|
|
129
|
+
deleteExpiredEvents.run(normalizedMaxEvents);
|
|
130
|
+
});
|
|
131
|
+
persist();
|
|
132
|
+
return event;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function getEventsPage({ beforeTimestamp = null, beforeId = '', limit = 40 } = {}) {
|
|
136
|
+
const hasCursor = beforeTimestamp !== null && beforeTimestamp !== undefined && String(beforeId || '').trim();
|
|
137
|
+
const timestamp = Number(beforeTimestamp);
|
|
138
|
+
const rows = listEvents.all({
|
|
139
|
+
beforeTimestamp: hasCursor && Number.isFinite(timestamp) ? Math.trunc(timestamp) : null,
|
|
140
|
+
beforeId: String(beforeId || ''),
|
|
141
|
+
limit: normalizeLimit(limit) + 1,
|
|
142
|
+
});
|
|
143
|
+
const hasMore = rows.length > normalizeLimit(limit);
|
|
144
|
+
const items = rows.slice(0, normalizeLimit(limit)).reverse().map(formatStatusEventRow);
|
|
145
|
+
const first = items[0] || null;
|
|
146
|
+
return {
|
|
147
|
+
items,
|
|
148
|
+
hasMore,
|
|
149
|
+
nextCursor: first ? { timestamp: first.timestamp, id: first.id } : null,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
getEventsPage,
|
|
155
|
+
recordEvent,
|
|
156
|
+
recordSharedAccess,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
function normalizeSessionId(value) {
|
|
4
|
+
const text = String(value || '').trim();
|
|
5
|
+
return text || '';
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function normalizeStatus(value) {
|
|
9
|
+
return String(value || '').trim().toLowerCase();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const ACTIVE_WORKER_STATUSES = new Set(['starting', 'ready', 'processing']);
|
|
13
|
+
|
|
14
|
+
export function isActiveWorkerStatus(value) {
|
|
15
|
+
return ACTIVE_WORKER_STATUSES.has(normalizeStatus(value));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function createTmuxInspectorAccessPolicy({
|
|
19
|
+
sessionWorkerRegistry = null,
|
|
20
|
+
sessionWorkerSupervisor = null,
|
|
21
|
+
} = {}) {
|
|
22
|
+
function getWorkerState(sdkSessionId) {
|
|
23
|
+
const sid = normalizeSessionId(sdkSessionId);
|
|
24
|
+
if (!sid) return null;
|
|
25
|
+
return sessionWorkerRegistry?.getWorker?.(sid)
|
|
26
|
+
|| sessionWorkerSupervisor?.getWorkerState?.(sid)
|
|
27
|
+
|| null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function evaluateSession(sdkSessionId) {
|
|
31
|
+
const sid = normalizeSessionId(sdkSessionId);
|
|
32
|
+
if (!sid) {
|
|
33
|
+
return {
|
|
34
|
+
ok: false,
|
|
35
|
+
code: 'missing-session-id',
|
|
36
|
+
reason: 'Missing sdkSessionId',
|
|
37
|
+
sdkSessionId: '',
|
|
38
|
+
worker: null,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const worker = getWorkerState(sid);
|
|
43
|
+
if (!worker) {
|
|
44
|
+
return {
|
|
45
|
+
ok: false,
|
|
46
|
+
code: 'session-worker-not-found',
|
|
47
|
+
reason: 'Session worker not found',
|
|
48
|
+
sdkSessionId: sid,
|
|
49
|
+
worker: null,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const status = normalizeStatus(worker.status);
|
|
54
|
+
if (!isActiveWorkerStatus(status)) {
|
|
55
|
+
return {
|
|
56
|
+
ok: false,
|
|
57
|
+
code: 'session-worker-inactive',
|
|
58
|
+
reason: `Session worker is not active (${status || 'unknown'})`,
|
|
59
|
+
sdkSessionId: sid,
|
|
60
|
+
worker,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
ok: true,
|
|
66
|
+
code: 'ok',
|
|
67
|
+
reason: null,
|
|
68
|
+
sdkSessionId: sid,
|
|
69
|
+
worker,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
evaluateSession,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|