@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,553 @@
1
+ import { vi, describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { EventEmitter } from "node:events";
3
+
4
+ // Mock dns/promises for checkFunnelDnsResolves (uses Resolver with public DNS)
5
+ const mockResolve4 = vi.fn();
6
+ vi.mock("node:dns/promises", () => ({
7
+ Resolver: class {
8
+ setServers() { /* no-op */ }
9
+ resolve4(...args: unknown[]) { return mockResolve4(...args); }
10
+ },
11
+ }));
12
+
13
+ // ── Mocks ───────────────────────────────────────────────────────────────────
14
+
15
+ vi.mock("./path-resolver.js", () => ({
16
+ resolveBinary: vi.fn(),
17
+ }));
18
+
19
+ vi.mock("./settings-manager.js", () => ({
20
+ updateSettings: vi.fn(),
21
+ getSettings: vi.fn(() => ({ publicUrl: "" })),
22
+ }));
23
+
24
+ // Queue of results for successive spawn calls. Each entry is either
25
+ // { stdout, code } for success or { stderr, code } for failure.
26
+ type SpawnResult = { stdout?: string; stderr?: string; code: number };
27
+ let spawnQueue: SpawnResult[] = [];
28
+
29
+ /**
30
+ * Mock spawn: returns a fake ChildProcess that emits data/close based on
31
+ * the next entry in spawnQueue. This avoids shell interpolation entirely
32
+ * (matching the real implementation).
33
+ */
34
+ function mockSpawnImpl() {
35
+ const result = spawnQueue.shift() ?? { stdout: "", code: 0 };
36
+ const proc = new EventEmitter() as ReturnType<typeof import("node:child_process").spawn>;
37
+
38
+ const stdoutEmitter = new EventEmitter();
39
+ const stderrEmitter = new EventEmitter();
40
+ (proc as unknown as Record<string, unknown>).stdout = stdoutEmitter;
41
+ (proc as unknown as Record<string, unknown>).stderr = stderrEmitter;
42
+
43
+ // Emit data + close asynchronously so the caller can attach listeners first
44
+ queueMicrotask(() => {
45
+ if (result.stdout !== undefined) {
46
+ stdoutEmitter.emit("data", Buffer.from(result.stdout));
47
+ }
48
+ if (result.stderr !== undefined) {
49
+ stderrEmitter.emit("data", Buffer.from(result.stderr));
50
+ }
51
+ proc.emit("close", result.code);
52
+ });
53
+
54
+ return proc;
55
+ }
56
+
57
+ vi.mock("node:child_process", () => ({
58
+ spawnSync: vi.fn(),
59
+ spawn: vi.fn(() => mockSpawnImpl()),
60
+ }));
61
+
62
+ vi.mock("node:fs", () => ({
63
+ existsSync: vi.fn(() => false),
64
+ readFileSync: vi.fn(),
65
+ writeFileSync: vi.fn(),
66
+ mkdirSync: vi.fn(),
67
+ unlinkSync: vi.fn(),
68
+ }));
69
+
70
+ import { resolveBinary } from "./path-resolver.js";
71
+ import { updateSettings, getSettings } from "./settings-manager.js";
72
+ import { spawnSync } from "node:child_process";
73
+ import { existsSync, readFileSync } from "node:fs";
74
+ import {
75
+ getTailscaleStatus,
76
+ startFunnel,
77
+ stopFunnel,
78
+ restoreIfNeeded,
79
+ cleanup,
80
+ _resetForTest,
81
+ } from "./tailscale-manager.js";
82
+
83
+ const mockResolveBinary = vi.mocked(resolveBinary);
84
+ const mockSpawnSync = vi.mocked(spawnSync);
85
+ const mockUpdateSettings = vi.mocked(updateSettings);
86
+ const mockGetSettings = vi.mocked(getSettings);
87
+ const mockExistsSync = vi.mocked(existsSync);
88
+ const mockReadFileSync = vi.mocked(readFileSync);
89
+
90
+ // Sample JSON outputs from the tailscale CLI
91
+ const CONNECTED_STATUS_JSON = JSON.stringify({
92
+ BackendState: "Running",
93
+ Self: { DNSName: "my-machine.tail1234.ts.net." },
94
+ });
95
+
96
+ const DISCONNECTED_STATUS_JSON = JSON.stringify({
97
+ BackendState: "Stopped",
98
+ Self: { DNSName: "" },
99
+ });
100
+
101
+ const FUNNEL_ACTIVE_JSON = JSON.stringify({
102
+ Web: {
103
+ "my-machine.tail1234.ts.net:443": {
104
+ Handlers: { "/": { Proxy: "http://127.0.0.1:3456" } },
105
+ },
106
+ },
107
+ AllowFunnel: { "my-machine.tail1234.ts.net:443": true },
108
+ });
109
+
110
+ const FUNNEL_INACTIVE_JSON = JSON.stringify({
111
+ Web: {},
112
+ AllowFunnel: {},
113
+ });
114
+
115
+ /** Helper to enqueue a successful spawn result */
116
+ function enqueueSpawnSuccess(stdout: string) {
117
+ spawnQueue.push({ stdout, code: 0 });
118
+ }
119
+
120
+ /** Helper to enqueue a failed spawn result */
121
+ function enqueueSpawnFailure(stderr: string, code = 1) {
122
+ spawnQueue.push({ stderr, code });
123
+ }
124
+
125
+ beforeEach(() => {
126
+ vi.clearAllMocks();
127
+ spawnQueue = [];
128
+ _resetForTest();
129
+ // Default: DNS resolves successfully (override per-test when needed)
130
+ mockResolve4.mockResolvedValue(["100.64.0.1"]);
131
+ });
132
+
133
+ afterEach(() => {
134
+ _resetForTest();
135
+ });
136
+
137
+ // ── getTailscaleStatus ──────────────────────────────────────────────────────
138
+
139
+ describe("getTailscaleStatus", () => {
140
+ it("returns installed=false when binary is not found", async () => {
141
+ mockResolveBinary.mockReturnValue(null);
142
+ const status = await getTailscaleStatus(3456);
143
+
144
+ expect(status.installed).toBe(false);
145
+ expect(status.binaryPath).toBeNull();
146
+ expect(status.connected).toBe(false);
147
+ expect(status.funnelActive).toBe(false);
148
+ expect(status.error).toBeNull();
149
+ });
150
+
151
+ it("returns connected=false when Tailscale is not running", async () => {
152
+ mockResolveBinary.mockReturnValue("/usr/bin/tailscale");
153
+ // parseConnectionStatus: `tailscale status --json`
154
+ enqueueSpawnSuccess(DISCONNECTED_STATUS_JSON);
155
+
156
+ const status = await getTailscaleStatus(3456);
157
+
158
+ expect(status.installed).toBe(true);
159
+ expect(status.binaryPath).toBe("/usr/bin/tailscale");
160
+ expect(status.connected).toBe(false);
161
+ expect(status.dnsName).toBeNull();
162
+ expect(status.funnelActive).toBe(false);
163
+ });
164
+
165
+ it("parses connected status and DNS name correctly", async () => {
166
+ mockResolveBinary.mockReturnValue("/usr/bin/tailscale");
167
+ // First call: tailscale status --json
168
+ enqueueSpawnSuccess(CONNECTED_STATUS_JSON);
169
+ // Second call: tailscale serve status --json
170
+ enqueueSpawnSuccess(FUNNEL_INACTIVE_JSON);
171
+
172
+ const status = await getTailscaleStatus(3456);
173
+
174
+ expect(status.installed).toBe(true);
175
+ expect(status.connected).toBe(true);
176
+ expect(status.dnsName).toBe("my-machine.tail1234.ts.net");
177
+ expect(status.funnelActive).toBe(false);
178
+ expect(status.funnelUrl).toBeNull();
179
+ });
180
+
181
+ it("detects active funnel for the correct port", async () => {
182
+ mockResolveBinary.mockReturnValue("/usr/bin/tailscale");
183
+ enqueueSpawnSuccess(CONNECTED_STATUS_JSON);
184
+ enqueueSpawnSuccess(FUNNEL_ACTIVE_JSON);
185
+
186
+ const status = await getTailscaleStatus(3456);
187
+
188
+ expect(status.funnelActive).toBe(true);
189
+ expect(status.funnelUrl).toBe("https://my-machine.tail1234.ts.net");
190
+ });
191
+
192
+ it("does not report funnel for a different port", async () => {
193
+ mockResolveBinary.mockReturnValue("/usr/bin/tailscale");
194
+ enqueueSpawnSuccess(CONNECTED_STATUS_JSON);
195
+ enqueueSpawnSuccess(FUNNEL_ACTIVE_JSON);
196
+
197
+ // Port 9999 is not in the funnel config (config has 3456)
198
+ const status = await getTailscaleStatus(9999);
199
+
200
+ expect(status.funnelActive).toBe(false);
201
+ expect(status.funnelUrl).toBeNull();
202
+ });
203
+
204
+ // Regression: port 34 should NOT match a funnel configured for port 3456
205
+ it("does not false-positive match port substring (e.g. 34 vs 3456)", async () => {
206
+ mockResolveBinary.mockReturnValue("/usr/bin/tailscale");
207
+ enqueueSpawnSuccess(CONNECTED_STATUS_JSON);
208
+ enqueueSpawnSuccess(FUNNEL_ACTIVE_JSON); // has port 3456
209
+
210
+ const status = await getTailscaleStatus(34);
211
+
212
+ expect(status.funnelActive).toBe(false);
213
+ expect(status.funnelUrl).toBeNull();
214
+ });
215
+
216
+ it("handles spawn errors gracefully", async () => {
217
+ mockResolveBinary.mockReturnValue("/usr/bin/tailscale");
218
+ // parseConnectionStatus fails
219
+ enqueueSpawnFailure("command failed");
220
+
221
+ const status = await getTailscaleStatus(3456);
222
+
223
+ expect(status.installed).toBe(true);
224
+ expect(status.connected).toBe(false);
225
+ });
226
+
227
+ it("returns needsOperatorMode=true on Linux when operator is not set", async () => {
228
+ const origPlatform = process.platform;
229
+ Object.defineProperty(process, "platform", { value: "linux", configurable: true });
230
+
231
+ mockResolveBinary.mockReturnValue("/usr/bin/tailscale");
232
+ enqueueSpawnSuccess(CONNECTED_STATUS_JSON);
233
+ enqueueSpawnSuccess(FUNNEL_INACTIVE_JSON);
234
+ // checkNeedsOperatorMode: `tailscale debug prefs`
235
+ enqueueSpawnSuccess(JSON.stringify({ OperatorUser: "" }));
236
+
237
+ const status = await getTailscaleStatus(3456);
238
+
239
+ expect(status.needsOperatorMode).toBe(true);
240
+
241
+ Object.defineProperty(process, "platform", { value: origPlatform, configurable: true });
242
+ });
243
+
244
+ it("returns needsOperatorMode=false on Linux when operator IS set", async () => {
245
+ const origPlatform = process.platform;
246
+ Object.defineProperty(process, "platform", { value: "linux", configurable: true });
247
+
248
+ mockResolveBinary.mockReturnValue("/usr/bin/tailscale");
249
+ enqueueSpawnSuccess(CONNECTED_STATUS_JSON);
250
+ enqueueSpawnSuccess(FUNNEL_INACTIVE_JSON);
251
+ enqueueSpawnSuccess(JSON.stringify({ OperatorUser: "myuser" }));
252
+
253
+ const status = await getTailscaleStatus(3456);
254
+
255
+ expect(status.needsOperatorMode).toBeUndefined();
256
+
257
+ Object.defineProperty(process, "platform", { value: origPlatform, configurable: true });
258
+ });
259
+
260
+ it("does not check operator mode on macOS", async () => {
261
+ const origPlatform = process.platform;
262
+ Object.defineProperty(process, "platform", { value: "darwin", configurable: true });
263
+
264
+ mockResolveBinary.mockReturnValue("/usr/bin/tailscale");
265
+ enqueueSpawnSuccess(CONNECTED_STATUS_JSON);
266
+ enqueueSpawnSuccess(FUNNEL_INACTIVE_JSON);
267
+ // No additional spawn for debug prefs expected
268
+
269
+ const status = await getTailscaleStatus(3456);
270
+
271
+ expect(status.needsOperatorMode).toBeUndefined();
272
+
273
+ Object.defineProperty(process, "platform", { value: origPlatform, configurable: true });
274
+ });
275
+
276
+ it("returns DNS warning when funnel is active but hostname does not resolve", async () => {
277
+ mockResolveBinary.mockReturnValue("/usr/bin/tailscale");
278
+ enqueueSpawnSuccess(CONNECTED_STATUS_JSON);
279
+ enqueueSpawnSuccess(FUNNEL_ACTIVE_JSON);
280
+ // DNS check fails
281
+ mockResolve4.mockRejectedValueOnce(new Error("NXDOMAIN"));
282
+
283
+ const status = await getTailscaleStatus(3456);
284
+
285
+ expect(status.funnelActive).toBe(true);
286
+ expect(status.warning).toContain("not resolving publicly");
287
+ });
288
+
289
+ it("returns no warning when funnel is active and hostname resolves", async () => {
290
+ mockResolveBinary.mockReturnValue("/usr/bin/tailscale");
291
+ enqueueSpawnSuccess(CONNECTED_STATUS_JSON);
292
+ enqueueSpawnSuccess(FUNNEL_ACTIVE_JSON);
293
+ // DNS check succeeds
294
+ mockResolve4.mockResolvedValueOnce(["100.64.0.1"]);
295
+
296
+ const status = await getTailscaleStatus(3456);
297
+
298
+ expect(status.funnelActive).toBe(true);
299
+ expect(status.warning).toBeUndefined();
300
+ });
301
+ });
302
+
303
+ // ── startFunnel ─────────────────────────────────────────────────────────────
304
+
305
+ describe("startFunnel", () => {
306
+ it("returns error when Tailscale is not installed", async () => {
307
+ mockResolveBinary.mockReturnValue(null);
308
+ const result = await startFunnel(3456);
309
+
310
+ expect(result.error).toBe("Tailscale is not installed");
311
+ expect(result.installed).toBe(false);
312
+ });
313
+
314
+ it("returns error when Tailscale is not connected", async () => {
315
+ mockResolveBinary.mockReturnValue("/usr/bin/tailscale");
316
+ enqueueSpawnSuccess(DISCONNECTED_STATUS_JSON);
317
+
318
+ const result = await startFunnel(3456);
319
+
320
+ expect(result.error).toContain("not connected");
321
+ expect(result.connected).toBe(false);
322
+ });
323
+
324
+ it("runs the funnel command and updates settings on success", async () => {
325
+ mockResolveBinary.mockReturnValue("/usr/bin/tailscale");
326
+ // parseConnectionStatus
327
+ enqueueSpawnSuccess(CONNECTED_STATUS_JSON);
328
+ // tailscale funnel --bg 3456 (succeeds)
329
+ enqueueSpawnSuccess("");
330
+ // parseFunnelStatus (verify)
331
+ enqueueSpawnSuccess(FUNNEL_ACTIVE_JSON);
332
+
333
+ const result = await startFunnel(3456);
334
+
335
+ expect(result.funnelActive).toBe(true);
336
+ expect(result.funnelUrl).toBe("https://my-machine.tail1234.ts.net");
337
+ expect(result.error).toBeNull();
338
+ expect(mockUpdateSettings).toHaveBeenCalledWith({ publicUrl: "https://my-machine.tail1234.ts.net" });
339
+ });
340
+
341
+ it("returns needsOperatorMode and clean message on permission failure (Linux)", async () => {
342
+ // Reactive permission detection is Linux-only to avoid false positives on macOS
343
+ const origPlatform = process.platform;
344
+ Object.defineProperty(process, "platform", { value: "linux", configurable: true });
345
+
346
+ mockResolveBinary.mockReturnValue("/usr/bin/tailscale");
347
+ enqueueSpawnSuccess(CONNECTED_STATUS_JSON);
348
+ // funnel command fails with permission error
349
+ enqueueSpawnFailure("access denied: permission required");
350
+
351
+ const result = await startFunnel(3456);
352
+
353
+ expect(result.error).toBe("Tailscale requires operator mode on Linux to manage Funnel.");
354
+ expect(result.needsOperatorMode).toBe(true);
355
+ expect(result.funnelActive).toBe(false);
356
+
357
+ Object.defineProperty(process, "platform", { value: origPlatform, configurable: true });
358
+ });
359
+
360
+ it("does not set needsOperatorMode on permission failure on macOS", async () => {
361
+ // On macOS, permission errors are not operator mode related
362
+ const origPlatform = process.platform;
363
+ Object.defineProperty(process, "platform", { value: "darwin", configurable: true });
364
+
365
+ mockResolveBinary.mockReturnValue("/usr/bin/tailscale");
366
+ enqueueSpawnSuccess(CONNECTED_STATUS_JSON);
367
+ enqueueSpawnFailure("access denied: permission required");
368
+
369
+ const result = await startFunnel(3456);
370
+
371
+ expect(result.error).toContain("Failed to start Funnel");
372
+ expect(result.needsOperatorMode).toBeUndefined();
373
+ expect(result.funnelActive).toBe(false);
374
+
375
+ Object.defineProperty(process, "platform", { value: origPlatform, configurable: true });
376
+ });
377
+
378
+ it("does not check DNS immediately after start (deferred to status polls)", async () => {
379
+ // DNS check is deferred to getTailscaleStatus() to avoid false warnings
380
+ // during DNS propagation after first enablement.
381
+ mockResolveBinary.mockReturnValue("/usr/bin/tailscale");
382
+ enqueueSpawnSuccess(CONNECTED_STATUS_JSON);
383
+ enqueueSpawnSuccess(""); // funnel command succeeds
384
+ enqueueSpawnSuccess(FUNNEL_ACTIVE_JSON);
385
+
386
+ const result = await startFunnel(3456);
387
+
388
+ expect(result.funnelActive).toBe(true);
389
+ expect(result.funnelUrl).toBe("https://my-machine.tail1234.ts.net");
390
+ expect(result.warning).toBeUndefined();
391
+ });
392
+
393
+ it("constructs URL from DNS name when serve status is empty", async () => {
394
+ mockResolveBinary.mockReturnValue("/usr/bin/tailscale");
395
+ enqueueSpawnSuccess(CONNECTED_STATUS_JSON);
396
+ enqueueSpawnSuccess(""); // funnel command succeeds
397
+ enqueueSpawnSuccess(FUNNEL_INACTIVE_JSON); // serve status doesn't show it yet
398
+
399
+ const result = await startFunnel(3456);
400
+
401
+ // Falls back to constructing URL from DNS name
402
+ expect(result.funnelActive).toBe(true);
403
+ expect(result.funnelUrl).toBe("https://my-machine.tail1234.ts.net");
404
+ expect(mockUpdateSettings).toHaveBeenCalled();
405
+ });
406
+ });
407
+
408
+ // ── stopFunnel ──────────────────────────────────────────────────────────────
409
+
410
+ describe("stopFunnel", () => {
411
+ it("runs the off command and clears publicUrl when it matches", async () => {
412
+ mockResolveBinary.mockReturnValue("/usr/bin/tailscale");
413
+ // Persisted state exists
414
+ mockExistsSync.mockReturnValue(true);
415
+ mockReadFileSync.mockReturnValue(JSON.stringify({
416
+ wasActive: true,
417
+ port: 3456,
418
+ funnelUrl: "https://my-machine.tail1234.ts.net",
419
+ activatedAt: Date.now(),
420
+ }));
421
+ mockGetSettings.mockReturnValue({
422
+ publicUrl: "https://my-machine.tail1234.ts.net",
423
+ } as ReturnType<typeof getSettings>);
424
+
425
+ // stop command succeeds
426
+ enqueueSpawnSuccess("");
427
+ // parseConnectionStatus for final status
428
+ enqueueSpawnSuccess(CONNECTED_STATUS_JSON);
429
+
430
+ const result = await stopFunnel(3456);
431
+
432
+ expect(result.funnelActive).toBe(false);
433
+ expect(result.error).toBeNull();
434
+ expect(mockUpdateSettings).toHaveBeenCalledWith({ publicUrl: "" });
435
+ });
436
+
437
+ it("does not clear publicUrl if it was manually changed", async () => {
438
+ mockResolveBinary.mockReturnValue("/usr/bin/tailscale");
439
+ mockExistsSync.mockReturnValue(true);
440
+ mockReadFileSync.mockReturnValue(JSON.stringify({
441
+ wasActive: true,
442
+ port: 3456,
443
+ funnelUrl: "https://my-machine.tail1234.ts.net",
444
+ activatedAt: Date.now(),
445
+ }));
446
+ // User manually set a different URL
447
+ mockGetSettings.mockReturnValue({
448
+ publicUrl: "https://custom-domain.example.com",
449
+ } as ReturnType<typeof getSettings>);
450
+
451
+ enqueueSpawnSuccess(""); // stop
452
+ enqueueSpawnSuccess(CONNECTED_STATUS_JSON); // status
453
+
454
+ await stopFunnel(3456);
455
+
456
+ // Should NOT have called updateSettings since publicUrl doesn't match
457
+ expect(mockUpdateSettings).not.toHaveBeenCalled();
458
+ });
459
+
460
+ it("returns error and re-queries actual state when stop command fails", async () => {
461
+ mockResolveBinary.mockReturnValue("/usr/bin/tailscale");
462
+ mockExistsSync.mockReturnValue(false);
463
+ enqueueSpawnFailure("stop failed");
464
+ // After failure, stopFunnel re-queries connection + funnel status
465
+ enqueueSpawnSuccess(CONNECTED_STATUS_JSON);
466
+ enqueueSpawnSuccess(FUNNEL_ACTIVE_JSON);
467
+
468
+ const result = await stopFunnel(3456);
469
+
470
+ expect(result.error).toContain("Failed to stop Funnel");
471
+ // Should reflect actual state from re-query, not hardcoded values
472
+ expect(result.funnelActive).toBe(true);
473
+ expect(result.connected).toBe(true);
474
+ expect(result.dnsName).toBe("my-machine.tail1234.ts.net");
475
+ });
476
+ });
477
+
478
+ // ── restoreIfNeeded ─────────────────────────────────────────────────────────
479
+
480
+ describe("restoreIfNeeded", () => {
481
+ it("does nothing when no persisted state exists", async () => {
482
+ mockExistsSync.mockReturnValue(false);
483
+ await restoreIfNeeded(3456);
484
+ // No binary resolution attempted
485
+ expect(mockResolveBinary).not.toHaveBeenCalled();
486
+ });
487
+
488
+ it("clears state when binary is not found", async () => {
489
+ mockExistsSync.mockReturnValue(true);
490
+ mockReadFileSync.mockReturnValue(JSON.stringify({
491
+ wasActive: true,
492
+ port: 3456,
493
+ funnelUrl: "https://my-machine.tail1234.ts.net",
494
+ activatedAt: Date.now(),
495
+ }));
496
+ mockResolveBinary.mockReturnValue(null);
497
+
498
+ await restoreIfNeeded(3456);
499
+ // Should not crash, just log and clear
500
+ });
501
+
502
+ it("updates publicUrl when funnel is still active", async () => {
503
+ mockExistsSync.mockReturnValue(true);
504
+ mockReadFileSync.mockReturnValue(JSON.stringify({
505
+ wasActive: true,
506
+ port: 3456,
507
+ funnelUrl: "https://my-machine.tail1234.ts.net",
508
+ activatedAt: Date.now(),
509
+ }));
510
+ mockResolveBinary.mockReturnValue("/usr/bin/tailscale");
511
+ // parseConnectionStatus
512
+ enqueueSpawnSuccess(CONNECTED_STATUS_JSON);
513
+ // parseFunnelStatus
514
+ enqueueSpawnSuccess(FUNNEL_ACTIVE_JSON);
515
+ mockGetSettings.mockReturnValue({ publicUrl: "" } as ReturnType<typeof getSettings>);
516
+
517
+ await restoreIfNeeded(3456);
518
+
519
+ expect(mockUpdateSettings).toHaveBeenCalledWith({ publicUrl: "https://my-machine.tail1234.ts.net" });
520
+ });
521
+ });
522
+
523
+ // ── cleanup ─────────────────────────────────────────────────────────────────
524
+ // cleanup() uses spawnSync (synchronous) because it runs before process.exit
525
+
526
+ describe("cleanup", () => {
527
+ it("is a no-op when COMPANION_TAILSCALE_CLEANUP_ON_EXIT is not set", () => {
528
+ delete process.env.COMPANION_TAILSCALE_CLEANUP_ON_EXIT;
529
+ mockResolveBinary.mockReturnValue("/usr/bin/tailscale");
530
+
531
+ cleanup(3456);
532
+
533
+ // spawnSync should not have been called for the funnel off command
534
+ expect(mockSpawnSync).not.toHaveBeenCalled();
535
+ });
536
+
537
+ it("stops funnel when COMPANION_TAILSCALE_CLEANUP_ON_EXIT=1", () => {
538
+ process.env.COMPANION_TAILSCALE_CLEANUP_ON_EXIT = "1";
539
+ mockResolveBinary.mockReturnValue("/usr/bin/tailscale");
540
+ mockSpawnSync.mockReturnValue({ status: 0 } as ReturnType<typeof spawnSync>);
541
+
542
+ cleanup(3456);
543
+
544
+ // Should call spawnSync with arg array (no shell interpolation)
545
+ expect(mockSpawnSync).toHaveBeenCalledWith(
546
+ "/usr/bin/tailscale",
547
+ ["funnel", "3456", "off"],
548
+ expect.any(Object),
549
+ );
550
+
551
+ delete process.env.COMPANION_TAILSCALE_CLEANUP_ON_EXIT;
552
+ });
553
+ });