@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,337 @@
|
|
|
1
|
+
import { vi, describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { RelayClient } from "./relay-client.js";
|
|
3
|
+
|
|
4
|
+
// ─── Mock WebSocket ─────────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
/** Minimal mock WebSocket with event listener support for testing. */
|
|
7
|
+
class MockWebSocket {
|
|
8
|
+
static OPEN = 1;
|
|
9
|
+
static CLOSED = 3;
|
|
10
|
+
|
|
11
|
+
readyState = MockWebSocket.OPEN;
|
|
12
|
+
private listeners = new Map<string, Set<Function>>();
|
|
13
|
+
|
|
14
|
+
sent: string[] = [];
|
|
15
|
+
|
|
16
|
+
constructor(public url: string) {
|
|
17
|
+
// Auto-fire "open" on next tick so tests can add listeners first
|
|
18
|
+
setTimeout(() => this.fireEvent("open", {}), 0);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
addEventListener(event: string, fn: Function) {
|
|
22
|
+
if (!this.listeners.has(event)) this.listeners.set(event, new Set());
|
|
23
|
+
this.listeners.get(event)!.add(fn);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
removeEventListener(event: string, fn: Function) {
|
|
27
|
+
this.listeners.get(event)?.delete(fn);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
send(data: string) {
|
|
31
|
+
this.sent.push(data);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
close(_code?: number, _reason?: string) {
|
|
35
|
+
this.readyState = MockWebSocket.CLOSED;
|
|
36
|
+
this.fireEvent("close", { code: _code || 1000, reason: _reason || "" });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Helper: simulate receiving a message from the relay. */
|
|
40
|
+
simulateMessage(data: string) {
|
|
41
|
+
this.fireEvent("message", { data });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Helper: simulate the connection closing. */
|
|
45
|
+
simulateClose(code = 1006, reason = "") {
|
|
46
|
+
this.readyState = MockWebSocket.CLOSED;
|
|
47
|
+
this.fireEvent("close", { code, reason });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
private fireEvent(event: string, detail: Record<string, unknown>) {
|
|
51
|
+
this.listeners.get(event)?.forEach((fn) => fn(detail));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Replace global WebSocket with mock
|
|
56
|
+
let capturedWs: MockWebSocket | null = null;
|
|
57
|
+
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
vi.useFakeTimers();
|
|
60
|
+
capturedWs = null;
|
|
61
|
+
|
|
62
|
+
// Capture the WebSocket constructor call so we can interact with the instance
|
|
63
|
+
vi.stubGlobal("WebSocket", class extends MockWebSocket {
|
|
64
|
+
constructor(url: string) {
|
|
65
|
+
super(url);
|
|
66
|
+
capturedWs = this;
|
|
67
|
+
}
|
|
68
|
+
static OPEN = 1;
|
|
69
|
+
static CLOSED = 3;
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
afterEach(() => {
|
|
74
|
+
vi.useRealTimers();
|
|
75
|
+
vi.restoreAllMocks();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
function createMockRouter() {
|
|
81
|
+
return {
|
|
82
|
+
webhooks: {} as Record<string, (req: Request, opts?: { waitUntil?: (task: Promise<unknown>) => void }) => Promise<Response>>,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ─── Tests ──────────────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
describe("RelayClient", () => {
|
|
89
|
+
describe("connect()", () => {
|
|
90
|
+
it("builds the correct WebSocket URL from an HTTPS relay URL", () => {
|
|
91
|
+
const router = createMockRouter();
|
|
92
|
+
const client = new RelayClient(
|
|
93
|
+
"https://relay.example.com",
|
|
94
|
+
"my-secret",
|
|
95
|
+
router as any,
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
client.connect();
|
|
99
|
+
|
|
100
|
+
// The WebSocket should be constructed with wss:// and the secret in the query param
|
|
101
|
+
expect(capturedWs).not.toBeNull();
|
|
102
|
+
expect(capturedWs!.url).toBe("wss://relay.example.com/ws/relay?secret=my-secret");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("converts HTTP relay URL to WS (not WSS)", () => {
|
|
106
|
+
const router = createMockRouter();
|
|
107
|
+
const client = new RelayClient(
|
|
108
|
+
"http://localhost:8787",
|
|
109
|
+
"dev-secret",
|
|
110
|
+
router as any,
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
client.connect();
|
|
114
|
+
|
|
115
|
+
expect(capturedWs!.url).toBe("ws://localhost:8787/ws/relay?secret=dev-secret");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("redacts the secret from log output", () => {
|
|
119
|
+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
120
|
+
const router = createMockRouter();
|
|
121
|
+
const client = new RelayClient(
|
|
122
|
+
"https://relay.example.com",
|
|
123
|
+
"super-secret-value",
|
|
124
|
+
router as any,
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
client.connect();
|
|
128
|
+
|
|
129
|
+
// The log message should contain the URL but with the secret redacted
|
|
130
|
+
const logCall = logSpy.mock.calls.find((args) =>
|
|
131
|
+
typeof args[0] === "string" && args[0].includes("[relay-client] Connecting to"),
|
|
132
|
+
);
|
|
133
|
+
expect(logCall).toBeDefined();
|
|
134
|
+
expect(logCall![0]).not.toContain("super-secret-value");
|
|
135
|
+
expect(logCall![0]).toContain("secret=***");
|
|
136
|
+
|
|
137
|
+
logSpy.mockRestore();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("strips trailing slashes from the relay URL", () => {
|
|
141
|
+
const router = createMockRouter();
|
|
142
|
+
const client = new RelayClient(
|
|
143
|
+
"https://relay.example.com///",
|
|
144
|
+
"s",
|
|
145
|
+
router as any,
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
client.connect();
|
|
149
|
+
|
|
150
|
+
expect(capturedWs!.url).toBe("wss://relay.example.com/ws/relay?secret=s");
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe("disconnect()", () => {
|
|
155
|
+
it("closes the WebSocket and prevents reconnection", async () => {
|
|
156
|
+
const router = createMockRouter();
|
|
157
|
+
const client = new RelayClient("https://relay.example.com", "s", router as any);
|
|
158
|
+
|
|
159
|
+
client.connect();
|
|
160
|
+
await vi.advanceTimersByTimeAsync(0); // let "open" event fire
|
|
161
|
+
|
|
162
|
+
expect(capturedWs!.readyState).toBe(MockWebSocket.OPEN);
|
|
163
|
+
|
|
164
|
+
client.disconnect();
|
|
165
|
+
|
|
166
|
+
// After disconnect, the WebSocket should be closed
|
|
167
|
+
expect(capturedWs!.readyState).toBe(MockWebSocket.CLOSED);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe("handleWebhookRequest()", () => {
|
|
172
|
+
it("forwards a webhook request to the handler and sends back the response", async () => {
|
|
173
|
+
const router = createMockRouter();
|
|
174
|
+
router.webhooks = {
|
|
175
|
+
github: vi.fn(async () => new Response("OK", { status: 200 })),
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const client = new RelayClient("https://relay.example.com", "s", router as any);
|
|
179
|
+
client.connect();
|
|
180
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
181
|
+
|
|
182
|
+
// handleWebhookRequest is private, but we can trigger it via a message
|
|
183
|
+
capturedWs!.simulateMessage(JSON.stringify({
|
|
184
|
+
type: "webhook_request",
|
|
185
|
+
requestId: "req-1",
|
|
186
|
+
platform: "github",
|
|
187
|
+
method: "POST",
|
|
188
|
+
headers: { "content-type": "application/json" },
|
|
189
|
+
body: '{"event":"test"}',
|
|
190
|
+
}));
|
|
191
|
+
|
|
192
|
+
// Wait for async processing
|
|
193
|
+
await vi.advanceTimersByTimeAsync(10);
|
|
194
|
+
|
|
195
|
+
// The webhook handler should have been called
|
|
196
|
+
expect(router.webhooks.github).toHaveBeenCalledTimes(1);
|
|
197
|
+
|
|
198
|
+
// The relay client should have sent a webhook_response back
|
|
199
|
+
expect(capturedWs!.sent).toHaveLength(1);
|
|
200
|
+
const response = JSON.parse(capturedWs!.sent[0]);
|
|
201
|
+
expect(response.type).toBe("webhook_response");
|
|
202
|
+
expect(response.requestId).toBe("req-1");
|
|
203
|
+
expect(response.status).toBe(200);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("returns 404 when no handler exists for the requested platform", async () => {
|
|
207
|
+
const router = createMockRouter();
|
|
208
|
+
router.webhooks = {}; // No handlers
|
|
209
|
+
|
|
210
|
+
const client = new RelayClient("https://relay.example.com", "s", router as any);
|
|
211
|
+
client.connect();
|
|
212
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
213
|
+
|
|
214
|
+
capturedWs!.simulateMessage(JSON.stringify({
|
|
215
|
+
type: "webhook_request",
|
|
216
|
+
requestId: "req-2",
|
|
217
|
+
platform: "unknown-platform",
|
|
218
|
+
method: "POST",
|
|
219
|
+
headers: {},
|
|
220
|
+
body: "",
|
|
221
|
+
}));
|
|
222
|
+
|
|
223
|
+
await vi.advanceTimersByTimeAsync(10);
|
|
224
|
+
|
|
225
|
+
expect(capturedWs!.sent).toHaveLength(1);
|
|
226
|
+
const response = JSON.parse(capturedWs!.sent[0]);
|
|
227
|
+
expect(response.type).toBe("webhook_response");
|
|
228
|
+
expect(response.requestId).toBe("req-2");
|
|
229
|
+
expect(response.status).toBe(404);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("returns 500 when the webhook handler throws", async () => {
|
|
233
|
+
const router = createMockRouter();
|
|
234
|
+
router.webhooks = {
|
|
235
|
+
github: vi.fn(async () => { throw new Error("boom"); }),
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const client = new RelayClient("https://relay.example.com", "s", router as any);
|
|
239
|
+
client.connect();
|
|
240
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
241
|
+
|
|
242
|
+
capturedWs!.simulateMessage(JSON.stringify({
|
|
243
|
+
type: "webhook_request",
|
|
244
|
+
requestId: "req-3",
|
|
245
|
+
platform: "github",
|
|
246
|
+
method: "POST",
|
|
247
|
+
headers: {},
|
|
248
|
+
body: "test",
|
|
249
|
+
}));
|
|
250
|
+
|
|
251
|
+
await vi.advanceTimersByTimeAsync(10);
|
|
252
|
+
|
|
253
|
+
expect(capturedWs!.sent).toHaveLength(1);
|
|
254
|
+
const response = JSON.parse(capturedWs!.sent[0]);
|
|
255
|
+
expect(response.status).toBe(500);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
describe("message handling", () => {
|
|
260
|
+
it("ignores unknown message types", async () => {
|
|
261
|
+
const router = createMockRouter();
|
|
262
|
+
const client = new RelayClient("https://relay.example.com", "s", router as any);
|
|
263
|
+
client.connect();
|
|
264
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
265
|
+
|
|
266
|
+
// Should not throw or send any response
|
|
267
|
+
capturedWs!.simulateMessage(JSON.stringify({ type: "unknown_type" }));
|
|
268
|
+
await vi.advanceTimersByTimeAsync(10);
|
|
269
|
+
|
|
270
|
+
expect(capturedWs!.sent).toHaveLength(0);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("ignores malformed JSON messages", async () => {
|
|
274
|
+
const router = createMockRouter();
|
|
275
|
+
const client = new RelayClient("https://relay.example.com", "s", router as any);
|
|
276
|
+
client.connect();
|
|
277
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
278
|
+
|
|
279
|
+
// Should not throw
|
|
280
|
+
capturedWs!.simulateMessage("not valid json {{{");
|
|
281
|
+
await vi.advanceTimersByTimeAsync(10);
|
|
282
|
+
|
|
283
|
+
expect(capturedWs!.sent).toHaveLength(0);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("ignores malformed webhook_request (missing required fields)", async () => {
|
|
287
|
+
const router = createMockRouter();
|
|
288
|
+
const client = new RelayClient("https://relay.example.com", "s", router as any);
|
|
289
|
+
client.connect();
|
|
290
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
291
|
+
|
|
292
|
+
// Missing platform, method, headers, body
|
|
293
|
+
capturedWs!.simulateMessage(JSON.stringify({ type: "webhook_request", requestId: "r" }));
|
|
294
|
+
await vi.advanceTimersByTimeAsync(10);
|
|
295
|
+
|
|
296
|
+
expect(capturedWs!.sent).toHaveLength(0);
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
describe("reconnection", () => {
|
|
301
|
+
it("schedules a reconnection when the WebSocket closes unexpectedly", async () => {
|
|
302
|
+
const router = createMockRouter();
|
|
303
|
+
const client = new RelayClient("https://relay.example.com", "s", router as any);
|
|
304
|
+
|
|
305
|
+
client.connect();
|
|
306
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
307
|
+
|
|
308
|
+
const firstWs = capturedWs!;
|
|
309
|
+
firstWs.simulateClose(1006, "abnormal");
|
|
310
|
+
|
|
311
|
+
// After 1s (initial backoff), a new connection attempt should be made
|
|
312
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
313
|
+
|
|
314
|
+
// A new WebSocket should have been created
|
|
315
|
+
expect(capturedWs).not.toBe(firstWs);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("does not reconnect after an intentional disconnect", async () => {
|
|
319
|
+
const router = createMockRouter();
|
|
320
|
+
const client = new RelayClient("https://relay.example.com", "s", router as any);
|
|
321
|
+
|
|
322
|
+
client.connect();
|
|
323
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
324
|
+
|
|
325
|
+
const firstWs = capturedWs!;
|
|
326
|
+
client.disconnect();
|
|
327
|
+
|
|
328
|
+
// Advance well past the reconnect delay
|
|
329
|
+
await vi.advanceTimersByTimeAsync(60000);
|
|
330
|
+
|
|
331
|
+
// Should still be the same (closed) WebSocket — no new connection created
|
|
332
|
+
// (capturedWs may be a new reference from disconnect's close(), but no connect() should follow)
|
|
333
|
+
// The key test: no new open event or connection attempt
|
|
334
|
+
expect(capturedWs!.readyState).toBe(MockWebSocket.CLOSED);
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
});
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
// ─── Relay Client ────────────────────────────────────────────────────────────
|
|
2
|
+
// Connects The Companion to a cloud relay worker via outbound WebSocket.
|
|
3
|
+
// This allows a Companion instance running behind a firewall (NAT, no public IP)
|
|
4
|
+
// to receive external webhooks relayed through a cloud worker.
|
|
5
|
+
//
|
|
6
|
+
// Flow:
|
|
7
|
+
// Companion --[outbound WS]--> Relay Worker <--[HTTPS webhooks]-- External platforms
|
|
8
|
+
// Companion receives webhook_request messages, processes them locally,
|
|
9
|
+
// and sends webhook_response messages back through the same WebSocket.
|
|
10
|
+
|
|
11
|
+
type WebhookHandler = (req: Request, opts?: { waitUntil?: (task: Promise<unknown>) => void }) => Promise<Response>;
|
|
12
|
+
|
|
13
|
+
/** Generic interface for webhook routing in the relay client */
|
|
14
|
+
export interface RelayWebhookRouter {
|
|
15
|
+
/** Global webhook handlers keyed by platform name */
|
|
16
|
+
webhooks: Record<string, WebhookHandler>;
|
|
17
|
+
/** Get a webhook handler for a specific agent + platform combination */
|
|
18
|
+
getWebhookHandler?: (agentId: string, platform: string) => WebhookHandler | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Inbound message from the relay worker containing a webhook to process */
|
|
22
|
+
interface WebhookRequestMessage {
|
|
23
|
+
type: "webhook_request";
|
|
24
|
+
requestId: string;
|
|
25
|
+
platform: string;
|
|
26
|
+
/** Optional agent ID for agent-scoped webhook routing */
|
|
27
|
+
agentId?: string;
|
|
28
|
+
method: string;
|
|
29
|
+
headers: Record<string, string>;
|
|
30
|
+
body: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Outbound message sent back to the relay worker with the webhook response */
|
|
34
|
+
interface WebhookResponseMessage {
|
|
35
|
+
type: "webhook_response";
|
|
36
|
+
requestId: string;
|
|
37
|
+
status: number;
|
|
38
|
+
headers: Record<string, string>;
|
|
39
|
+
body: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Any message that may arrive from the relay worker */
|
|
43
|
+
type RelayIncomingMessage = WebhookRequestMessage | { type: string; [key: string]: unknown };
|
|
44
|
+
|
|
45
|
+
const MIN_RECONNECT_DELAY_MS = 1_000;
|
|
46
|
+
const MAX_RECONNECT_DELAY_MS = 30_000;
|
|
47
|
+
|
|
48
|
+
export class RelayClient {
|
|
49
|
+
private relayUrl: string;
|
|
50
|
+
private relaySecret: string;
|
|
51
|
+
private router: RelayWebhookRouter;
|
|
52
|
+
|
|
53
|
+
private ws: WebSocket | null = null;
|
|
54
|
+
private reconnectDelay = MIN_RECONNECT_DELAY_MS;
|
|
55
|
+
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
56
|
+
private intentionalDisconnect = false;
|
|
57
|
+
|
|
58
|
+
constructor(relayUrl: string, relaySecret: string, router: RelayWebhookRouter) {
|
|
59
|
+
this.relayUrl = relayUrl;
|
|
60
|
+
this.relaySecret = relaySecret;
|
|
61
|
+
this.router = router;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Open a WebSocket connection to the relay worker.
|
|
66
|
+
* Automatically reconnects with exponential backoff on disconnection.
|
|
67
|
+
*/
|
|
68
|
+
connect(): void {
|
|
69
|
+
this.intentionalDisconnect = false;
|
|
70
|
+
this.clearReconnectTimer();
|
|
71
|
+
|
|
72
|
+
// Convert http(s) URL to ws(s) URL
|
|
73
|
+
const wsUrl = this.buildWsUrl();
|
|
74
|
+
const displayUrl = wsUrl.replace(/([?&])(secret)=[^&]*/gi, "$1$2=***");
|
|
75
|
+
console.log(`[relay-client] Connecting to ${displayUrl}`);
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
this.ws = new WebSocket(wsUrl);
|
|
79
|
+
} catch (err) {
|
|
80
|
+
console.log(`[relay-client] Failed to create WebSocket: ${err instanceof Error ? err.message : String(err)}`);
|
|
81
|
+
this.scheduleReconnect();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
this.ws.addEventListener("open", () => {
|
|
86
|
+
console.log("[relay-client] Connected to relay worker");
|
|
87
|
+
this.resetBackoff();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
this.ws.addEventListener("message", (event: MessageEvent) => {
|
|
91
|
+
this.handleRawMessage(event.data);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
this.ws.addEventListener("close", (event: CloseEvent) => {
|
|
95
|
+
console.log(`[relay-client] Connection closed (code=${event.code}, reason=${event.reason || "none"})`);
|
|
96
|
+
this.ws = null;
|
|
97
|
+
|
|
98
|
+
if (!this.intentionalDisconnect) {
|
|
99
|
+
this.scheduleReconnect();
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
this.ws.addEventListener("error", (event: Event) => {
|
|
104
|
+
// The error event does not carry much detail in browser-style WebSocket API;
|
|
105
|
+
// the subsequent close event will trigger reconnection.
|
|
106
|
+
console.log(`[relay-client] WebSocket error: ${String(event)}`);
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Gracefully close the connection and stop reconnection attempts.
|
|
112
|
+
*/
|
|
113
|
+
disconnect(): void {
|
|
114
|
+
this.intentionalDisconnect = true;
|
|
115
|
+
this.clearReconnectTimer();
|
|
116
|
+
|
|
117
|
+
if (this.ws) {
|
|
118
|
+
console.log("[relay-client] Disconnecting from relay worker");
|
|
119
|
+
try {
|
|
120
|
+
this.ws.close(1000, "Client shutting down");
|
|
121
|
+
} catch {
|
|
122
|
+
// Ignore errors on close — socket may already be closed
|
|
123
|
+
}
|
|
124
|
+
this.ws = null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Handle a parsed webhook_request message by forwarding it to the webhook router
|
|
130
|
+
* and sending the response back to the relay worker.
|
|
131
|
+
*/
|
|
132
|
+
async handleWebhookRequest(msg: WebhookRequestMessage): Promise<void> {
|
|
133
|
+
const { requestId, platform, agentId, method, headers, body } = msg;
|
|
134
|
+
|
|
135
|
+
// Route to agent-scoped handler if agentId is provided, otherwise use global handler
|
|
136
|
+
const webhookHandler = agentId
|
|
137
|
+
? this.router.getWebhookHandler?.(agentId, platform) ?? null
|
|
138
|
+
: this.router.webhooks[platform];
|
|
139
|
+
|
|
140
|
+
if (!webhookHandler) {
|
|
141
|
+
const target = agentId ? `agent "${agentId}" / platform "${platform}"` : `platform "${platform}"`;
|
|
142
|
+
console.log(`[relay-client] No webhook handler for ${target}, returning 404`);
|
|
143
|
+
this.sendWebhookResponse({
|
|
144
|
+
type: "webhook_response",
|
|
145
|
+
requestId,
|
|
146
|
+
status: 404,
|
|
147
|
+
headers: {},
|
|
148
|
+
body: `No webhook handler for ${target}`,
|
|
149
|
+
});
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
// Construct a Request object from the relayed data
|
|
155
|
+
const hasBody = method !== "GET" && method !== "HEAD" && body;
|
|
156
|
+
const request = new Request(`https://relay-proxy/${platform}`, {
|
|
157
|
+
method,
|
|
158
|
+
headers: new Headers(headers),
|
|
159
|
+
body: hasBody ? body : undefined,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// Execute the webhook handler
|
|
163
|
+
const response = await webhookHandler(request, {
|
|
164
|
+
waitUntil: (task: Promise<unknown>) => {
|
|
165
|
+
task.catch((err) => console.error("[relay-client] Background task error:", err));
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// Extract response details
|
|
170
|
+
const responseBody = await response.text();
|
|
171
|
+
const responseHeaders: Record<string, string> = {};
|
|
172
|
+
response.headers.forEach((value, key) => {
|
|
173
|
+
responseHeaders[key] = value;
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
this.sendWebhookResponse({
|
|
177
|
+
type: "webhook_response",
|
|
178
|
+
requestId,
|
|
179
|
+
status: response.status,
|
|
180
|
+
headers: responseHeaders,
|
|
181
|
+
body: responseBody,
|
|
182
|
+
});
|
|
183
|
+
} catch (err) {
|
|
184
|
+
console.log(`[relay-client] Error processing webhook for platform "${platform}": ${err instanceof Error ? err.message : String(err)}`);
|
|
185
|
+
this.sendWebhookResponse({
|
|
186
|
+
type: "webhook_response",
|
|
187
|
+
requestId,
|
|
188
|
+
status: 500,
|
|
189
|
+
headers: {},
|
|
190
|
+
body: "Internal error",
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ─── Private helpers ─────────────────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Build the WebSocket URL from the relay HTTP URL.
|
|
199
|
+
* Converts https:// to wss:// and http:// to ws://.
|
|
200
|
+
*/
|
|
201
|
+
private buildWsUrl(): string {
|
|
202
|
+
let url = this.relayUrl;
|
|
203
|
+
|
|
204
|
+
if (url.startsWith("https://")) {
|
|
205
|
+
url = "wss://" + url.slice("https://".length);
|
|
206
|
+
} else if (url.startsWith("http://")) {
|
|
207
|
+
url = "ws://" + url.slice("http://".length);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Remove trailing slash if present before appending path
|
|
211
|
+
url = url.replace(/\/+$/, "");
|
|
212
|
+
|
|
213
|
+
// NOTE: The secret is passed as a query param for simplicity. This means
|
|
214
|
+
// it may appear in relay-side HTTP access logs. For higher security, migrate
|
|
215
|
+
// to an auth-frame approach (send secret as the first WebSocket message after
|
|
216
|
+
// connection opens). Rotate the secret regularly if using this approach.
|
|
217
|
+
return `${url}/ws/relay?secret=${encodeURIComponent(this.relaySecret)}`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Parse and route an incoming raw WebSocket message.
|
|
222
|
+
*/
|
|
223
|
+
private handleRawMessage(data: unknown): void {
|
|
224
|
+
let parsed: RelayIncomingMessage;
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
const text = typeof data === "string" ? data : String(data);
|
|
228
|
+
parsed = JSON.parse(text) as RelayIncomingMessage;
|
|
229
|
+
} catch (err) {
|
|
230
|
+
console.log(`[relay-client] Failed to parse message: ${err instanceof Error ? err.message : String(err)}`);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (!parsed || typeof parsed.type !== "string") {
|
|
235
|
+
console.log("[relay-client] Received message without a valid type field, ignoring");
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
switch (parsed.type) {
|
|
240
|
+
case "webhook_request":
|
|
241
|
+
// Validate required fields before processing
|
|
242
|
+
if (!this.isValidWebhookRequest(parsed)) {
|
|
243
|
+
console.log("[relay-client] Received malformed webhook_request, ignoring");
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
// Fire-and-forget: errors are caught inside handleWebhookRequest
|
|
247
|
+
void this.handleWebhookRequest(parsed as WebhookRequestMessage);
|
|
248
|
+
break;
|
|
249
|
+
|
|
250
|
+
default:
|
|
251
|
+
console.log(`[relay-client] Received unknown message type: ${parsed.type}`);
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Validate that a parsed message has all required webhook_request fields.
|
|
258
|
+
*/
|
|
259
|
+
private isValidWebhookRequest(msg: RelayIncomingMessage): msg is WebhookRequestMessage {
|
|
260
|
+
return (
|
|
261
|
+
msg.type === "webhook_request" &&
|
|
262
|
+
typeof (msg as WebhookRequestMessage).requestId === "string" &&
|
|
263
|
+
typeof (msg as WebhookRequestMessage).platform === "string" &&
|
|
264
|
+
typeof (msg as WebhookRequestMessage).method === "string" &&
|
|
265
|
+
typeof (msg as WebhookRequestMessage).headers === "object" &&
|
|
266
|
+
(msg as WebhookRequestMessage).headers !== null &&
|
|
267
|
+
typeof (msg as WebhookRequestMessage).body === "string"
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Send a webhook response message back through the WebSocket.
|
|
273
|
+
*/
|
|
274
|
+
private sendWebhookResponse(response: WebhookResponseMessage): void {
|
|
275
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
276
|
+
console.log(`[relay-client] Cannot send response for ${response.requestId}: WebSocket not open`);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
try {
|
|
281
|
+
this.ws.send(JSON.stringify(response));
|
|
282
|
+
} catch (err) {
|
|
283
|
+
console.log(`[relay-client] Failed to send response for ${response.requestId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Schedule a reconnection attempt with exponential backoff.
|
|
289
|
+
*/
|
|
290
|
+
private scheduleReconnect(): void {
|
|
291
|
+
if (this.intentionalDisconnect) return;
|
|
292
|
+
|
|
293
|
+
const delay = this.reconnectDelay;
|
|
294
|
+
console.log(`[relay-client] Reconnecting in ${delay}ms`);
|
|
295
|
+
|
|
296
|
+
this.reconnectTimer = setTimeout(() => {
|
|
297
|
+
this.reconnectTimer = null;
|
|
298
|
+
// Double the delay for next time, capped at max
|
|
299
|
+
this.reconnectDelay = Math.min(this.reconnectDelay * 2, MAX_RECONNECT_DELAY_MS);
|
|
300
|
+
this.connect();
|
|
301
|
+
}, delay);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Reset backoff delay to the minimum after a successful connection.
|
|
306
|
+
*/
|
|
307
|
+
private resetBackoff(): void {
|
|
308
|
+
this.reconnectDelay = MIN_RECONNECT_DELAY_MS;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Clear any pending reconnection timer.
|
|
313
|
+
*/
|
|
314
|
+
private clearReconnectTimer(): void {
|
|
315
|
+
if (this.reconnectTimer !== null) {
|
|
316
|
+
clearTimeout(this.reconnectTimer);
|
|
317
|
+
this.reconnectTimer = null;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|