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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-office-cli",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "Run and manage AI agent sessions locally, with optional relay to agentoffice.top",
5
5
  "license": "MIT",
6
6
  "engines": {
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;
@@ -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
- function createTunnelClient({ key, relayUrl, localServerUrl }) {
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 = RECONNECT_BASE_MS;
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
- ws = new WebSocket(url);
122
+ const socket = new WebSocket(url);
123
+ let socketAuthenticated = false;
124
+ let lastActivityAt = Date.now();
68
125
 
69
- ws.on("open", () => {
70
- reconnectDelay = RECONNECT_BASE_MS;
71
- console.log("[tunnel] connected to relay, authenticating...");
72
- ws.send(JSON.stringify({ type: "auth", key }));
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
- ws.on("message", async (raw) => {
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
- console.log(`[tunnel] authenticated with relay: ${relayUrl} (userId=${msg.userId})`);
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
- console.error(`[tunnel] authentication failed: ${msg.error || "invalid key"}`);
100
- stopped = true;
101
- ws.close();
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
- console.error(`[tunnel] message error: ${err.message}`);
210
+ logger.error(`[tunnel] message error: ${err.message}`);
112
211
  }
113
212
  });
114
213
 
115
- ws.on("close", (code) => {
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 === 4401) {
120
- console.error("[tunnel] authentication rejected by relay. Not reconnecting.");
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
- console.log(`[tunnel] disconnected (${code}). Reconnecting in ${reconnectDelay}ms...`);
125
- setTimeout(() => {
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
- ws.on("error", (err) => {
132
- console.error(`[tunnel] ws error: ${err.message}`);
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
  };
@@ -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
+ });