@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.
- package/bin/cli.ts +168 -0
- package/bin/ctl.ts +528 -0
- package/bin/generate-token.ts +28 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/assets/AgentsPage-DCFhrJ28.js +13 -0
- package/dist/assets/CronManager-EGwLJONv.js +1 -0
- package/dist/assets/IntegrationsPage-CTMRnbQS.js +1 -0
- package/dist/assets/LinearOAuthSettingsPage-CgQFMIgr.js +1 -0
- package/dist/assets/LinearSettingsPage-C9nok1qi.js +1 -0
- package/dist/assets/Playground-BV3k0RbV.js +109 -0
- package/dist/assets/PromptsPage-CFojqNKP.js +4 -0
- package/dist/assets/RunsPage-DUJ1QUSa.js +1 -0
- package/dist/assets/SandboxManager-CrVQ-VU_.js +8 -0
- package/dist/assets/SettingsPage-D1fPCL19.js +1 -0
- package/dist/assets/TailscalePage-D06cyvyC.js +1 -0
- package/dist/assets/index-BhUa1e6X.css +1 -0
- package/dist/assets/index-DkqeP-R9.js +134 -0
- package/dist/assets/sw-register-BibwRdvC.js +1 -0
- package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
- package/dist/favicon.svg +8 -0
- package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
- package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
- package/dist/icon-192.png +0 -0
- package/dist/icon-512.png +0 -0
- package/dist/index.html +20 -0
- package/dist/logo-codex.svg +14 -0
- package/dist/logo-docker.svg +4 -0
- package/dist/logo.svg +14 -0
- package/dist/manifest.json +24 -0
- package/dist/sw.js +2 -0
- package/package.json +104 -0
- package/server/agent-cron-migrator.test.ts +610 -0
- package/server/agent-cron-migrator.ts +85 -0
- package/server/agent-executor.test.ts +1108 -0
- package/server/agent-executor.ts +346 -0
- package/server/agent-store.test.ts +588 -0
- package/server/agent-store.ts +185 -0
- package/server/agent-types.ts +138 -0
- package/server/ai-validation-settings.test.ts +128 -0
- package/server/ai-validation-settings.ts +35 -0
- package/server/ai-validator.test.ts +387 -0
- package/server/ai-validator.ts +271 -0
- package/server/auth-manager.test.ts +83 -0
- package/server/auth-manager.ts +150 -0
- package/server/auto-namer.test.ts +252 -0
- package/server/auto-namer.ts +78 -0
- package/server/backend-adapter.test.ts +38 -0
- package/server/backend-adapter.ts +54 -0
- package/server/cache-headers.test.ts +98 -0
- package/server/cache-headers.ts +61 -0
- package/server/claude-adapter.test.ts +1363 -0
- package/server/claude-adapter.ts +889 -0
- package/server/claude-container-auth.test.ts +44 -0
- package/server/claude-container-auth.ts +30 -0
- package/server/claude-protocol-contract.test.ts +71 -0
- package/server/claude-protocol-drift.test.ts +78 -0
- package/server/claude-session-discovery.test.ts +132 -0
- package/server/claude-session-discovery.ts +157 -0
- package/server/claude-session-history.test.ts +158 -0
- package/server/claude-session-history.ts +410 -0
- package/server/cli-launcher.test.ts +1343 -0
- package/server/cli-launcher.ts +1298 -0
- package/server/cli.test.ts +16 -0
- package/server/codex-adapter.test.ts +5545 -0
- package/server/codex-adapter.ts +3062 -0
- package/server/codex-container-auth.test.ts +50 -0
- package/server/codex-container-auth.ts +24 -0
- package/server/codex-home.test.ts +61 -0
- package/server/codex-home.ts +26 -0
- package/server/codex-protocol-contract.test.ts +96 -0
- package/server/codex-protocol-drift.test.ts +123 -0
- package/server/codex-ws-proxy.cjs +226 -0
- package/server/commands-discovery.test.ts +179 -0
- package/server/commands-discovery.ts +81 -0
- package/server/constants.ts +7 -0
- package/server/container-manager.test.ts +1211 -0
- package/server/container-manager.ts +1053 -0
- package/server/cron-scheduler.test.ts +957 -0
- package/server/cron-scheduler.ts +243 -0
- package/server/cron-store.test.ts +422 -0
- package/server/cron-store.ts +148 -0
- package/server/cron-types.ts +63 -0
- package/server/env-manager.test.ts +268 -0
- package/server/env-manager.ts +161 -0
- package/server/event-bus-types.ts +64 -0
- package/server/event-bus.test.ts +244 -0
- package/server/event-bus.ts +124 -0
- package/server/execution-store.test.ts +307 -0
- package/server/execution-store.ts +170 -0
- package/server/fs-utils.ts +15 -0
- package/server/git-utils.test.ts +938 -0
- package/server/git-utils.ts +421 -0
- package/server/github-pr.test.ts +498 -0
- package/server/github-pr.ts +379 -0
- package/server/image-pull-manager.test.ts +303 -0
- package/server/image-pull-manager.ts +279 -0
- package/server/index.ts +396 -0
- package/server/linear-agent-bridge.test.ts +1157 -0
- package/server/linear-agent-bridge.ts +629 -0
- package/server/linear-agent.test.ts +473 -0
- package/server/linear-agent.ts +479 -0
- package/server/linear-cache.test.ts +136 -0
- package/server/linear-cache.ts +113 -0
- package/server/linear-connections.test.ts +350 -0
- package/server/linear-connections.ts +231 -0
- package/server/linear-credential-migration.test.ts +337 -0
- package/server/linear-credential-migration.ts +63 -0
- package/server/linear-oauth-connections-migration.test.ts +268 -0
- package/server/linear-oauth-connections.test.ts +365 -0
- package/server/linear-oauth-connections.ts +294 -0
- package/server/linear-project-manager.test.ts +162 -0
- package/server/linear-project-manager.ts +111 -0
- package/server/linear-prompt-builder.test.ts +74 -0
- package/server/linear-prompt-builder.ts +61 -0
- package/server/linear-staging.test.ts +276 -0
- package/server/linear-staging.ts +142 -0
- package/server/logger.test.ts +393 -0
- package/server/logger.ts +259 -0
- package/server/metrics-collector.test.ts +413 -0
- package/server/metrics-collector.ts +350 -0
- package/server/metrics-types.ts +108 -0
- package/server/middleware/managed-auth.test.ts +264 -0
- package/server/middleware/managed-auth.ts +195 -0
- package/server/novnc-proxy.test.ts +333 -0
- package/server/novnc-proxy.ts +99 -0
- package/server/path-resolver.test.ts +552 -0
- package/server/path-resolver.ts +186 -0
- package/server/paths.test.ts +31 -0
- package/server/paths.ts +11 -0
- package/server/pr-poller.test.ts +191 -0
- package/server/pr-poller.ts +162 -0
- package/server/prompt-manager.test.ts +211 -0
- package/server/prompt-manager.ts +211 -0
- package/server/protocol/claude-upstream/README.md +19 -0
- package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
- package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
- package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
- package/server/protocol/codex-upstream/README.md +18 -0
- package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
- package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
- package/server/protocol-monitor.ts +50 -0
- package/server/recorder.test.ts +454 -0
- package/server/recorder.ts +374 -0
- package/server/recording-hub/compat-validator.test.ts +150 -0
- package/server/recording-hub/compat-validator.ts +284 -0
- package/server/recording-hub/diagnostics.test.ts +140 -0
- package/server/recording-hub/diagnostics.ts +299 -0
- package/server/recording-hub/hub-config.test.ts +44 -0
- package/server/recording-hub/hub-config.ts +19 -0
- package/server/recording-hub/hub-routes.test.ts +417 -0
- package/server/recording-hub/hub-routes.ts +236 -0
- package/server/recording-hub/hub-store.test.ts +262 -0
- package/server/recording-hub/hub-store.ts +265 -0
- package/server/recording-hub/replay-adapter.test.ts +294 -0
- package/server/recording-hub/replay-adapter.ts +207 -0
- package/server/relay-client.test.ts +337 -0
- package/server/relay-client.ts +320 -0
- package/server/replay.test.ts +200 -0
- package/server/replay.ts +78 -0
- package/server/routes/agent-routes.test.ts +1400 -0
- package/server/routes/agent-routes.ts +409 -0
- package/server/routes/cron-routes.test.ts +881 -0
- package/server/routes/cron-routes.ts +103 -0
- package/server/routes/env-routes.test.ts +383 -0
- package/server/routes/env-routes.ts +95 -0
- package/server/routes/fs-routes.test.ts +1198 -0
- package/server/routes/fs-routes.ts +605 -0
- package/server/routes/git-routes.test.ts +813 -0
- package/server/routes/git-routes.ts +97 -0
- package/server/routes/linear-agent-routes.test.ts +721 -0
- package/server/routes/linear-agent-routes.ts +304 -0
- package/server/routes/linear-connection-routes.test.ts +927 -0
- package/server/routes/linear-connection-routes.ts +244 -0
- package/server/routes/linear-oauth-connection-routes.test.ts +406 -0
- package/server/routes/linear-oauth-connection-routes.ts +129 -0
- package/server/routes/linear-routes.test.ts +1510 -0
- package/server/routes/linear-routes.ts +953 -0
- package/server/routes/metrics-routes.test.ts +103 -0
- package/server/routes/metrics-routes.ts +13 -0
- package/server/routes/prompt-routes.ts +67 -0
- package/server/routes/sandbox-routes.test.ts +513 -0
- package/server/routes/sandbox-routes.ts +127 -0
- package/server/routes/settings-routes.ts +270 -0
- package/server/routes/skills-routes.test.ts +690 -0
- package/server/routes/skills-routes.ts +100 -0
- package/server/routes/system-routes.test.ts +637 -0
- package/server/routes/system-routes.ts +228 -0
- package/server/routes/tailscale-routes.test.ts +176 -0
- package/server/routes/tailscale-routes.ts +22 -0
- package/server/routes.test.ts +4655 -0
- package/server/routes.ts +1277 -0
- package/server/sandbox-manager.test.ts +378 -0
- package/server/sandbox-manager.ts +168 -0
- package/server/service.test.ts +1419 -0
- package/server/service.ts +718 -0
- package/server/session-creation-service.test.ts +661 -0
- package/server/session-creation-service.ts +473 -0
- package/server/session-git-info.ts +104 -0
- package/server/session-linear-issues.test.ts +118 -0
- package/server/session-linear-issues.ts +88 -0
- package/server/session-names.test.ts +94 -0
- package/server/session-names.ts +67 -0
- package/server/session-orchestrator.test.ts +1784 -0
- package/server/session-orchestrator.ts +973 -0
- package/server/session-state-machine.test.ts +606 -0
- package/server/session-state-machine.ts +207 -0
- package/server/session-store.test.ts +290 -0
- package/server/session-store.ts +146 -0
- package/server/session-types.ts +509 -0
- package/server/settings-manager.test.ts +275 -0
- package/server/settings-manager.ts +173 -0
- package/server/tailscale-manager.test.ts +553 -0
- package/server/tailscale-manager.ts +451 -0
- package/server/terminal-manager.ts +240 -0
- package/server/update-checker.test.ts +306 -0
- package/server/update-checker.ts +197 -0
- package/server/usage-limits.test.ts +536 -0
- package/server/usage-limits.ts +225 -0
- package/server/worktree-tracker.test.ts +243 -0
- package/server/worktree-tracker.ts +84 -0
- package/server/ws-auth.test.ts +59 -0
- package/server/ws-auth.ts +41 -0
- package/server/ws-bridge-browser-ingest.test.ts +272 -0
- package/server/ws-bridge-browser-ingest.ts +72 -0
- package/server/ws-bridge-browser.ts +112 -0
- package/server/ws-bridge-cli-ingest.test.ts +302 -0
- package/server/ws-bridge-cli-ingest.ts +81 -0
- package/server/ws-bridge-codex.test.ts +1837 -0
- package/server/ws-bridge-codex.ts +266 -0
- package/server/ws-bridge-controls.test.ts +124 -0
- package/server/ws-bridge-controls.ts +20 -0
- package/server/ws-bridge-persist.test.ts +296 -0
- package/server/ws-bridge-persist.ts +66 -0
- package/server/ws-bridge-publish.test.ts +234 -0
- package/server/ws-bridge-publish.ts +79 -0
- package/server/ws-bridge-replay.test.ts +44 -0
- package/server/ws-bridge-replay.ts +61 -0
- package/server/ws-bridge-types.ts +106 -0
- package/server/ws-bridge.test.ts +4777 -0
- 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
|
+
});
|