agent-office-cli 0.1.7 → 0.1.9
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/README.md +168 -0
- package/package.json +1 -1
- package/src/tunnel.js +119 -16
- package/src/tunnel.test.js +168 -1
package/README.md
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# agent-office-cli
|
|
2
|
+
|
|
3
|
+
`agent-office-cli` is the local runtime for AgentOffice.
|
|
4
|
+
|
|
5
|
+
It starts the local session manager, restores tmux-backed AI workers, connects your machine to the hosted relay, and lets you launch or attach to Claude Code / Codex sessions from the terminal.
|
|
6
|
+
|
|
7
|
+
## What This Package Does
|
|
8
|
+
|
|
9
|
+
- Starts the local AgentOffice service with `ato start`
|
|
10
|
+
- Restores existing tmux-backed workers after restart
|
|
11
|
+
- Connects your machine to `agentoffice.top` or a custom relay
|
|
12
|
+
- Launches Claude Code and Codex workers with one command
|
|
13
|
+
- Exposes local worker terminals to the AgentOffice web or mobile UI
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm i -g agent-office-cli
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Requirements
|
|
22
|
+
|
|
23
|
+
- Node.js 18+
|
|
24
|
+
- `tmux`
|
|
25
|
+
- Claude Code and/or Codex CLI if you want to launch those providers
|
|
26
|
+
|
|
27
|
+
Example on macOS:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
brew install tmux
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Quick Start
|
|
34
|
+
|
|
35
|
+
1. Create an API key on `https://agentoffice.top`
|
|
36
|
+
2. Start the local runtime
|
|
37
|
+
3. Launch workers from another terminal
|
|
38
|
+
|
|
39
|
+
Using an environment variable:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
export AGENTOFFICE_API_KEY=sk_your_api_key
|
|
43
|
+
ato start
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Or pass the key directly:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
ato start --key sk_your_api_key
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Launch workers:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
ato claude
|
|
56
|
+
ato codex
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Launch with a custom title:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
ato claude -t "Review PR #42"
|
|
63
|
+
ato codex -t "Fix login bug"
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Attach your local terminal to an existing worker:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
ato attach <sessionId>
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Common Commands
|
|
73
|
+
|
|
74
|
+
### `ato start`
|
|
75
|
+
|
|
76
|
+
Starts the local AgentOffice runtime, restores managed sessions, and connects to the hosted relay when an API key is present.
|
|
77
|
+
|
|
78
|
+
Examples:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
ato start
|
|
82
|
+
ato start --key sk_your_api_key
|
|
83
|
+
ato start --key sk_your_api_key --relay https://your-relay.example.com
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Notes:
|
|
87
|
+
|
|
88
|
+
- On macOS, `ato start` keeps the machine awake while the tunnel is active
|
|
89
|
+
- Hosted tunnel logs are written to `~/.agentoffice/logs/tunnel.log`
|
|
90
|
+
|
|
91
|
+
### `ato claude`
|
|
92
|
+
|
|
93
|
+
Launches a Claude Code worker in tmux so it can be supervised from AgentOffice.
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
ato claude
|
|
97
|
+
ato claude -t "Investigate flaky test"
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### `ato codex`
|
|
101
|
+
|
|
102
|
+
Launches a Codex worker in tmux.
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
ato codex
|
|
106
|
+
ato codex -t "Refactor websocket retry logic"
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### `ato attach`
|
|
110
|
+
|
|
111
|
+
Attaches your local shell directly to a worker's tmux session.
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
ato attach <sessionId>
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Hosted Mode
|
|
118
|
+
|
|
119
|
+
When you start the runtime with an API key, the CLI opens a secure tunnel from your local machine to the AgentOffice relay.
|
|
120
|
+
|
|
121
|
+
That tunnel is used to:
|
|
122
|
+
|
|
123
|
+
- show your workers in the Office UI
|
|
124
|
+
- open remote terminals from web or mobile
|
|
125
|
+
- launch workers from the AgentOffice interface
|
|
126
|
+
- proxy requests into the local runtime
|
|
127
|
+
|
|
128
|
+
If the connection drops, the tunnel automatically retries and records reconnect details in the local tunnel log.
|
|
129
|
+
|
|
130
|
+
## Troubleshooting
|
|
131
|
+
|
|
132
|
+
### `tmux is required`
|
|
133
|
+
|
|
134
|
+
Install `tmux` first, then run `ato start` again.
|
|
135
|
+
|
|
136
|
+
### Worker launch commands are missing
|
|
137
|
+
|
|
138
|
+
Make sure the provider CLI is installed and available on `PATH`:
|
|
139
|
+
|
|
140
|
+
- `claude`
|
|
141
|
+
- `codex`
|
|
142
|
+
|
|
143
|
+
### Remote Office shows offline
|
|
144
|
+
|
|
145
|
+
Check the local tunnel log:
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
tail -n 100 ~/.agentoffice/logs/tunnel.log
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Look for:
|
|
152
|
+
|
|
153
|
+
- websocket connection errors
|
|
154
|
+
- reconnect attempts
|
|
155
|
+
- auth failures
|
|
156
|
+
- relay disconnect reasons
|
|
157
|
+
|
|
158
|
+
## Package Scope
|
|
159
|
+
|
|
160
|
+
This package is the CLI/runtime portion of AgentOffice only.
|
|
161
|
+
|
|
162
|
+
It does not include the full web app source or product docs. Those live in the main repository:
|
|
163
|
+
|
|
164
|
+
- GitHub: `https://github.com/fakeou/agent-office`
|
|
165
|
+
|
|
166
|
+
## License
|
|
167
|
+
|
|
168
|
+
MIT
|
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
|
+
});
|