agent-office-cli 0.1.7 → 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/tunnel.js +119 -16
- package/src/tunnel.test.js +168 -1
package/package.json
CHANGED
package/src/tunnel.js
CHANGED
|
@@ -4,6 +4,9 @@ const { createTunnelLogger, describeWebSocketClose } = require("./runtime/tunnel
|
|
|
4
4
|
|
|
5
5
|
const RECONNECT_BASE_MS = 1000;
|
|
6
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;
|
|
7
10
|
const LOCAL_PROXY_STRIP_HEADERS = new Set([
|
|
8
11
|
"accept-encoding",
|
|
9
12
|
"connection",
|
|
@@ -41,12 +44,49 @@ function buildLocalRequestHeaders(headers, localServerUrl) {
|
|
|
41
44
|
return nextHeaders;
|
|
42
45
|
}
|
|
43
46
|
|
|
44
|
-
|
|
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
|
+
}) {
|
|
45
84
|
let ws = null;
|
|
46
|
-
let reconnectDelay =
|
|
85
|
+
let reconnectDelay = reconnectBaseMs;
|
|
47
86
|
let stopped = false;
|
|
48
87
|
let authenticated = false;
|
|
49
88
|
let pendingStatusSummary = [];
|
|
89
|
+
let reconnectTimer = null;
|
|
50
90
|
|
|
51
91
|
function flushStatusSummary() {
|
|
52
92
|
if (!authenticated || !ws || ws.readyState !== WebSocket.OPEN) {
|
|
@@ -58,6 +98,20 @@ function createTunnelClient({ key, relayUrl, localServerUrl, logger = createTunn
|
|
|
58
98
|
}));
|
|
59
99
|
}
|
|
60
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
|
+
|
|
61
115
|
function connect() {
|
|
62
116
|
if (stopped) {
|
|
63
117
|
return;
|
|
@@ -65,16 +119,56 @@ function createTunnelClient({ key, relayUrl, localServerUrl, logger = createTunn
|
|
|
65
119
|
|
|
66
120
|
authenticated = false;
|
|
67
121
|
const url = `${relayUrl.replace(/^http/, "ws")}/upstream`;
|
|
68
|
-
|
|
122
|
+
const socket = new WebSocket(url);
|
|
123
|
+
let socketAuthenticated = false;
|
|
124
|
+
let lastActivityAt = Date.now();
|
|
69
125
|
|
|
70
|
-
|
|
71
|
-
|
|
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();
|
|
72
158
|
logger.info("[tunnel] connected to relay, authenticating...");
|
|
73
|
-
|
|
159
|
+
socket.send(JSON.stringify({ type: "auth", key }));
|
|
74
160
|
});
|
|
75
161
|
|
|
76
|
-
|
|
162
|
+
socket.on("ping", markActivity);
|
|
163
|
+
socket.on("pong", markActivity);
|
|
164
|
+
|
|
165
|
+
socket.on("message", async (raw) => {
|
|
77
166
|
try {
|
|
167
|
+
if (ws !== socket) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
markActivity();
|
|
78
172
|
const str = String(raw);
|
|
79
173
|
|
|
80
174
|
// Fast path: WS data forwarding uses "W:${connId}:${data}" prefix (no JSON parse)
|
|
@@ -91,15 +185,19 @@ function createTunnelClient({ key, relayUrl, localServerUrl, logger = createTunn
|
|
|
91
185
|
const msg = JSON.parse(str);
|
|
92
186
|
|
|
93
187
|
if (msg.type === "auth:ok") {
|
|
188
|
+
socketAuthenticated = true;
|
|
94
189
|
authenticated = true;
|
|
190
|
+
reconnectDelay = reconnectBaseMs;
|
|
95
191
|
logger.info(`[tunnel] authenticated with relay: ${relayUrl} (userId=${msg.userId})`);
|
|
96
192
|
flushStatusSummary();
|
|
97
193
|
return;
|
|
98
194
|
}
|
|
99
195
|
if (msg.type === "auth:error") {
|
|
100
196
|
logger.error(`[tunnel] authentication failed: ${msg.error || "invalid key"}`);
|
|
101
|
-
|
|
102
|
-
|
|
197
|
+
if (isTerminalAuthFailure({ error: msg.error })) {
|
|
198
|
+
stopped = true;
|
|
199
|
+
}
|
|
200
|
+
socket.close();
|
|
103
201
|
return;
|
|
104
202
|
}
|
|
105
203
|
|
|
@@ -113,26 +211,27 @@ function createTunnelClient({ key, relayUrl, localServerUrl, logger = createTunn
|
|
|
113
211
|
}
|
|
114
212
|
});
|
|
115
213
|
|
|
116
|
-
|
|
214
|
+
socket.on("close", (code, reasonBuffer) => {
|
|
215
|
+
clearInterval(watchdogTimer);
|
|
216
|
+
if (ws === socket) {
|
|
217
|
+
ws = null;
|
|
218
|
+
}
|
|
117
219
|
const reason = Buffer.isBuffer(reasonBuffer) ? reasonBuffer.toString("utf8") : String(reasonBuffer || "");
|
|
118
220
|
const closeDetails = describeWebSocketClose({ code, reason });
|
|
119
221
|
if (stopped) {
|
|
120
222
|
logger.info(`[tunnel] stopped with close ${closeDetails}`);
|
|
121
223
|
return;
|
|
122
224
|
}
|
|
123
|
-
if (code
|
|
225
|
+
if (isTerminalAuthFailure({ code, reason })) {
|
|
124
226
|
logger.error(`[tunnel] authentication rejected by relay (${closeDetails}). Not reconnecting.`);
|
|
125
227
|
stopped = true;
|
|
126
228
|
return;
|
|
127
229
|
}
|
|
128
230
|
logger.info(`[tunnel] disconnected (${closeDetails}). Reconnecting in ${reconnectDelay}ms...`);
|
|
129
|
-
|
|
130
|
-
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS);
|
|
131
|
-
connect();
|
|
132
|
-
}, reconnectDelay);
|
|
231
|
+
scheduleReconnect();
|
|
133
232
|
});
|
|
134
233
|
|
|
135
|
-
|
|
234
|
+
socket.on("error", (err) => {
|
|
136
235
|
logger.error(`[tunnel] ws error: ${err.message}`);
|
|
137
236
|
});
|
|
138
237
|
}
|
|
@@ -242,6 +341,10 @@ function createTunnelClient({ key, relayUrl, localServerUrl, logger = createTunn
|
|
|
242
341
|
|
|
243
342
|
function stop() {
|
|
244
343
|
stopped = true;
|
|
344
|
+
if (reconnectTimer) {
|
|
345
|
+
clearTimeout(reconnectTimer);
|
|
346
|
+
reconnectTimer = null;
|
|
347
|
+
}
|
|
245
348
|
for (const localWs of localWsConnections.values()) {
|
|
246
349
|
localWs.close();
|
|
247
350
|
}
|
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
|
+
});
|