agent-office-cli 0.1.6 → 0.1.8
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/package.json +1 -1
- package/src/index.js +3 -0
- package/src/runtime/index.js +4 -0
- package/src/runtime/tunnel-log.js +56 -0
- package/src/runtime/tunnel-log.test.js +43 -0
- package/src/tunnel.js +131 -23
- package/src/tunnel.test.js +168 -1
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -202,6 +202,9 @@ async function main() {
|
|
|
202
202
|
});
|
|
203
203
|
console.log(`AgentOffice tunnel connecting to relay: ${hosted.relayUrl}`);
|
|
204
204
|
console.log(`- hosted auth: key from ${hosted.keySource}, relay from ${hosted.relaySource}`);
|
|
205
|
+
if (tunnel.logPath) {
|
|
206
|
+
console.log(`- tunnel log: ${tunnel.logPath}`);
|
|
207
|
+
}
|
|
205
208
|
tunnel.sendStatusSummary(store.listSessionSummaries());
|
|
206
209
|
|
|
207
210
|
let statusDebounceTimer = null;
|
package/src/runtime/index.js
CHANGED
|
@@ -19,6 +19,7 @@ const {
|
|
|
19
19
|
} = require("./session-registry");
|
|
20
20
|
const { ensureNodePtySpawnHelper } = require("./ensure-node-pty");
|
|
21
21
|
const { startSleepInhibitor } = require("./sleep-inhibitor");
|
|
22
|
+
const { createTunnelLogger, TUNNEL_LOG_PATH, describeWebSocketClose } = require("./tunnel-log");
|
|
22
23
|
const {
|
|
23
24
|
applyClaudeHookConfig,
|
|
24
25
|
claudeSettingsPath,
|
|
@@ -47,6 +48,9 @@ module.exports = {
|
|
|
47
48
|
removeSessionRecord,
|
|
48
49
|
ensureNodePtySpawnHelper,
|
|
49
50
|
startSleepInhibitor,
|
|
51
|
+
createTunnelLogger,
|
|
52
|
+
TUNNEL_LOG_PATH,
|
|
53
|
+
describeWebSocketClose,
|
|
50
54
|
applyClaudeHookConfig,
|
|
51
55
|
claudeSettingsPath,
|
|
52
56
|
commandExists,
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
const fs = require("node:fs");
|
|
2
|
+
const os = require("node:os");
|
|
3
|
+
const path = require("node:path");
|
|
4
|
+
|
|
5
|
+
const TUNNEL_LOG_PATH = path.join(os.homedir(), ".agentoffice", "logs", "tunnel.log");
|
|
6
|
+
|
|
7
|
+
function describeWebSocketClose({ code, reason }) {
|
|
8
|
+
const parts = [];
|
|
9
|
+
|
|
10
|
+
if (typeof code === "number") {
|
|
11
|
+
parts.push(`code=${code}`);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (reason) {
|
|
15
|
+
parts.push(`reason=${reason}`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return parts.length > 0 ? parts.join(" ") : "no close details";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function createTunnelLogger({
|
|
22
|
+
logPath = TUNNEL_LOG_PATH,
|
|
23
|
+
now = () => new Date().toISOString(),
|
|
24
|
+
mkdirSync = fs.mkdirSync,
|
|
25
|
+
appendFileSync = fs.appendFileSync,
|
|
26
|
+
consoleObj = console,
|
|
27
|
+
} = {}) {
|
|
28
|
+
function write(level, message) {
|
|
29
|
+
const line = `[${now()}] [${level}] ${message}`;
|
|
30
|
+
const print = level === "error" ? consoleObj.error : consoleObj.log;
|
|
31
|
+
print.call(consoleObj, line);
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
mkdirSync(path.dirname(logPath), { recursive: true });
|
|
35
|
+
appendFileSync(logPath, `${line}\n`, "utf8");
|
|
36
|
+
} catch {
|
|
37
|
+
// Logging should never crash the tunnel client.
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
logPath,
|
|
43
|
+
info(message) {
|
|
44
|
+
write("info", message);
|
|
45
|
+
},
|
|
46
|
+
error(message) {
|
|
47
|
+
write("error", message);
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
module.exports = {
|
|
53
|
+
TUNNEL_LOG_PATH,
|
|
54
|
+
createTunnelLogger,
|
|
55
|
+
describeWebSocketClose,
|
|
56
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
const test = require("node:test");
|
|
2
|
+
const assert = require("node:assert/strict");
|
|
3
|
+
|
|
4
|
+
const { createTunnelLogger, describeWebSocketClose } = require("./tunnel-log");
|
|
5
|
+
|
|
6
|
+
test("describeWebSocketClose includes code and reason when present", () => {
|
|
7
|
+
assert.equal(
|
|
8
|
+
describeWebSocketClose({ code: 1006, reason: "network_reset" }),
|
|
9
|
+
"code=1006 reason=network_reset"
|
|
10
|
+
);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("describeWebSocketClose falls back when no details are available", () => {
|
|
14
|
+
assert.equal(describeWebSocketClose({}), "no close details");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("createTunnelLogger mirrors lines to console and appends a local tunnel log", () => {
|
|
18
|
+
const writes = [];
|
|
19
|
+
const consoleLines = [];
|
|
20
|
+
|
|
21
|
+
const logger = createTunnelLogger({
|
|
22
|
+
logPath: "/tmp/agentoffice-tunnel.log",
|
|
23
|
+
now: () => "2026-03-20T08:12:00.000Z",
|
|
24
|
+
mkdirSync: () => {},
|
|
25
|
+
appendFileSync: (_path, content) => writes.push(content),
|
|
26
|
+
consoleObj: {
|
|
27
|
+
log: (line) => consoleLines.push(["log", line]),
|
|
28
|
+
error: (line) => consoleLines.push(["error", line]),
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
logger.info("connected to relay");
|
|
33
|
+
logger.error("ws error: socket hang up");
|
|
34
|
+
|
|
35
|
+
assert.deepEqual(consoleLines, [
|
|
36
|
+
["log", "[2026-03-20T08:12:00.000Z] [info] connected to relay"],
|
|
37
|
+
["error", "[2026-03-20T08:12:00.000Z] [error] ws error: socket hang up"],
|
|
38
|
+
]);
|
|
39
|
+
assert.deepEqual(writes, [
|
|
40
|
+
"[2026-03-20T08:12:00.000Z] [info] connected to relay\n",
|
|
41
|
+
"[2026-03-20T08:12:00.000Z] [error] ws error: socket hang up\n",
|
|
42
|
+
]);
|
|
43
|
+
});
|
package/src/tunnel.js
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
const { WebSocket } = require("ws");
|
|
2
2
|
const { toSessionSummary } = require("./core");
|
|
3
|
+
const { createTunnelLogger, describeWebSocketClose } = require("./runtime/tunnel-log");
|
|
3
4
|
|
|
4
5
|
const RECONNECT_BASE_MS = 1000;
|
|
5
6
|
const RECONNECT_MAX_MS = 30000;
|
|
7
|
+
const AUTH_RESPONSE_TIMEOUT_MS = 15000;
|
|
8
|
+
const STALE_UPSTREAM_TIMEOUT_MS = 70000;
|
|
9
|
+
const WATCHDOG_INTERVAL_MS = 5000;
|
|
6
10
|
const LOCAL_PROXY_STRIP_HEADERS = new Set([
|
|
7
11
|
"accept-encoding",
|
|
8
12
|
"connection",
|
|
@@ -40,12 +44,49 @@ function buildLocalRequestHeaders(headers, localServerUrl) {
|
|
|
40
44
|
return nextHeaders;
|
|
41
45
|
}
|
|
42
46
|
|
|
43
|
-
|
|
47
|
+
const TERMINAL_AUTH_REASONS = new Set([
|
|
48
|
+
"invalid_key",
|
|
49
|
+
"key_revoked"
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
function isTerminalAuthFailure({ code, reason, error }) {
|
|
53
|
+
if (error) {
|
|
54
|
+
return TERMINAL_AUTH_REASONS.has(error);
|
|
55
|
+
}
|
|
56
|
+
if (code !== 4401) {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
return TERMINAL_AUTH_REASONS.has(reason);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function closeSocket(socket) {
|
|
63
|
+
if (!socket) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (typeof socket.terminate === "function") {
|
|
67
|
+
socket.terminate();
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
socket.close();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function createTunnelClient({
|
|
74
|
+
key,
|
|
75
|
+
relayUrl,
|
|
76
|
+
localServerUrl,
|
|
77
|
+
logger = createTunnelLogger(),
|
|
78
|
+
reconnectBaseMs = RECONNECT_BASE_MS,
|
|
79
|
+
reconnectMaxMs = RECONNECT_MAX_MS,
|
|
80
|
+
authResponseTimeoutMs = AUTH_RESPONSE_TIMEOUT_MS,
|
|
81
|
+
staleUpstreamTimeoutMs = STALE_UPSTREAM_TIMEOUT_MS,
|
|
82
|
+
watchdogIntervalMs = WATCHDOG_INTERVAL_MS
|
|
83
|
+
}) {
|
|
44
84
|
let ws = null;
|
|
45
|
-
let reconnectDelay =
|
|
85
|
+
let reconnectDelay = reconnectBaseMs;
|
|
46
86
|
let stopped = false;
|
|
47
87
|
let authenticated = false;
|
|
48
88
|
let pendingStatusSummary = [];
|
|
89
|
+
let reconnectTimer = null;
|
|
49
90
|
|
|
50
91
|
function flushStatusSummary() {
|
|
51
92
|
if (!authenticated || !ws || ws.readyState !== WebSocket.OPEN) {
|
|
@@ -57,6 +98,20 @@ function createTunnelClient({ key, relayUrl, localServerUrl }) {
|
|
|
57
98
|
}));
|
|
58
99
|
}
|
|
59
100
|
|
|
101
|
+
function scheduleReconnect() {
|
|
102
|
+
if (stopped || reconnectTimer) {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const delay = reconnectDelay;
|
|
107
|
+
reconnectTimer = setTimeout(() => {
|
|
108
|
+
reconnectTimer = null;
|
|
109
|
+
reconnectDelay = Math.min(reconnectDelay * 2, reconnectMaxMs);
|
|
110
|
+
connect();
|
|
111
|
+
}, delay);
|
|
112
|
+
reconnectTimer.unref?.();
|
|
113
|
+
}
|
|
114
|
+
|
|
60
115
|
function connect() {
|
|
61
116
|
if (stopped) {
|
|
62
117
|
return;
|
|
@@ -64,16 +119,56 @@ function createTunnelClient({ key, relayUrl, localServerUrl }) {
|
|
|
64
119
|
|
|
65
120
|
authenticated = false;
|
|
66
121
|
const url = `${relayUrl.replace(/^http/, "ws")}/upstream`;
|
|
67
|
-
|
|
122
|
+
const socket = new WebSocket(url);
|
|
123
|
+
let socketAuthenticated = false;
|
|
124
|
+
let lastActivityAt = Date.now();
|
|
68
125
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
126
|
+
function markActivity() {
|
|
127
|
+
lastActivityAt = Date.now();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const watchdogTimer = setInterval(() => {
|
|
131
|
+
if (stopped || ws !== socket) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const idleForMs = Date.now() - lastActivityAt;
|
|
136
|
+
if (!socketAuthenticated) {
|
|
137
|
+
if (idleForMs < authResponseTimeoutMs) {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
logger.error(`[tunnel] auth response timeout after ${idleForMs}ms. Terminating socket and retrying.`);
|
|
141
|
+
closeSocket(socket);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (idleForMs < staleUpstreamTimeoutMs) {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
logger.error(`[tunnel] upstream stale for ${idleForMs}ms. Terminating socket and reconnecting.`);
|
|
150
|
+
closeSocket(socket);
|
|
151
|
+
}, watchdogIntervalMs);
|
|
152
|
+
watchdogTimer.unref?.();
|
|
153
|
+
|
|
154
|
+
ws = socket;
|
|
155
|
+
|
|
156
|
+
socket.on("open", () => {
|
|
157
|
+
markActivity();
|
|
158
|
+
logger.info("[tunnel] connected to relay, authenticating...");
|
|
159
|
+
socket.send(JSON.stringify({ type: "auth", key }));
|
|
73
160
|
});
|
|
74
161
|
|
|
75
|
-
|
|
162
|
+
socket.on("ping", markActivity);
|
|
163
|
+
socket.on("pong", markActivity);
|
|
164
|
+
|
|
165
|
+
socket.on("message", async (raw) => {
|
|
76
166
|
try {
|
|
167
|
+
if (ws !== socket) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
markActivity();
|
|
77
172
|
const str = String(raw);
|
|
78
173
|
|
|
79
174
|
// Fast path: WS data forwarding uses "W:${connId}:${data}" prefix (no JSON parse)
|
|
@@ -90,15 +185,19 @@ function createTunnelClient({ key, relayUrl, localServerUrl }) {
|
|
|
90
185
|
const msg = JSON.parse(str);
|
|
91
186
|
|
|
92
187
|
if (msg.type === "auth:ok") {
|
|
188
|
+
socketAuthenticated = true;
|
|
93
189
|
authenticated = true;
|
|
94
|
-
|
|
190
|
+
reconnectDelay = reconnectBaseMs;
|
|
191
|
+
logger.info(`[tunnel] authenticated with relay: ${relayUrl} (userId=${msg.userId})`);
|
|
95
192
|
flushStatusSummary();
|
|
96
193
|
return;
|
|
97
194
|
}
|
|
98
195
|
if (msg.type === "auth:error") {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
196
|
+
logger.error(`[tunnel] authentication failed: ${msg.error || "invalid key"}`);
|
|
197
|
+
if (isTerminalAuthFailure({ error: msg.error })) {
|
|
198
|
+
stopped = true;
|
|
199
|
+
}
|
|
200
|
+
socket.close();
|
|
102
201
|
return;
|
|
103
202
|
}
|
|
104
203
|
|
|
@@ -108,28 +207,32 @@ function createTunnelClient({ key, relayUrl, localServerUrl }) {
|
|
|
108
207
|
|
|
109
208
|
await handleRelayMessage(msg);
|
|
110
209
|
} catch (err) {
|
|
111
|
-
|
|
210
|
+
logger.error(`[tunnel] message error: ${err.message}`);
|
|
112
211
|
}
|
|
113
212
|
});
|
|
114
213
|
|
|
115
|
-
|
|
214
|
+
socket.on("close", (code, reasonBuffer) => {
|
|
215
|
+
clearInterval(watchdogTimer);
|
|
216
|
+
if (ws === socket) {
|
|
217
|
+
ws = null;
|
|
218
|
+
}
|
|
219
|
+
const reason = Buffer.isBuffer(reasonBuffer) ? reasonBuffer.toString("utf8") : String(reasonBuffer || "");
|
|
220
|
+
const closeDetails = describeWebSocketClose({ code, reason });
|
|
116
221
|
if (stopped) {
|
|
222
|
+
logger.info(`[tunnel] stopped with close ${closeDetails}`);
|
|
117
223
|
return;
|
|
118
224
|
}
|
|
119
|
-
if (code
|
|
120
|
-
|
|
225
|
+
if (isTerminalAuthFailure({ code, reason })) {
|
|
226
|
+
logger.error(`[tunnel] authentication rejected by relay (${closeDetails}). Not reconnecting.`);
|
|
121
227
|
stopped = true;
|
|
122
228
|
return;
|
|
123
229
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS);
|
|
127
|
-
connect();
|
|
128
|
-
}, reconnectDelay);
|
|
230
|
+
logger.info(`[tunnel] disconnected (${closeDetails}). Reconnecting in ${reconnectDelay}ms...`);
|
|
231
|
+
scheduleReconnect();
|
|
129
232
|
});
|
|
130
233
|
|
|
131
|
-
|
|
132
|
-
|
|
234
|
+
socket.on("error", (err) => {
|
|
235
|
+
logger.error(`[tunnel] ws error: ${err.message}`);
|
|
133
236
|
});
|
|
134
237
|
}
|
|
135
238
|
|
|
@@ -238,6 +341,10 @@ function createTunnelClient({ key, relayUrl, localServerUrl }) {
|
|
|
238
341
|
|
|
239
342
|
function stop() {
|
|
240
343
|
stopped = true;
|
|
344
|
+
if (reconnectTimer) {
|
|
345
|
+
clearTimeout(reconnectTimer);
|
|
346
|
+
reconnectTimer = null;
|
|
347
|
+
}
|
|
241
348
|
for (const localWs of localWsConnections.values()) {
|
|
242
349
|
localWs.close();
|
|
243
350
|
}
|
|
@@ -250,6 +357,7 @@ function createTunnelClient({ key, relayUrl, localServerUrl }) {
|
|
|
250
357
|
connect();
|
|
251
358
|
|
|
252
359
|
return {
|
|
360
|
+
logPath: logger.logPath,
|
|
253
361
|
sendStatusSummary,
|
|
254
362
|
stop
|
|
255
363
|
};
|
package/src/tunnel.test.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
const test = require("node:test");
|
|
2
2
|
const assert = require("node:assert/strict");
|
|
3
|
+
const { once } = require("node:events");
|
|
4
|
+
const { createServer } = require("node:http");
|
|
5
|
+
const { WebSocketServer } = require("ws");
|
|
3
6
|
|
|
4
|
-
const { buildLocalRequestHeaders } = require("./tunnel");
|
|
7
|
+
const { buildLocalRequestHeaders, createTunnelClient } = require("./tunnel");
|
|
5
8
|
|
|
6
9
|
test("buildLocalRequestHeaders strips browser-only proxy headers and rewrites host", () => {
|
|
7
10
|
const next = buildLocalRequestHeaders(
|
|
@@ -29,3 +32,167 @@ test("buildLocalRequestHeaders strips browser-only proxy headers and rewrites ho
|
|
|
29
32
|
host: "127.0.0.1:8765"
|
|
30
33
|
});
|
|
31
34
|
});
|
|
35
|
+
|
|
36
|
+
async function withRelaySocketServer(run) {
|
|
37
|
+
const server = createServer();
|
|
38
|
+
const wss = new WebSocketServer({ server });
|
|
39
|
+
|
|
40
|
+
server.listen(0, "127.0.0.1");
|
|
41
|
+
await once(server, "listening");
|
|
42
|
+
|
|
43
|
+
const address = server.address();
|
|
44
|
+
const relayUrl = `http://127.0.0.1:${address.port}`;
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
await run({ relayUrl, wss });
|
|
48
|
+
} finally {
|
|
49
|
+
await new Promise((resolve) => wss.close(resolve));
|
|
50
|
+
await new Promise((resolve, reject) => {
|
|
51
|
+
server.close((error) => {
|
|
52
|
+
if (error) {
|
|
53
|
+
reject(error);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
resolve();
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function createNoopLogger() {
|
|
63
|
+
return {
|
|
64
|
+
info() {},
|
|
65
|
+
error() {}
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function waitFor(predicate, timeoutMs = 500) {
|
|
70
|
+
const startedAt = Date.now();
|
|
71
|
+
while (!predicate()) {
|
|
72
|
+
if (Date.now() - startedAt > timeoutMs) {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
76
|
+
}
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
test("createTunnelClient reconnects when auth never completes", async () => {
|
|
81
|
+
await withRelaySocketServer(async ({ relayUrl, wss }) => {
|
|
82
|
+
const connections = [];
|
|
83
|
+
wss.on("connection", (socket) => {
|
|
84
|
+
connections.push(socket);
|
|
85
|
+
socket.on("message", () => {
|
|
86
|
+
// Intentionally ignore auth messages to simulate a stalled handshake.
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const tunnel = createTunnelClient({
|
|
91
|
+
key: "test-key",
|
|
92
|
+
relayUrl,
|
|
93
|
+
localServerUrl: "http://127.0.0.1:8765",
|
|
94
|
+
logger: createNoopLogger(),
|
|
95
|
+
reconnectBaseMs: 20,
|
|
96
|
+
reconnectMaxMs: 40,
|
|
97
|
+
authResponseTimeoutMs: 50,
|
|
98
|
+
watchdogIntervalMs: 10
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
const reconnected = await waitFor(() => connections.length >= 2);
|
|
103
|
+
assert.ok(reconnected, `expected reconnect after stalled auth, saw ${connections.length} connection(s)`);
|
|
104
|
+
} finally {
|
|
105
|
+
tunnel.stop();
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("createTunnelClient reconnects when an authenticated tunnel goes silent", async () => {
|
|
111
|
+
await withRelaySocketServer(async ({ relayUrl, wss }) => {
|
|
112
|
+
const connections = [];
|
|
113
|
+
wss.on("connection", (socket) => {
|
|
114
|
+
connections.push(socket);
|
|
115
|
+
socket.once("message", () => {
|
|
116
|
+
socket.send(JSON.stringify({ type: "auth:ok", userId: "user_test" }));
|
|
117
|
+
// Intentionally stay silent after auth: no ping, no messages.
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const tunnel = createTunnelClient({
|
|
122
|
+
key: "test-key",
|
|
123
|
+
relayUrl,
|
|
124
|
+
localServerUrl: "http://127.0.0.1:8765",
|
|
125
|
+
logger: createNoopLogger(),
|
|
126
|
+
reconnectBaseMs: 20,
|
|
127
|
+
reconnectMaxMs: 40,
|
|
128
|
+
staleUpstreamTimeoutMs: 60,
|
|
129
|
+
watchdogIntervalMs: 10
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
const reconnected = await waitFor(() => connections.length >= 2);
|
|
134
|
+
assert.ok(reconnected, `expected reconnect after stale upstream, saw ${connections.length} connection(s)`);
|
|
135
|
+
} finally {
|
|
136
|
+
tunnel.stop();
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("createTunnelClient retries after relay auth timeout closes the socket", async () => {
|
|
142
|
+
await withRelaySocketServer(async ({ relayUrl, wss }) => {
|
|
143
|
+
const connections = [];
|
|
144
|
+
wss.on("connection", (socket) => {
|
|
145
|
+
connections.push(socket);
|
|
146
|
+
socket.once("message", () => {
|
|
147
|
+
socket.close(4401, "auth_timeout");
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const tunnel = createTunnelClient({
|
|
152
|
+
key: "test-key",
|
|
153
|
+
relayUrl,
|
|
154
|
+
localServerUrl: "http://127.0.0.1:8765",
|
|
155
|
+
logger: createNoopLogger(),
|
|
156
|
+
reconnectBaseMs: 20,
|
|
157
|
+
reconnectMaxMs: 40,
|
|
158
|
+
watchdogIntervalMs: 10
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
const reconnected = await waitFor(() => connections.length >= 2);
|
|
163
|
+
assert.ok(reconnected, `expected reconnect after auth_timeout, saw ${connections.length} connection(s)`);
|
|
164
|
+
} finally {
|
|
165
|
+
tunnel.stop();
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("createTunnelClient stops retrying after invalid key is rejected", async () => {
|
|
171
|
+
await withRelaySocketServer(async ({ relayUrl, wss }) => {
|
|
172
|
+
const connections = [];
|
|
173
|
+
wss.on("connection", (socket) => {
|
|
174
|
+
connections.push(socket);
|
|
175
|
+
socket.once("message", () => {
|
|
176
|
+
socket.send(JSON.stringify({ type: "auth:error", error: "invalid_key" }));
|
|
177
|
+
socket.close(4401, "invalid_key");
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const tunnel = createTunnelClient({
|
|
182
|
+
key: "test-key",
|
|
183
|
+
relayUrl,
|
|
184
|
+
localServerUrl: "http://127.0.0.1:8765",
|
|
185
|
+
logger: createNoopLogger(),
|
|
186
|
+
reconnectBaseMs: 20,
|
|
187
|
+
reconnectMaxMs: 40,
|
|
188
|
+
watchdogIntervalMs: 10
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
try {
|
|
192
|
+
await new Promise((resolve) => setTimeout(resolve, 120));
|
|
193
|
+
assert.equal(connections.length, 1);
|
|
194
|
+
} finally {
|
|
195
|
+
tunnel.stop();
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
});
|