@hellcoder/companion 0.96.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 (242) hide show
  1. package/bin/cli.ts +168 -0
  2. package/bin/ctl.ts +528 -0
  3. package/bin/generate-token.ts +28 -0
  4. package/dist/apple-touch-icon.png +0 -0
  5. package/dist/assets/AgentsPage-DCFhrJ28.js +13 -0
  6. package/dist/assets/CronManager-EGwLJONv.js +1 -0
  7. package/dist/assets/IntegrationsPage-CTMRnbQS.js +1 -0
  8. package/dist/assets/LinearOAuthSettingsPage-CgQFMIgr.js +1 -0
  9. package/dist/assets/LinearSettingsPage-C9nok1qi.js +1 -0
  10. package/dist/assets/Playground-BV3k0RbV.js +109 -0
  11. package/dist/assets/PromptsPage-CFojqNKP.js +4 -0
  12. package/dist/assets/RunsPage-DUJ1QUSa.js +1 -0
  13. package/dist/assets/SandboxManager-CrVQ-VU_.js +8 -0
  14. package/dist/assets/SettingsPage-D1fPCL19.js +1 -0
  15. package/dist/assets/TailscalePage-D06cyvyC.js +1 -0
  16. package/dist/assets/index-BhUa1e6X.css +1 -0
  17. package/dist/assets/index-DkqeP-R9.js +134 -0
  18. package/dist/assets/sw-register-BibwRdvC.js +1 -0
  19. package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
  20. package/dist/favicon.svg +8 -0
  21. package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
  22. package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
  23. package/dist/icon-192.png +0 -0
  24. package/dist/icon-512.png +0 -0
  25. package/dist/index.html +20 -0
  26. package/dist/logo-codex.svg +14 -0
  27. package/dist/logo-docker.svg +4 -0
  28. package/dist/logo.svg +14 -0
  29. package/dist/manifest.json +24 -0
  30. package/dist/sw.js +2 -0
  31. package/package.json +104 -0
  32. package/server/agent-cron-migrator.test.ts +610 -0
  33. package/server/agent-cron-migrator.ts +85 -0
  34. package/server/agent-executor.test.ts +1108 -0
  35. package/server/agent-executor.ts +346 -0
  36. package/server/agent-store.test.ts +588 -0
  37. package/server/agent-store.ts +185 -0
  38. package/server/agent-types.ts +138 -0
  39. package/server/ai-validation-settings.test.ts +128 -0
  40. package/server/ai-validation-settings.ts +35 -0
  41. package/server/ai-validator.test.ts +387 -0
  42. package/server/ai-validator.ts +271 -0
  43. package/server/auth-manager.test.ts +83 -0
  44. package/server/auth-manager.ts +150 -0
  45. package/server/auto-namer.test.ts +252 -0
  46. package/server/auto-namer.ts +78 -0
  47. package/server/backend-adapter.test.ts +38 -0
  48. package/server/backend-adapter.ts +54 -0
  49. package/server/cache-headers.test.ts +98 -0
  50. package/server/cache-headers.ts +61 -0
  51. package/server/claude-adapter.test.ts +1363 -0
  52. package/server/claude-adapter.ts +889 -0
  53. package/server/claude-container-auth.test.ts +44 -0
  54. package/server/claude-container-auth.ts +30 -0
  55. package/server/claude-protocol-contract.test.ts +71 -0
  56. package/server/claude-protocol-drift.test.ts +78 -0
  57. package/server/claude-session-discovery.test.ts +132 -0
  58. package/server/claude-session-discovery.ts +157 -0
  59. package/server/claude-session-history.test.ts +158 -0
  60. package/server/claude-session-history.ts +410 -0
  61. package/server/cli-launcher.test.ts +1343 -0
  62. package/server/cli-launcher.ts +1298 -0
  63. package/server/cli.test.ts +16 -0
  64. package/server/codex-adapter.test.ts +5545 -0
  65. package/server/codex-adapter.ts +3062 -0
  66. package/server/codex-container-auth.test.ts +50 -0
  67. package/server/codex-container-auth.ts +24 -0
  68. package/server/codex-home.test.ts +61 -0
  69. package/server/codex-home.ts +26 -0
  70. package/server/codex-protocol-contract.test.ts +96 -0
  71. package/server/codex-protocol-drift.test.ts +123 -0
  72. package/server/codex-ws-proxy.cjs +226 -0
  73. package/server/commands-discovery.test.ts +179 -0
  74. package/server/commands-discovery.ts +81 -0
  75. package/server/constants.ts +7 -0
  76. package/server/container-manager.test.ts +1211 -0
  77. package/server/container-manager.ts +1053 -0
  78. package/server/cron-scheduler.test.ts +957 -0
  79. package/server/cron-scheduler.ts +243 -0
  80. package/server/cron-store.test.ts +422 -0
  81. package/server/cron-store.ts +148 -0
  82. package/server/cron-types.ts +63 -0
  83. package/server/env-manager.test.ts +268 -0
  84. package/server/env-manager.ts +161 -0
  85. package/server/event-bus-types.ts +64 -0
  86. package/server/event-bus.test.ts +244 -0
  87. package/server/event-bus.ts +124 -0
  88. package/server/execution-store.test.ts +307 -0
  89. package/server/execution-store.ts +170 -0
  90. package/server/fs-utils.ts +15 -0
  91. package/server/git-utils.test.ts +938 -0
  92. package/server/git-utils.ts +421 -0
  93. package/server/github-pr.test.ts +498 -0
  94. package/server/github-pr.ts +379 -0
  95. package/server/image-pull-manager.test.ts +303 -0
  96. package/server/image-pull-manager.ts +279 -0
  97. package/server/index.ts +396 -0
  98. package/server/linear-agent-bridge.test.ts +1157 -0
  99. package/server/linear-agent-bridge.ts +629 -0
  100. package/server/linear-agent.test.ts +473 -0
  101. package/server/linear-agent.ts +479 -0
  102. package/server/linear-cache.test.ts +136 -0
  103. package/server/linear-cache.ts +113 -0
  104. package/server/linear-connections.test.ts +350 -0
  105. package/server/linear-connections.ts +231 -0
  106. package/server/linear-credential-migration.test.ts +337 -0
  107. package/server/linear-credential-migration.ts +63 -0
  108. package/server/linear-oauth-connections-migration.test.ts +268 -0
  109. package/server/linear-oauth-connections.test.ts +365 -0
  110. package/server/linear-oauth-connections.ts +294 -0
  111. package/server/linear-project-manager.test.ts +162 -0
  112. package/server/linear-project-manager.ts +111 -0
  113. package/server/linear-prompt-builder.test.ts +74 -0
  114. package/server/linear-prompt-builder.ts +61 -0
  115. package/server/linear-staging.test.ts +276 -0
  116. package/server/linear-staging.ts +142 -0
  117. package/server/logger.test.ts +393 -0
  118. package/server/logger.ts +259 -0
  119. package/server/metrics-collector.test.ts +413 -0
  120. package/server/metrics-collector.ts +350 -0
  121. package/server/metrics-types.ts +108 -0
  122. package/server/middleware/managed-auth.test.ts +264 -0
  123. package/server/middleware/managed-auth.ts +195 -0
  124. package/server/novnc-proxy.test.ts +333 -0
  125. package/server/novnc-proxy.ts +99 -0
  126. package/server/path-resolver.test.ts +552 -0
  127. package/server/path-resolver.ts +186 -0
  128. package/server/paths.test.ts +31 -0
  129. package/server/paths.ts +11 -0
  130. package/server/pr-poller.test.ts +191 -0
  131. package/server/pr-poller.ts +162 -0
  132. package/server/prompt-manager.test.ts +211 -0
  133. package/server/prompt-manager.ts +211 -0
  134. package/server/protocol/claude-upstream/README.md +19 -0
  135. package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
  136. package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
  137. package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
  138. package/server/protocol/codex-upstream/README.md +18 -0
  139. package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
  140. package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
  141. package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
  142. package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
  143. package/server/protocol-monitor.ts +50 -0
  144. package/server/recorder.test.ts +454 -0
  145. package/server/recorder.ts +374 -0
  146. package/server/recording-hub/compat-validator.test.ts +150 -0
  147. package/server/recording-hub/compat-validator.ts +284 -0
  148. package/server/recording-hub/diagnostics.test.ts +140 -0
  149. package/server/recording-hub/diagnostics.ts +299 -0
  150. package/server/recording-hub/hub-config.test.ts +44 -0
  151. package/server/recording-hub/hub-config.ts +19 -0
  152. package/server/recording-hub/hub-routes.test.ts +417 -0
  153. package/server/recording-hub/hub-routes.ts +236 -0
  154. package/server/recording-hub/hub-store.test.ts +262 -0
  155. package/server/recording-hub/hub-store.ts +265 -0
  156. package/server/recording-hub/replay-adapter.test.ts +294 -0
  157. package/server/recording-hub/replay-adapter.ts +207 -0
  158. package/server/relay-client.test.ts +337 -0
  159. package/server/relay-client.ts +320 -0
  160. package/server/replay.test.ts +200 -0
  161. package/server/replay.ts +78 -0
  162. package/server/routes/agent-routes.test.ts +1400 -0
  163. package/server/routes/agent-routes.ts +409 -0
  164. package/server/routes/cron-routes.test.ts +881 -0
  165. package/server/routes/cron-routes.ts +103 -0
  166. package/server/routes/env-routes.test.ts +383 -0
  167. package/server/routes/env-routes.ts +95 -0
  168. package/server/routes/fs-routes.test.ts +1198 -0
  169. package/server/routes/fs-routes.ts +605 -0
  170. package/server/routes/git-routes.test.ts +813 -0
  171. package/server/routes/git-routes.ts +97 -0
  172. package/server/routes/linear-agent-routes.test.ts +721 -0
  173. package/server/routes/linear-agent-routes.ts +304 -0
  174. package/server/routes/linear-connection-routes.test.ts +927 -0
  175. package/server/routes/linear-connection-routes.ts +244 -0
  176. package/server/routes/linear-oauth-connection-routes.test.ts +406 -0
  177. package/server/routes/linear-oauth-connection-routes.ts +129 -0
  178. package/server/routes/linear-routes.test.ts +1510 -0
  179. package/server/routes/linear-routes.ts +953 -0
  180. package/server/routes/metrics-routes.test.ts +103 -0
  181. package/server/routes/metrics-routes.ts +13 -0
  182. package/server/routes/prompt-routes.ts +67 -0
  183. package/server/routes/sandbox-routes.test.ts +513 -0
  184. package/server/routes/sandbox-routes.ts +127 -0
  185. package/server/routes/settings-routes.ts +270 -0
  186. package/server/routes/skills-routes.test.ts +690 -0
  187. package/server/routes/skills-routes.ts +100 -0
  188. package/server/routes/system-routes.test.ts +637 -0
  189. package/server/routes/system-routes.ts +228 -0
  190. package/server/routes/tailscale-routes.test.ts +176 -0
  191. package/server/routes/tailscale-routes.ts +22 -0
  192. package/server/routes.test.ts +4655 -0
  193. package/server/routes.ts +1277 -0
  194. package/server/sandbox-manager.test.ts +378 -0
  195. package/server/sandbox-manager.ts +168 -0
  196. package/server/service.test.ts +1419 -0
  197. package/server/service.ts +718 -0
  198. package/server/session-creation-service.test.ts +661 -0
  199. package/server/session-creation-service.ts +473 -0
  200. package/server/session-git-info.ts +104 -0
  201. package/server/session-linear-issues.test.ts +118 -0
  202. package/server/session-linear-issues.ts +88 -0
  203. package/server/session-names.test.ts +94 -0
  204. package/server/session-names.ts +67 -0
  205. package/server/session-orchestrator.test.ts +1784 -0
  206. package/server/session-orchestrator.ts +973 -0
  207. package/server/session-state-machine.test.ts +606 -0
  208. package/server/session-state-machine.ts +207 -0
  209. package/server/session-store.test.ts +290 -0
  210. package/server/session-store.ts +146 -0
  211. package/server/session-types.ts +509 -0
  212. package/server/settings-manager.test.ts +275 -0
  213. package/server/settings-manager.ts +173 -0
  214. package/server/tailscale-manager.test.ts +553 -0
  215. package/server/tailscale-manager.ts +451 -0
  216. package/server/terminal-manager.ts +240 -0
  217. package/server/update-checker.test.ts +306 -0
  218. package/server/update-checker.ts +197 -0
  219. package/server/usage-limits.test.ts +536 -0
  220. package/server/usage-limits.ts +225 -0
  221. package/server/worktree-tracker.test.ts +243 -0
  222. package/server/worktree-tracker.ts +84 -0
  223. package/server/ws-auth.test.ts +59 -0
  224. package/server/ws-auth.ts +41 -0
  225. package/server/ws-bridge-browser-ingest.test.ts +272 -0
  226. package/server/ws-bridge-browser-ingest.ts +72 -0
  227. package/server/ws-bridge-browser.ts +112 -0
  228. package/server/ws-bridge-cli-ingest.test.ts +302 -0
  229. package/server/ws-bridge-cli-ingest.ts +81 -0
  230. package/server/ws-bridge-codex.test.ts +1837 -0
  231. package/server/ws-bridge-codex.ts +266 -0
  232. package/server/ws-bridge-controls.test.ts +124 -0
  233. package/server/ws-bridge-controls.ts +20 -0
  234. package/server/ws-bridge-persist.test.ts +296 -0
  235. package/server/ws-bridge-persist.ts +66 -0
  236. package/server/ws-bridge-publish.test.ts +234 -0
  237. package/server/ws-bridge-publish.ts +79 -0
  238. package/server/ws-bridge-replay.test.ts +44 -0
  239. package/server/ws-bridge-replay.ts +61 -0
  240. package/server/ws-bridge-types.ts +106 -0
  241. package/server/ws-bridge.test.ts +4777 -0
  242. package/server/ws-bridge.ts +1279 -0
@@ -0,0 +1,451 @@
1
+ /**
2
+ * Tailscale CLI wrapper for Funnel integration.
3
+ *
4
+ * Detects the `tailscale` binary, checks connection status, and manages
5
+ * Tailscale Funnel to expose the Companion over HTTPS. Persists funnel
6
+ * state to ~/.companion/tailscale-state.json for restoration across
7
+ * server restarts.
8
+ */
9
+
10
+ import { spawnSync, spawn } from "node:child_process";
11
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
12
+ import { dirname, join } from "node:path";
13
+ import { homedir } from "node:os";
14
+ import { resolveBinary } from "./path-resolver.js";
15
+ import { getSettings, updateSettings } from "./settings-manager.js";
16
+
17
+ // ── Types ───────────────────────────────────────────────────────────────────
18
+
19
+ /** Keep in sync with web/src/api.ts TailscaleStatus */
20
+ export interface TailscaleStatus {
21
+ /** Whether the `tailscale` binary was found on PATH */
22
+ installed: boolean;
23
+ /** Resolved absolute path to the binary, or null */
24
+ binaryPath: string | null;
25
+ /** Whether Tailscale is connected to a tailnet */
26
+ connected: boolean;
27
+ /** Machine DNS name, e.g. "my-machine.tail1234.ts.net" */
28
+ dnsName: string | null;
29
+ /** Whether Funnel is currently active for our port */
30
+ funnelActive: boolean;
31
+ /** HTTPS Funnel URL when active, e.g. "https://my-machine.tail1234.ts.net" */
32
+ funnelUrl: string | null;
33
+ /** Error message if the last operation failed */
34
+ error: string | null;
35
+ /** True when on Linux and Tailscale operator mode is not configured */
36
+ needsOperatorMode?: boolean;
37
+ /** Non-blocking warning (e.g. DNS not resolving publicly) */
38
+ warning?: string;
39
+ }
40
+
41
+ interface PersistedFunnelState {
42
+ wasActive: boolean;
43
+ port: number;
44
+ funnelUrl: string;
45
+ activatedAt: number;
46
+ }
47
+
48
+ // ── Internal state ──────────────────────────────────────────────────────────
49
+
50
+ const STATE_PATH = join(homedir(), ".companion", "tailscale-state.json");
51
+ const CMD_TIMEOUT = 15_000;
52
+ const BINARY_CACHE_TTL = 60_000; // 1 minute — allows detecting install/uninstall without restart
53
+
54
+ let cachedBinaryPath: string | null | undefined; // undefined = not yet checked
55
+ let binaryCacheTime = 0;
56
+
57
+ // ── Helpers ─────────────────────────────────────────────────────────────────
58
+
59
+ function findBinary(): string | null {
60
+ if (cachedBinaryPath !== undefined && Date.now() - binaryCacheTime < BINARY_CACHE_TTL) {
61
+ return cachedBinaryPath;
62
+ }
63
+ cachedBinaryPath = resolveBinary("tailscale");
64
+ binaryCacheTime = Date.now();
65
+ return cachedBinaryPath;
66
+ }
67
+
68
+ /**
69
+ * Run a command asynchronously using spawn with explicit argument array
70
+ * (no shell interpolation — eliminates command injection).
71
+ */
72
+ function execAsync(binary: string, args: string[]): Promise<string> {
73
+ return new Promise((resolve, reject) => {
74
+ const proc = spawn(binary, args, {
75
+ stdio: ["pipe", "pipe", "pipe"],
76
+ timeout: CMD_TIMEOUT,
77
+ });
78
+
79
+ let stdout = "";
80
+ let stderr = "";
81
+
82
+ proc.stdout.on("data", (data: Buffer) => { stdout += data.toString(); });
83
+ proc.stderr.on("data", (data: Buffer) => { stderr += data.toString(); });
84
+
85
+ proc.on("error", (err) => reject(err));
86
+ proc.on("close", (code) => {
87
+ if (code === 0) {
88
+ resolve(stdout.trim());
89
+ } else {
90
+ reject(new Error(stderr.trim() || `Process exited with code ${code}`));
91
+ }
92
+ });
93
+ });
94
+ }
95
+
96
+ /**
97
+ * Check if operator mode is needed but not configured (Linux only).
98
+ * On macOS the Tailscale GUI app handles permissions, so this is a no-op.
99
+ */
100
+ async function checkNeedsOperatorMode(binary: string): Promise<boolean> {
101
+ if (process.platform !== "linux") return false;
102
+ try {
103
+ const output = await execAsync(binary, ["debug", "prefs"]);
104
+ const prefs = JSON.parse(output);
105
+ return !prefs.OperatorUser;
106
+ } catch {
107
+ return false; // Can't determine — assume ok
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Check if a hostname resolves via public DNS (Google 8.8.8.8).
113
+ * We explicitly use a public resolver to avoid Tailscale's MagicDNS
114
+ * returning private CGNAT addresses (100.64.x.x) for .ts.net hostnames.
115
+ */
116
+ async function checkFunnelDnsResolves(hostname: string): Promise<boolean> {
117
+ try {
118
+ const { Resolver } = await import("node:dns/promises");
119
+ const resolver = new Resolver();
120
+ resolver.setServers(["8.8.8.8"]);
121
+ const addresses = await resolver.resolve4(hostname);
122
+ return addresses.length > 0;
123
+ } catch {
124
+ return false;
125
+ }
126
+ }
127
+
128
+ function loadPersistedState(): PersistedFunnelState | null {
129
+ try {
130
+ if (!existsSync(STATE_PATH)) return null;
131
+ const raw = JSON.parse(readFileSync(STATE_PATH, "utf-8")) as PersistedFunnelState;
132
+ if (raw && typeof raw.wasActive === "boolean" && typeof raw.port === "number") {
133
+ return raw;
134
+ }
135
+ return null;
136
+ } catch {
137
+ return null;
138
+ }
139
+ }
140
+
141
+ function persistState(state: PersistedFunnelState): void {
142
+ try {
143
+ mkdirSync(dirname(STATE_PATH), { recursive: true });
144
+ writeFileSync(STATE_PATH, JSON.stringify(state, null, 2), "utf-8");
145
+ } catch (err) {
146
+ console.warn("[tailscale] Failed to persist state:", err);
147
+ }
148
+ }
149
+
150
+ function clearPersistedState(): void {
151
+ try {
152
+ if (existsSync(STATE_PATH)) unlinkSync(STATE_PATH);
153
+ } catch {
154
+ // best-effort
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Parse `tailscale status --json` to get connection state and DNS name.
160
+ */
161
+ async function parseConnectionStatus(binary: string): Promise<{ connected: boolean; dnsName: string | null }> {
162
+ try {
163
+ const output = await execAsync(binary, ["status", "--json"]);
164
+ const status = JSON.parse(output) as {
165
+ BackendState?: string;
166
+ Self?: { DNSName?: string };
167
+ };
168
+
169
+ const backendState = status.BackendState ?? "";
170
+ const connected = backendState === "Running";
171
+ let dnsName: string | null = null;
172
+
173
+ if (connected && status.Self?.DNSName) {
174
+ // DNSName typically ends with a trailing dot — strip it
175
+ dnsName = status.Self.DNSName.replace(/\.$/, "");
176
+ }
177
+
178
+ return { connected, dnsName };
179
+ } catch {
180
+ return { connected: false, dnsName: null };
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Parse `tailscale serve status --json` to determine if a given port is being
186
+ * funneled. The output looks like:
187
+ * {
188
+ * "Web": { "machine.ts.net:443": { "Handlers": { "/": { "Proxy": "http://127.0.0.1:PORT" } } } },
189
+ * "AllowFunnel": { "machine.ts.net:443": true }
190
+ * }
191
+ */
192
+ async function parseFunnelStatus(binary: string, port: number): Promise<{ active: boolean; funnelUrl: string | null }> {
193
+ try {
194
+ const output = await execAsync(binary, ["serve", "status", "--json"]);
195
+ const config = JSON.parse(output) as {
196
+ Web?: Record<string, { Handlers?: Record<string, { Proxy?: string }> }>;
197
+ AllowFunnel?: Record<string, boolean>;
198
+ };
199
+
200
+ if (!config.Web || !config.AllowFunnel) {
201
+ return { active: false, funnelUrl: null };
202
+ }
203
+
204
+ // Match port precisely: the Proxy URL ends with ":PORT" (no trailing path beyond optional /)
205
+ const portSuffix = `:${port}`;
206
+ for (const [hostPort, isFunnel] of Object.entries(config.AllowFunnel)) {
207
+ if (!isFunnel) continue;
208
+ const handlers = config.Web[hostPort]?.Handlers;
209
+ if (!handlers) continue;
210
+ for (const handler of Object.values(handlers)) {
211
+ if (!handler.Proxy) continue;
212
+ // Exact port match: URL ends with ":PORT" or ":PORT/"
213
+ if (handler.Proxy.endsWith(portSuffix) || handler.Proxy.endsWith(`${portSuffix}/`)) {
214
+ // Extract the hostname from "machine.ts.net:443"
215
+ const hostname = hostPort.split(":")[0];
216
+ return { active: true, funnelUrl: `https://${hostname}` };
217
+ }
218
+ }
219
+ }
220
+
221
+ return { active: false, funnelUrl: null };
222
+ } catch {
223
+ return { active: false, funnelUrl: null };
224
+ }
225
+ }
226
+
227
+ // ── Public API ──────────────────────────────────────────────────────────────
228
+
229
+ /**
230
+ * Get full Tailscale status: binary availability, connection, and funnel state.
231
+ */
232
+ export async function getTailscaleStatus(port: number): Promise<TailscaleStatus> {
233
+ const binary = findBinary();
234
+ if (!binary) {
235
+ return {
236
+ installed: false,
237
+ binaryPath: null,
238
+ connected: false,
239
+ dnsName: null,
240
+ funnelActive: false,
241
+ funnelUrl: null,
242
+ error: null,
243
+ };
244
+ }
245
+
246
+ const { connected, dnsName } = await parseConnectionStatus(binary);
247
+ if (!connected) {
248
+ return {
249
+ installed: true,
250
+ binaryPath: binary,
251
+ connected: false,
252
+ dnsName: null,
253
+ funnelActive: false,
254
+ funnelUrl: null,
255
+ error: null,
256
+ };
257
+ }
258
+
259
+ const { active, funnelUrl } = await parseFunnelStatus(binary, port);
260
+ const needsOperatorMode = !active ? await checkNeedsOperatorMode(binary) : undefined;
261
+
262
+ // If funnel is active, check if the URL actually resolves publicly
263
+ let warning: string | undefined;
264
+ if (active && dnsName) {
265
+ const dnsOk = await checkFunnelDnsResolves(dnsName);
266
+ if (!dnsOk) {
267
+ warning = "DNS for this hostname is not resolving publicly. Ensure Funnel is enabled in your Tailscale admin console (admin.tailscale.com \u2192 Access Controls \u2192 nodeAttrs). DNS propagation can take up to 10 minutes on first use.";
268
+ }
269
+ }
270
+
271
+ return {
272
+ installed: true,
273
+ binaryPath: binary,
274
+ connected: true,
275
+ dnsName,
276
+ funnelActive: active,
277
+ funnelUrl,
278
+ error: null,
279
+ ...(needsOperatorMode && { needsOperatorMode }),
280
+ ...(warning && { warning }),
281
+ };
282
+ }
283
+
284
+ /**
285
+ * Start Tailscale Funnel for the given port.
286
+ * Automatically updates publicUrl in settings and persists funnel state.
287
+ */
288
+ export async function startFunnel(port: number): Promise<TailscaleStatus> {
289
+ const binary = findBinary();
290
+ if (!binary) {
291
+ return { installed: false, binaryPath: null, connected: false, dnsName: null, funnelActive: false, funnelUrl: null, error: "Tailscale is not installed" };
292
+ }
293
+
294
+ const { connected, dnsName } = await parseConnectionStatus(binary);
295
+ if (!connected) {
296
+ return { installed: true, binaryPath: binary, connected: false, dnsName: null, funnelActive: false, funnelUrl: null, error: "Tailscale is not connected. Run `tailscale up` to connect." };
297
+ }
298
+
299
+ try {
300
+ await execAsync(binary, ["funnel", "--bg", String(port)]);
301
+ } catch (err: unknown) {
302
+ const message = err instanceof Error ? err.message : String(err);
303
+ const isPermissionError = process.platform === "linux" && /permission|sudo|access denied/i.test(message);
304
+ return {
305
+ installed: true, binaryPath: binary, connected: true, dnsName,
306
+ funnelActive: false, funnelUrl: null,
307
+ error: isPermissionError
308
+ ? "Tailscale requires operator mode on Linux to manage Funnel."
309
+ : `Failed to start Funnel: ${message}`,
310
+ ...(isPermissionError && { needsOperatorMode: true }),
311
+ };
312
+ }
313
+
314
+ // Verify it's running and get the URL
315
+ const { active, funnelUrl } = await parseFunnelStatus(binary, port);
316
+
317
+ // DNS reachability is NOT checked here — it takes seconds to minutes for
318
+ // Tailscale to provision public DNS records after first enablement.
319
+ // The check runs in getTailscaleStatus() on subsequent polls instead.
320
+
321
+ if (!active || !funnelUrl) {
322
+ // Funnel command succeeded but we can't detect it yet — construct URL from DNS name
323
+ const constructedUrl = dnsName ? `https://${dnsName}` : null;
324
+ if (constructedUrl) {
325
+ updateSettings({ publicUrl: constructedUrl });
326
+ persistState({ wasActive: true, port, funnelUrl: constructedUrl, activatedAt: Date.now() });
327
+ return { installed: true, binaryPath: binary, connected: true, dnsName, funnelActive: true, funnelUrl: constructedUrl, error: null };
328
+ }
329
+ return { installed: true, binaryPath: binary, connected: true, dnsName, funnelActive: false, funnelUrl: null, error: "Funnel started but could not determine URL" };
330
+ }
331
+
332
+ updateSettings({ publicUrl: funnelUrl });
333
+ persistState({ wasActive: true, port, funnelUrl, activatedAt: Date.now() });
334
+ console.log(`[tailscale] Funnel started: ${funnelUrl} → localhost:${port}`);
335
+
336
+ return { installed: true, binaryPath: binary, connected: true, dnsName, funnelActive: true, funnelUrl, error: null };
337
+ }
338
+
339
+ /**
340
+ * Stop Tailscale Funnel for the given port.
341
+ * Clears publicUrl if it still matches the Funnel URL.
342
+ */
343
+ export async function stopFunnel(port: number): Promise<TailscaleStatus> {
344
+ const binary = findBinary();
345
+ if (!binary) {
346
+ return { installed: false, binaryPath: null, connected: false, dnsName: null, funnelActive: false, funnelUrl: null, error: "Tailscale is not installed" };
347
+ }
348
+
349
+ // Read persisted URL before stopping
350
+ const persisted = loadPersistedState();
351
+ const previousUrl = persisted?.funnelUrl ?? null;
352
+
353
+ try {
354
+ await execAsync(binary, ["funnel", String(port), "off"]);
355
+ } catch (err: unknown) {
356
+ const message = err instanceof Error ? err.message : String(err);
357
+ // Re-query actual state — funnel is likely still running after a failed stop
358
+ const { connected, dnsName } = await parseConnectionStatus(binary).catch(() => ({ connected: true, dnsName: null as string | null }));
359
+ const { active, funnelUrl } = await parseFunnelStatus(binary, port).catch(() => ({ active: true, funnelUrl: null as string | null }));
360
+ return { installed: true, binaryPath: binary, connected, dnsName, funnelActive: active, funnelUrl, error: `Failed to stop Funnel: ${message}` };
361
+ }
362
+
363
+ clearPersistedState();
364
+
365
+ // Clear publicUrl only if it matches the Funnel URL (don't overwrite manual URL)
366
+ if (previousUrl) {
367
+ const currentPublicUrl = getSettings().publicUrl;
368
+ if (currentPublicUrl === previousUrl) {
369
+ updateSettings({ publicUrl: "" });
370
+ }
371
+ }
372
+
373
+ console.log(`[tailscale] Funnel stopped for port ${port}`);
374
+
375
+ const { connected, dnsName } = await parseConnectionStatus(binary);
376
+ return { installed: true, binaryPath: binary, connected, dnsName, funnelActive: false, funnelUrl: null, error: null };
377
+ }
378
+
379
+ /**
380
+ * Check if Tailscale Funnel was previously active and verify it's still running.
381
+ * Called on server startup to keep publicUrl in sync.
382
+ */
383
+ export async function restoreIfNeeded(port: number): Promise<void> {
384
+ const persisted = loadPersistedState();
385
+ if (!persisted?.wasActive) return;
386
+
387
+ const binary = findBinary();
388
+ if (!binary) {
389
+ console.log("[tailscale] Binary not found, clearing persisted funnel state");
390
+ clearPersistedState();
391
+ return;
392
+ }
393
+
394
+ const { connected } = await parseConnectionStatus(binary);
395
+ if (!connected) {
396
+ console.log("[tailscale] Not connected, clearing persisted funnel state");
397
+ clearPersistedState();
398
+ return;
399
+ }
400
+
401
+ // Check if funnel is still active (--bg makes it a daemon, so it should survive restarts)
402
+ const { active, funnelUrl } = await parseFunnelStatus(binary, port);
403
+ if (active && funnelUrl) {
404
+ console.log(`[tailscale] Funnel still active: ${funnelUrl}`);
405
+ // Ensure publicUrl is in sync
406
+ const currentPublicUrl = getSettings().publicUrl;
407
+ if (currentPublicUrl !== funnelUrl) {
408
+ updateSettings({ publicUrl: funnelUrl });
409
+ console.log(`[tailscale] Updated publicUrl to match active Funnel: ${funnelUrl}`);
410
+ }
411
+ } else {
412
+ console.log("[tailscale] Funnel no longer active, clearing persisted state");
413
+ clearPersistedState();
414
+ // Clear publicUrl if it still points to the old Funnel URL
415
+ const currentPublicUrl = getSettings().publicUrl;
416
+ if (persisted.funnelUrl && currentPublicUrl === persisted.funnelUrl) {
417
+ updateSettings({ publicUrl: "" });
418
+ }
419
+ }
420
+ }
421
+
422
+ /**
423
+ * Best-effort cleanup on server shutdown. Uses spawnSync since process.exit follows.
424
+ * By default, leaves Funnel running (it's a system daemon).
425
+ * Set COMPANION_TAILSCALE_CLEANUP_ON_EXIT=1 to stop on shutdown.
426
+ */
427
+ export function cleanup(port: number): void {
428
+ const shouldCleanup = process.env.COMPANION_TAILSCALE_CLEANUP_ON_EXIT === "1";
429
+ if (!shouldCleanup) return;
430
+
431
+ const binary = findBinary();
432
+ if (!binary) return;
433
+
434
+ try {
435
+ spawnSync(binary, ["funnel", String(port), "off"], {
436
+ encoding: "utf-8",
437
+ timeout: 5_000,
438
+ stdio: ["pipe", "pipe", "pipe"],
439
+ });
440
+ clearPersistedState();
441
+ console.log(`[tailscale] Funnel stopped on shutdown for port ${port}`);
442
+ } catch {
443
+ // best-effort
444
+ }
445
+ }
446
+
447
+ /** Reset cached state for testing. */
448
+ export function _resetForTest(): void {
449
+ cachedBinaryPath = undefined;
450
+ binaryCacheTime = 0;
451
+ }
@@ -0,0 +1,240 @@
1
+ import type { ServerWebSocket } from "bun";
2
+ import { existsSync } from "node:fs";
3
+ import { randomUUID } from "node:crypto";
4
+ import type { SocketData } from "./ws-bridge.js";
5
+
6
+ /** Bun's PTY terminal handle exposed on proc when spawned with `terminal` option */
7
+ interface BunTerminalHandle {
8
+ write(data: string): void;
9
+ resize(cols: number, rows: number): void;
10
+ close(): void;
11
+ }
12
+
13
+ interface TerminalInstance {
14
+ id: string;
15
+ cwd: string;
16
+ containerId?: string;
17
+ proc: ReturnType<typeof Bun.spawn>;
18
+ terminal: BunTerminalHandle;
19
+ browserSockets: Set<ServerWebSocket<SocketData>>;
20
+ cols: number;
21
+ rows: number;
22
+ orphanTimer: ReturnType<typeof setTimeout> | null;
23
+ }
24
+
25
+ function resolveShell(): string {
26
+ if (process.env.SHELL && existsSync(process.env.SHELL)) return process.env.SHELL;
27
+ if (existsSync("/bin/bash")) return "/bin/bash";
28
+ return "/bin/sh";
29
+ }
30
+
31
+ export class TerminalManager {
32
+ private instances = new Map<string, TerminalInstance>();
33
+
34
+ /** Spawn a terminal in the given directory (host or container). */
35
+ spawn(cwd: string, cols = 80, rows = 24, options?: { containerId?: string }): string {
36
+ const id = randomUUID();
37
+ const containerId = options?.containerId?.trim() || undefined;
38
+ const sockets = new Set<ServerWebSocket<SocketData>>();
39
+ const shell = resolveShell();
40
+ const cmd = containerId
41
+ ? [
42
+ "docker",
43
+ "exec",
44
+ "-i",
45
+ "-t",
46
+ "-w",
47
+ cwd,
48
+ containerId,
49
+ "sh",
50
+ "-lc",
51
+ "if command -v bash >/dev/null 2>&1; then exec bash -l; else exec sh -l; fi",
52
+ ]
53
+ : [shell, "-l"];
54
+
55
+ const proc = Bun.spawn(cmd, {
56
+ cwd: containerId ? undefined : cwd,
57
+ env: { ...process.env, TERM: "xterm-256color", CLAUDECODE: undefined },
58
+ terminal: {
59
+ cols,
60
+ rows,
61
+ data: (_terminal, data) => {
62
+ // Broadcast raw PTY output as binary to all connected browsers
63
+ for (const ws of sockets) {
64
+ try {
65
+ ws.sendBinary(data);
66
+ } catch {
67
+ // socket may have closed
68
+ }
69
+ }
70
+ },
71
+ exit: () => {
72
+ // PTY stream closed — get exit code from proc
73
+ const inst = this.instances.get(id);
74
+ if (inst) {
75
+ const exitMsg = JSON.stringify({ type: "exit", exitCode: proc.exitCode ?? 0 });
76
+ for (const ws of inst.browserSockets) {
77
+ try {
78
+ ws.send(exitMsg);
79
+ } catch {
80
+ // socket may have closed
81
+ }
82
+ }
83
+ }
84
+ },
85
+ },
86
+ });
87
+
88
+ // Extract the terminal handle from the proc — Bun attaches it when spawned with `terminal` option
89
+ const terminal = (proc as any).terminal as BunTerminalHandle;
90
+ this.instances.set(id, {
91
+ id,
92
+ cwd,
93
+ containerId,
94
+ proc,
95
+ terminal,
96
+ browserSockets: sockets,
97
+ cols,
98
+ rows,
99
+ orphanTimer: null,
100
+ });
101
+ console.log(
102
+ `[terminal] Spawned terminal ${id} in ${cwd}${containerId ? ` (container ${containerId.slice(0, 12)})` : ""} (${containerId ? "docker-shell" : shell}, ${cols}x${rows})`,
103
+ );
104
+
105
+ // Handle process exit
106
+ proc.exited.then((exitCode) => {
107
+ const inst = this.instances.get(id);
108
+ if (!inst) return;
109
+ console.log(`[terminal] Terminal ${id} exited with code ${exitCode}`);
110
+ this.cleanupInstance(id);
111
+ });
112
+
113
+ return id;
114
+ }
115
+
116
+ private getTerminalIdFromSocket(ws: ServerWebSocket<SocketData>): string | null {
117
+ const data = ws.data;
118
+ if (data.kind !== "terminal") return null;
119
+ return data.terminalId;
120
+ }
121
+
122
+ private cleanupInstance(terminalId: string): void {
123
+ const inst = this.instances.get(terminalId);
124
+ if (!inst) return;
125
+ if (inst.orphanTimer) clearTimeout(inst.orphanTimer);
126
+ this.instances.delete(terminalId);
127
+ }
128
+
129
+ /** Handle a message from a browser WebSocket */
130
+ handleBrowserMessage(ws: ServerWebSocket<SocketData>, msg: string | Buffer): void {
131
+ const terminalId = this.getTerminalIdFromSocket(ws);
132
+ if (!terminalId) return;
133
+ const inst = this.instances.get(terminalId);
134
+ if (!inst) return;
135
+ try {
136
+ const str = typeof msg === "string" ? msg : msg.toString();
137
+ const parsed = JSON.parse(str);
138
+ if (parsed.type === "input" && typeof parsed.data === "string") {
139
+ inst.terminal.write(parsed.data);
140
+ } else if (parsed.type === "resize" && typeof parsed.cols === "number" && typeof parsed.rows === "number") {
141
+ this.resize(terminalId, parsed.cols, parsed.rows);
142
+ }
143
+ } catch {
144
+ // Malformed message, ignore
145
+ }
146
+ }
147
+
148
+ /** Resize the PTY */
149
+ resize(terminalId: string, cols: number, rows: number): void {
150
+ const inst = this.instances.get(terminalId);
151
+ if (!inst) return;
152
+ inst.cols = cols;
153
+ inst.rows = rows;
154
+ try {
155
+ inst.terminal.resize(cols, rows);
156
+ } catch {
157
+ // resize not available or failed
158
+ }
159
+ }
160
+
161
+ private killInstance(inst: TerminalInstance): void {
162
+ if (inst.orphanTimer) clearTimeout(inst.orphanTimer);
163
+ this.instances.delete(inst.id);
164
+
165
+ try {
166
+ inst.proc.kill();
167
+ } catch {
168
+ // process may have already exited
169
+ }
170
+
171
+ // SIGKILL fallback if SIGTERM doesn't work within 2 seconds
172
+ const pid = inst.proc.pid;
173
+ setTimeout(() => {
174
+ try {
175
+ process.kill(pid, 0); // check if still alive
176
+ inst.proc.kill(9); // SIGKILL
177
+ } catch {
178
+ // already dead, good
179
+ }
180
+ }, 2_000);
181
+
182
+ console.log(`[terminal] Killed terminal ${inst.id}`);
183
+ }
184
+
185
+ /** Kill one terminal instance. */
186
+ kill(terminalId: string): void {
187
+ const inst = this.instances.get(terminalId);
188
+ if (!inst) return;
189
+ this.killInstance(inst);
190
+ }
191
+
192
+ /** Get current terminal info */
193
+ getInfo(terminalId?: string): { id: string; cwd: string; containerId?: string } | null {
194
+ if (terminalId) {
195
+ const inst = this.instances.get(terminalId);
196
+ if (!inst) return null;
197
+ return { id: inst.id, cwd: inst.cwd, containerId: inst.containerId };
198
+ }
199
+ const first = this.instances.values().next().value as TerminalInstance | undefined;
200
+ if (!first) return null;
201
+ return { id: first.id, cwd: first.cwd, containerId: first.containerId };
202
+ }
203
+
204
+ /** Attach a browser WebSocket to the terminal */
205
+ addBrowserSocket(ws: ServerWebSocket<SocketData>): void {
206
+ const terminalId = this.getTerminalIdFromSocket(ws);
207
+ if (!terminalId) return;
208
+ const inst = this.instances.get(terminalId);
209
+ if (!inst) return;
210
+
211
+ // Cancel orphan kill timer if any
212
+ if (inst.orphanTimer) {
213
+ clearTimeout(inst.orphanTimer);
214
+ inst.orphanTimer = null;
215
+ }
216
+
217
+ inst.browserSockets.add(ws);
218
+ }
219
+
220
+ /** Remove a browser WebSocket from the terminal */
221
+ removeBrowserSocket(ws: ServerWebSocket<SocketData>): void {
222
+ const terminalId = this.getTerminalIdFromSocket(ws);
223
+ if (!terminalId) return;
224
+ const inst = this.instances.get(terminalId);
225
+ if (!inst) return;
226
+ inst.browserSockets.delete(ws);
227
+
228
+ // If no browsers remain, start a grace timer to kill the orphaned terminal
229
+ if (inst.browserSockets.size === 0) {
230
+ const id = inst.id;
231
+ inst.orphanTimer = setTimeout(() => {
232
+ const alive = this.instances.get(id);
233
+ if (alive && alive.browserSockets.size === 0) {
234
+ console.log(`[terminal] No browsers connected, killing orphaned terminal ${id}`);
235
+ this.kill(id);
236
+ }
237
+ }, 5_000);
238
+ }
239
+ }
240
+ }