@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.
Files changed (293) hide show
  1. package/.github/extensions/web-relay/README.md +190 -0
  2. package/.github/extensions/web-relay/extension.mjs +972 -0
  3. package/.github/extensions/web-relay/model-api/model-switching.mjs +338 -0
  4. package/.github/extensions/web-relay/polling/heartbeat.mjs +55 -0
  5. package/.github/extensions/web-relay/polling/polling-loop.mjs +1105 -0
  6. package/.github/extensions/web-relay/runtime/api-client.mjs +34 -0
  7. package/.github/extensions/web-relay/runtime/banner-state.mjs +126 -0
  8. package/.github/extensions/web-relay/runtime/config-loader.mjs +104 -0
  9. package/.github/extensions/web-relay/runtime/debug-log.mjs +27 -0
  10. package/.github/extensions/web-relay/runtime/send-and-wait-errors.mjs +170 -0
  11. package/.github/extensions/web-relay/runtime/session-binding.mjs +78 -0
  12. package/.github/extensions/web-relay/runtime/session-io.mjs +349 -0
  13. package/.github/extensions/web-relay/runtime/session-join-retry.mjs +44 -0
  14. package/.github/extensions/web-relay/runtime/session-registry.mjs +29 -0
  15. package/.github/extensions/web-relay/runtime/session-runtime-manager.mjs +156 -0
  16. package/.github/extensions/web-relay/runtime/session-sync-bridge.mjs +74 -0
  17. package/.github/extensions/web-relay/runtime/worker-websocket-link.mjs +325 -0
  18. package/.github/extensions/web-relay/runtime/workspace-root.mjs +55 -0
  19. package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +386 -0
  20. package/.github/extensions/web-relay/skills/prompt-context.mjs +160 -0
  21. package/.github/extensions/web-relay/skills/question-bridge.mjs +141 -0
  22. package/.github/extensions/web-relay/skills/question-routing-hooks.mjs +463 -0
  23. package/.github/extensions/web-relay/skills/reasoning-stream.mjs +285 -0
  24. package/.github/extensions/web-relay/skills/subagent-lifecycle.mjs +345 -0
  25. package/.github/extensions/web-relay/skills/tool-activity.mjs +243 -0
  26. package/.github/extensions/web-relay/utils/process-utils.mjs +25 -0
  27. package/.github/extensions/web-relay/utils/token-generation.mjs +58 -0
  28. package/CHANGELOG.md +25 -0
  29. package/LICENSE +21 -0
  30. package/README.md +717 -0
  31. package/bin/bat/oar.bat +46 -0
  32. package/bin/oar.js +665 -0
  33. package/package.json +68 -0
  34. package/server/README.md +1379 -0
  35. package/server/claude-worker/claude-attachments.mjs +134 -0
  36. package/server/claude-worker/claude-preview-tool.mjs +57 -0
  37. package/server/claude-worker/claude-sdk-adapter.mjs +420 -0
  38. package/server/claude-worker/claude-session-process.mjs +2391 -0
  39. package/server/claude-worker/claude-session-test-harness.mjs +236 -0
  40. package/server/claude-worker/claude-session-worker.mjs +186 -0
  41. package/server/claude-worker/claude-transcript-relocator.mjs +189 -0
  42. package/server/claude-worker/claude-turn-publisher.mjs +291 -0
  43. package/server/claude-worker/fixtures/workflow-run-record.json +1 -0
  44. package/server/claude-worker/sdk-message-normalizer.mjs +629 -0
  45. package/server/claude-worker/workflow-progress-digest.mjs +269 -0
  46. package/server/copilot-sdk-runtime.mjs +126 -0
  47. package/server/copilot-worker/copilot-attachments.mjs +97 -0
  48. package/server/copilot-worker/copilot-byok-provider.mjs +121 -0
  49. package/server/copilot-worker/copilot-continuation-signals.mjs +349 -0
  50. package/server/copilot-worker/copilot-plan-board.mjs +110 -0
  51. package/server/copilot-worker/copilot-prompt-context.mjs +86 -0
  52. package/server/copilot-worker/copilot-question-bridge.mjs +216 -0
  53. package/server/copilot-worker/copilot-sdk-adapter.mjs +494 -0
  54. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +800 -0
  55. package/server/copilot-worker/copilot-sdk-session-process.mjs +1729 -0
  56. package/server/copilot-worker/copilot-sdk-session-worker.mjs +167 -0
  57. package/server/copilot-worker/copilot-sdk-test-harness.mjs +329 -0
  58. package/server/copilot-worker/fixtures/abort-turn.json +588 -0
  59. package/server/copilot-worker/fixtures/ask-user-turn.json +1154 -0
  60. package/server/copilot-worker/fixtures/background-timer-continuation.json +445 -0
  61. package/server/copilot-worker/fixtures/background-timer-turn.json +497 -0
  62. package/server/copilot-worker/fixtures/happy-turn.json +566 -0
  63. package/server/copilot-worker/fixtures/quota-turn.json +647 -0
  64. package/server/copilot-worker/fixtures/reasoning-turn.json +625 -0
  65. package/server/copilot-worker/fixtures/resume-turn.json +700 -0
  66. package/server/copilot-worker/fixtures/subagent-turn.json +1378 -0
  67. package/server/copilot-worker/fixtures/tool-permission-turn.json +1293 -0
  68. package/server/cursor-worker/cursor-ask-user-tool.mjs +83 -0
  69. package/server/cursor-worker/cursor-attachments.mjs +88 -0
  70. package/server/cursor-worker/cursor-context-usage.mjs +85 -0
  71. package/server/cursor-worker/cursor-preview-tool.mjs +44 -0
  72. package/server/cursor-worker/cursor-sdk-adapter.mjs +554 -0
  73. package/server/cursor-worker/cursor-session-worker.mjs +145 -0
  74. package/server/cursor-worker/cursor-subagent-roster.mjs +81 -0
  75. package/server/cursor-worker/cursor-turn-runner.mjs +717 -0
  76. package/server/cursor-worker/sdk-message-normalizer.mjs +485 -0
  77. package/server/db-schema.mjs +932 -0
  78. package/server/features.mjs +106 -0
  79. package/server/grok-worker/acp-client.mjs +434 -0
  80. package/server/grok-worker/acp-host-services.mjs +277 -0
  81. package/server/grok-worker/grok-context-usage.mjs +77 -0
  82. package/server/grok-worker/grok-sdk-adapter.mjs +334 -0
  83. package/server/grok-worker/grok-session-worker.mjs +145 -0
  84. package/server/grok-worker/grok-turn-runner.mjs +523 -0
  85. package/server/grok-worker/sdk-message-normalizer.mjs +311 -0
  86. package/server/migrations/0001-add-structured-answer.mjs +80 -0
  87. package/server/migrations/0002-recent-workspace-roots-path-key.mjs +121 -0
  88. package/server/migrations/0003-push-subscriptions.mjs +87 -0
  89. package/server/public/app/action-confirmations.js +267 -0
  90. package/server/public/app/activity-replay-state.mjs +149 -0
  91. package/server/public/app/api-client.js +1082 -0
  92. package/server/public/app/ask-user-view.js +545 -0
  93. package/server/public/app/attachments-view.js +2112 -0
  94. package/server/public/app/auto-compact-window-options.mjs +59 -0
  95. package/server/public/app/background-tasks-view.mjs +494 -0
  96. package/server/public/app/bootstrap.js +4347 -0
  97. package/server/public/app/claude-auth-ui.js +394 -0
  98. package/server/public/app/claude-thinking-options.mjs +38 -0
  99. package/server/public/app/cli-install-ui.js +455 -0
  100. package/server/public/app/code-copy.mjs +85 -0
  101. package/server/public/app/composer-attachment-cache.mjs +151 -0
  102. package/server/public/app/composer-control-state.mjs +107 -0
  103. package/server/public/app/composer-paste.mjs +132 -0
  104. package/server/public/app/context-tier-options.mjs +68 -0
  105. package/server/public/app/context-usage-view.mjs +338 -0
  106. package/server/public/app/conversation-draft-timestamp-utils.mjs +11 -0
  107. package/server/public/app/conversation-preferences.mjs +91 -0
  108. package/server/public/app/conversation-provider-indicator.mjs +88 -0
  109. package/server/public/app/conversation-view.js +2898 -0
  110. package/server/public/app/cwd-menu-placement.mjs +85 -0
  111. package/server/public/app/cwd-picker.js +605 -0
  112. package/server/public/app/emoji-view.js +101 -0
  113. package/server/public/app/external-link-policy.mjs +80 -0
  114. package/server/public/app/file-preview-navigation.mjs +51 -0
  115. package/server/public/app/font-scaling.js +262 -0
  116. package/server/public/app/git-changes-view.js +339 -0
  117. package/server/public/app/git-diff-model.mjs +109 -0
  118. package/server/public/app/grok-auth-ui.js +387 -0
  119. package/server/public/app/image-downscale.mjs +174 -0
  120. package/server/public/app/in-flight-snapshot.mjs +40 -0
  121. package/server/public/app/infinite-loader.js +164 -0
  122. package/server/public/app/journal-view.js +1141 -0
  123. package/server/public/app/known-cwd-options.mjs +41 -0
  124. package/server/public/app/launch-cwd-path.mjs +23 -0
  125. package/server/public/app/live-message-dedupe.mjs +57 -0
  126. package/server/public/app/message-search-view.js +242 -0
  127. package/server/public/app/model-selector-options.mjs +31 -0
  128. package/server/public/app/model-vision-support.mjs +56 -0
  129. package/server/public/app/new-conversation-model-choice.mjs +67 -0
  130. package/server/public/app/plan-usage-view.mjs +307 -0
  131. package/server/public/app/preview-cards.mjs +273 -0
  132. package/server/public/app/preview-command.mjs +103 -0
  133. package/server/public/app/push-settings.js +370 -0
  134. package/server/public/app/pwa-install.js +456 -0
  135. package/server/public/app/question-schema-view.mjs +71 -0
  136. package/server/public/app/reasoning-effort-labels.mjs +42 -0
  137. package/server/public/app/relay-board-view.js +117 -0
  138. package/server/public/app/relay-dot-state.mjs +69 -0
  139. package/server/public/app/relay-error-ctas.mjs +52 -0
  140. package/server/public/app/relay-prompt-sanitizer.mjs +114 -0
  141. package/server/public/app/relay-thoughts.mjs +98 -0
  142. package/server/public/app/relay-watchdog-policy.mjs +29 -0
  143. package/server/public/app/repo-browser-preferences.mjs +79 -0
  144. package/server/public/app/repo-browser-tree-state.mjs +97 -0
  145. package/server/public/app/router.js +657 -0
  146. package/server/public/app/selection-guard.mjs +93 -0
  147. package/server/public/app/settings-modal.js +1484 -0
  148. package/server/public/app/settings-tabs.js +129 -0
  149. package/server/public/app/slash-autocomplete.mjs +188 -0
  150. package/server/public/app/slash-commands.mjs +183 -0
  151. package/server/public/app/socket-handlers.js +703 -0
  152. package/server/public/app/status-store.mjs +242 -0
  153. package/server/public/app/status-view.mjs +315 -0
  154. package/server/public/app/store.js +1454 -0
  155. package/server/public/app/stream-state.mjs +73 -0
  156. package/server/public/app/streaming-dom-patch.mjs +23 -0
  157. package/server/public/app/sync-outbox.mjs +208 -0
  158. package/server/public/app/tap-actions.js +62 -0
  159. package/server/public/app/thread-order.mjs +54 -0
  160. package/server/public/app/tmux-inspector-view.js +422 -0
  161. package/server/public/app/transcript-separators.mjs +282 -0
  162. package/server/public/app-icon-192.png +0 -0
  163. package/server/public/app-icon-512.png +0 -0
  164. package/server/public/app-icon.svg +4 -0
  165. package/server/public/favicon.ico +0 -0
  166. package/server/public/index.html +5743 -0
  167. package/server/public/manifest.webmanifest +32 -0
  168. package/server/public/sw.js +307 -0
  169. package/server/relay-exit-codes.mjs +4 -0
  170. package/server/relay-self-restart.mjs +170 -0
  171. package/server/relay-tools.md +27 -0
  172. package/server/relay.mjs +1026 -0
  173. package/server/repositories/image-conversation-repository.mjs +317 -0
  174. package/server/repositories/message-repository.mjs +434 -0
  175. package/server/repositories/question-repository.mjs +161 -0
  176. package/server/repositories/session-repository.mjs +320 -0
  177. package/server/repositories/share-visibility-statements.mjs +24 -0
  178. package/server/routes/ask-user-routes.mjs +292 -0
  179. package/server/routes/cache-routes.mjs +44 -0
  180. package/server/routes/git-routes.mjs +92 -0
  181. package/server/routes/messages-routes-test-harness.mjs +111 -0
  182. package/server/routes/messages-routes.mjs +7107 -0
  183. package/server/routes/preview-routes.mjs +108 -0
  184. package/server/routes/push-routes.mjs +59 -0
  185. package/server/routes/relay-board-routes.mjs +268 -0
  186. package/server/routes/sessions-routes.mjs +6150 -0
  187. package/server/server-runtime.mjs +7463 -0
  188. package/server/server.js +24 -0
  189. package/server/services/ask-user-routing-service.mjs +117 -0
  190. package/server/services/cache-rebuild-service.mjs +236 -0
  191. package/server/services/claude-auth-service.mjs +578 -0
  192. package/server/services/claude-context-usage.mjs +240 -0
  193. package/server/services/claude-session-root-service.mjs +228 -0
  194. package/server/services/cli-install-service.mjs +1152 -0
  195. package/server/services/cli-process-runner.mjs +181 -0
  196. package/server/services/cloudflared-tunnel-service.mjs +358 -0
  197. package/server/services/context-snapshot-service.mjs +499 -0
  198. package/server/services/context-usage-view.mjs +160 -0
  199. package/server/services/conversation-preferences-service.mjs +208 -0
  200. package/server/services/cursor-dashboard-usage.mjs +217 -0
  201. package/server/services/cursor-session-root-service.mjs +62 -0
  202. package/server/services/delete-archive-service.mjs +154 -0
  203. package/server/services/drives-path-helpers.mjs +66 -0
  204. package/server/services/fixtures/claude-auth-stub.sh +62 -0
  205. package/server/services/fixtures/cli-install-stub.sh +75 -0
  206. package/server/services/fixtures/grok-stub.sh +86 -0
  207. package/server/services/generated-image-cleanup-service.mjs +114 -0
  208. package/server/services/git-changes-service.mjs +195 -0
  209. package/server/services/github-billing-usage.mjs +133 -0
  210. package/server/services/grok-auth-service.mjs +593 -0
  211. package/server/services/grok-billing-usage.mjs +120 -0
  212. package/server/services/image-context-service.mjs +106 -0
  213. package/server/services/image-operation-service.mjs +135 -0
  214. package/server/services/keyed-mutex-service.mjs +82 -0
  215. package/server/services/mime-sniffer.mjs +118 -0
  216. package/server/services/oar-cli-helpers.mjs +109 -0
  217. package/server/services/oar-state-migration-service.mjs +243 -0
  218. package/server/services/plan-usage-claude.mjs +387 -0
  219. package/server/services/plan-usage-contract.mjs +271 -0
  220. package/server/services/plan-usage-copilot.mjs +421 -0
  221. package/server/services/plan-usage-cursor.mjs +566 -0
  222. package/server/services/plan-usage-grok.mjs +436 -0
  223. package/server/services/plan-usage-service.mjs +487 -0
  224. package/server/services/preview-health-probe.mjs +82 -0
  225. package/server/services/preview-proxy-server.mjs +498 -0
  226. package/server/services/preview-registry-service.mjs +457 -0
  227. package/server/services/preview-static-handler.mjs +237 -0
  228. package/server/services/provider-model-selection.mjs +91 -0
  229. package/server/services/provider-reasoning-effort.mjs +94 -0
  230. package/server/services/push-active-device-service.mjs +57 -0
  231. package/server/services/push-dispatch-service.mjs +459 -0
  232. package/server/services/rate-limit-service.mjs +63 -0
  233. package/server/services/relay-bridge-owner-service.mjs +84 -0
  234. package/server/services/relay-cli-launcher-service.mjs +209 -0
  235. package/server/services/relay-prompt-sanitizer.mjs +137 -0
  236. package/server/services/relay-queue-gate-service.mjs +38 -0
  237. package/server/services/relay-restart-orchestrator-service.mjs +935 -0
  238. package/server/services/relay-singleton-guard.mjs +124 -0
  239. package/server/services/safe-served-content.mjs +57 -0
  240. package/server/services/sdk-session-import-service.mjs +271 -0
  241. package/server/services/sdk-session-sync-service.mjs +207 -0
  242. package/server/services/session-history-refresh-service.mjs +497 -0
  243. package/server/services/session-transcript-service.mjs +419 -0
  244. package/server/services/session-worker-launch-service.mjs +786 -0
  245. package/server/services/session-worker-process-service.mjs +319 -0
  246. package/server/services/session-worker-registry-service.mjs +194 -0
  247. package/server/services/session-worker-stop-service.mjs +141 -0
  248. package/server/services/session-worker-supervisor-service.mjs +898 -0
  249. package/server/services/session-worker-websocket-service.mjs +483 -0
  250. package/server/services/ssh-tunnel-manager-service.mjs +423 -0
  251. package/server/services/status-event-service.mjs +158 -0
  252. package/server/services/tmux-inspector-access-policy.mjs +77 -0
  253. package/server/services/tmux-inspector-socket-service.mjs +191 -0
  254. package/server/services/tmux-inspector-stream-service.mjs +552 -0
  255. package/server/services/tunnel-worker-path-guard.mjs +107 -0
  256. package/server/services/upload-sweep.mjs +60 -0
  257. package/server/services/usage-snapshot-helpers.mjs +209 -0
  258. package/server/services/windows-autostart-service.mjs +115 -0
  259. package/server/services/workspace-root-defaults-service.mjs +34 -0
  260. package/server/services/workspace-root-path-policy.mjs +196 -0
  261. package/server/services/workspace-root-relaunch-service.mjs +132 -0
  262. package/server/services/workspace-symlink-guard.mjs +54 -0
  263. package/server/tools/capture-proxy.mjs +173 -0
  264. package/server/tty-console-bootstrap.mjs +75 -0
  265. package/server/uploads/README.md +36 -0
  266. package/server/uploads/image_input.md +540 -0
  267. package/server/windows-terminal-launcher.mjs +29 -0
  268. package/server/workspace-root.mjs +87 -0
  269. package/shared/ask-user-bridge.mjs +131 -0
  270. package/shared/auto-compact-window.mjs +116 -0
  271. package/shared/background-task-timeout.mjs +52 -0
  272. package/shared/claude-thinking.mjs +101 -0
  273. package/shared/context-window-fallbacks.mjs +148 -0
  274. package/shared/control-poller.mjs +115 -0
  275. package/shared/empty-turn-completion.mjs +20 -0
  276. package/shared/model-catalog-freshness.mjs +11 -0
  277. package/shared/model-descriptors.mjs +118 -0
  278. package/shared/model-id.mjs +147 -0
  279. package/shared/model-refresh.mjs +24 -0
  280. package/shared/openai-reasoning.mjs +13 -0
  281. package/shared/plan-lines.mjs +13 -0
  282. package/shared/preview-instructions.mjs +77 -0
  283. package/shared/preview-tool-core.mjs +191 -0
  284. package/shared/provider-routing.mjs +29 -0
  285. package/shared/question-schema.mjs +315 -0
  286. package/shared/question-timeout.mjs +18 -0
  287. package/shared/stream-emit-gating.mjs +28 -0
  288. package/shared/subagent-run-id.mjs +17 -0
  289. package/shared/thought-cap.mjs +13 -0
  290. package/shared/tool-activity.mjs +98 -0
  291. package/shared/turn-ceiling.mjs +53 -0
  292. package/shared/worker-bootstrap.mjs +44 -0
  293. 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
+