@kortix/agent-tunnel 0.13.41 → 0.13.42
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 +120 -11
- package/dist/agent-cli.js +1144 -435
- package/dist/client-cli.js +1 -3
- package/package.json +1 -1
- package/src/agent/access.test.ts +164 -0
- package/src/agent/access.ts +176 -0
- package/src/agent/agent.test.ts +641 -0
- package/src/agent/agent.ts +333 -10
- package/src/agent/capabilities/security.test.ts +20 -1
- package/src/agent/cli-device-auth.test.ts +114 -0
- package/src/agent/cli-reauth.test.ts +108 -8
- package/src/agent/cli.ts +201 -57
- package/src/agent/config.test.ts +5 -0
- package/src/agent/config.ts +17 -2
- package/src/agent/credential-probe.ts +3 -3
- package/src/agent/credential-store.ts +2 -2
- package/src/agent/device-auth.test.ts +57 -0
- package/src/agent/device-auth.ts +45 -3
- package/src/agent/security/path-validator.ts +3 -1
- package/src/agent/service-control.ts +5 -2
- package/src/agent/service-drivers.test.ts +164 -0
- package/src/agent/service-drivers.ts +205 -56
- package/src/agent/service-lifecycle.test.ts +32 -18
- package/src/agent/service-paths.ts +42 -10
- package/src/agent/service.test.ts +126 -5
- package/src/agent/service.ts +99 -12
- package/src/agent/state-file.test.ts +32 -0
- package/src/agent/state-file.ts +58 -0
- package/src/client/tunnel-client.ts +6 -3
- package/src/server/ws-handler.test.ts +31 -1
- package/src/server/ws-handler.ts +3 -3
- package/src/shared/types.ts +12 -0
|
@@ -0,0 +1,641 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import { TunnelAgent, isCredentialRejection, type TunnelAgentStatus } from './agent';
|
|
4
|
+
import { CapabilityRegistry } from './capabilities/index';
|
|
5
|
+
import { loadConfig } from './config';
|
|
6
|
+
|
|
7
|
+
const RealWebSocket = globalThis.WebSocket;
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
globalThis.WebSocket = RealWebSocket;
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
/** Node 22's built-in WebSocket on a refused connection: 'error', and no 'close'. */
|
|
13
|
+
class ErrorOnlyWebSocket extends EventTarget {
|
|
14
|
+
static OPEN = 1;
|
|
15
|
+
static created = 0;
|
|
16
|
+
readyState = 3;
|
|
17
|
+
constructor(_url: URL) {
|
|
18
|
+
super();
|
|
19
|
+
ErrorOnlyWebSocket.created++;
|
|
20
|
+
setTimeout(() => this.dispatchEvent(new Event('error')), 5);
|
|
21
|
+
}
|
|
22
|
+
close() {}
|
|
23
|
+
send() {}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('TunnelAgent connection status', () => {
|
|
27
|
+
test('reconnects and reports offline when the relay is unreachable and no close event follows', async () => {
|
|
28
|
+
globalThis.WebSocket = ErrorOnlyWebSocket as unknown as typeof WebSocket;
|
|
29
|
+
ErrorOnlyWebSocket.created = 0;
|
|
30
|
+
const statuses: TunnelAgentStatus[] = [];
|
|
31
|
+
const agent = new TunnelAgent(
|
|
32
|
+
loadConfig({
|
|
33
|
+
apiUrl: 'http://127.0.0.1:9/v1/tunnel',
|
|
34
|
+
token: 'kortix_tnl_ABCDEFGHIJKLMNOPQRSTUVWXYZ123456',
|
|
35
|
+
tunnelId: '00000000-0000-4000-8000-000000000001',
|
|
36
|
+
}),
|
|
37
|
+
new CapabilityRegistry(),
|
|
38
|
+
{ onStatus: (status) => statuses.push(status) },
|
|
39
|
+
);
|
|
40
|
+
const originalWrite = process.stdout.write;
|
|
41
|
+
process.stdout.write = (() => true) as typeof process.stdout.write;
|
|
42
|
+
try {
|
|
43
|
+
agent.connect();
|
|
44
|
+
// error at 5 ms, stand-in close at ~1 s, reconnect after the 1 s backoff.
|
|
45
|
+
await Bun.sleep(2_300);
|
|
46
|
+
} finally {
|
|
47
|
+
agent.disconnect();
|
|
48
|
+
process.stdout.write = originalWrite;
|
|
49
|
+
}
|
|
50
|
+
expect(ErrorOnlyWebSocket.created).toBeGreaterThanOrEqual(2);
|
|
51
|
+
expect(statuses.slice(0, 3)).toEqual(['connecting', 'offline', 'connecting']);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
/** Opens, then closes with the given code/reason once the agent sends auth. */
|
|
56
|
+
function closingWebSocket(code: number, reason: string) {
|
|
57
|
+
return class extends EventTarget {
|
|
58
|
+
static OPEN = 1;
|
|
59
|
+
static created = 0;
|
|
60
|
+
readyState = 1;
|
|
61
|
+
constructor(_url: URL) {
|
|
62
|
+
super();
|
|
63
|
+
(this.constructor as unknown as { created: number }).created++;
|
|
64
|
+
setTimeout(() => this.dispatchEvent(new Event('open')), 1);
|
|
65
|
+
}
|
|
66
|
+
send() {
|
|
67
|
+
setTimeout(() => {
|
|
68
|
+
this.readyState = 3;
|
|
69
|
+
this.dispatchEvent(Object.assign(new Event('close'), { code, reason }));
|
|
70
|
+
}, 1);
|
|
71
|
+
}
|
|
72
|
+
close() {}
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function quietAgent(onTerminalClose: (e: { reason: string }) => void, statuses: TunnelAgentStatus[]) {
|
|
77
|
+
return new TunnelAgent(
|
|
78
|
+
loadConfig({
|
|
79
|
+
apiUrl: 'http://127.0.0.1:9/v1/tunnel',
|
|
80
|
+
token: 'kortix_tnl_ABCDEFGHIJKLMNOPQRSTUVWXYZ123456',
|
|
81
|
+
tunnelId: '00000000-0000-4000-8000-000000000001',
|
|
82
|
+
}),
|
|
83
|
+
new CapabilityRegistry(),
|
|
84
|
+
{ onStatus: (status) => statuses.push(status), onTerminalClose },
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
describe('TunnelAgent auth close handling', () => {
|
|
89
|
+
test('a 4001 caused by a relay-side auth error is retried, not treated as a bad credential', async () => {
|
|
90
|
+
const Socket = closingWebSocket(4001, 'authentication error');
|
|
91
|
+
globalThis.WebSocket = Socket as unknown as typeof WebSocket;
|
|
92
|
+
const terminal: string[] = [];
|
|
93
|
+
const statuses: TunnelAgentStatus[] = [];
|
|
94
|
+
const agent = quietAgent((e) => terminal.push(e.reason), statuses);
|
|
95
|
+
const originalWrite = process.stdout.write;
|
|
96
|
+
process.stdout.write = (() => true) as typeof process.stdout.write;
|
|
97
|
+
try {
|
|
98
|
+
agent.connect();
|
|
99
|
+
await Bun.sleep(1_300);
|
|
100
|
+
} finally {
|
|
101
|
+
agent.disconnect();
|
|
102
|
+
process.stdout.write = originalWrite;
|
|
103
|
+
}
|
|
104
|
+
expect(terminal).toEqual([]);
|
|
105
|
+
expect(Socket.created).toBeGreaterThanOrEqual(2);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('a 4001 for a refused secret still stops the agent', async () => {
|
|
109
|
+
globalThis.WebSocket = closingWebSocket(4001, 'authentication failed') as unknown as typeof WebSocket;
|
|
110
|
+
const terminal: string[] = [];
|
|
111
|
+
const agent = quietAgent((e) => terminal.push(e.reason), []);
|
|
112
|
+
const originalWrite = process.stdout.write;
|
|
113
|
+
process.stdout.write = (() => true) as typeof process.stdout.write;
|
|
114
|
+
try {
|
|
115
|
+
agent.connect();
|
|
116
|
+
await Bun.sleep(50);
|
|
117
|
+
} finally {
|
|
118
|
+
agent.disconnect();
|
|
119
|
+
process.stdout.write = originalWrite;
|
|
120
|
+
}
|
|
121
|
+
expect(terminal).toEqual(['credential-rejected']);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('isCredentialRejection separates bad credentials from relay trouble', () => {
|
|
125
|
+
expect(isCredentialRejection(4001, 'authentication failed')).toBe(true);
|
|
126
|
+
expect(isCredentialRejection(4001, 'auth failed')).toBe(true);
|
|
127
|
+
expect(isCredentialRejection(4003, '')).toBe(true);
|
|
128
|
+
expect(isCredentialRejection(4001, 'authentication error')).toBe(false);
|
|
129
|
+
expect(isCredentialRejection(4001, 'auth timeout')).toBe(false);
|
|
130
|
+
expect(isCredentialRejection(4001, 'authentication response failed')).toBe(false);
|
|
131
|
+
expect(isCredentialRejection(1011, 'authentication error')).toBe(false);
|
|
132
|
+
expect(isCredentialRejection(1006, '')).toBe(false);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// ── v2: reliability (R1, R2, R4), access control (A2, A3) and X2 ───────────
|
|
137
|
+
|
|
138
|
+
import { mkdtempSync, rmSync, existsSync as fileExists } from 'node:fs';
|
|
139
|
+
import { tmpdir } from 'node:os';
|
|
140
|
+
import { join } from 'node:path';
|
|
141
|
+
import { reconnectDelay } from './agent';
|
|
142
|
+
import { writeAccess, accessRequestPath, readAccessRequest } from './access';
|
|
143
|
+
import { signMessage } from '../shared/crypto';
|
|
144
|
+
|
|
145
|
+
const SIGNING_KEY = 'test-signing-key';
|
|
146
|
+
|
|
147
|
+
/** A relay that authenticates, then lets the test push signed messages. */
|
|
148
|
+
class FakeRelay extends EventTarget {
|
|
149
|
+
static OPEN = 1;
|
|
150
|
+
static sockets: FakeRelay[] = [];
|
|
151
|
+
readyState = 1;
|
|
152
|
+
sent: any[] = [];
|
|
153
|
+
closedWith: { code?: number; reason?: string } | null = null;
|
|
154
|
+
private nonce = 0;
|
|
155
|
+
constructor(_url: URL) {
|
|
156
|
+
super();
|
|
157
|
+
FakeRelay.sockets.push(this);
|
|
158
|
+
setTimeout(() => this.dispatchEvent(new Event('open')), 1);
|
|
159
|
+
}
|
|
160
|
+
send(raw: string) {
|
|
161
|
+
const msg = JSON.parse(raw);
|
|
162
|
+
this.sent.push(msg);
|
|
163
|
+
if (msg.type === 'auth') this.deliverRaw({ type: 'auth_ok', signingKey: SIGNING_KEY });
|
|
164
|
+
}
|
|
165
|
+
close(code?: number, reason?: string) {
|
|
166
|
+
if (this.readyState === 3) return;
|
|
167
|
+
this.readyState = 3;
|
|
168
|
+
this.closedWith = { code, reason };
|
|
169
|
+
}
|
|
170
|
+
deliverRaw(msg: unknown) {
|
|
171
|
+
setTimeout(() => this.dispatchEvent(Object.assign(new Event('message'), { data: JSON.stringify(msg) })), 1);
|
|
172
|
+
}
|
|
173
|
+
deliverSigned(msg: Record<string, unknown>) {
|
|
174
|
+
const nonce = ++this.nonce;
|
|
175
|
+
this.deliverRaw({ ...msg, _sig: signMessage(SIGNING_KEY, JSON.stringify(msg), nonce), _nonce: nonce });
|
|
176
|
+
}
|
|
177
|
+
serverClose(code: number, reason = '') {
|
|
178
|
+
this.readyState = 3;
|
|
179
|
+
this.dispatchEvent(Object.assign(new Event('close'), { code, reason }));
|
|
180
|
+
}
|
|
181
|
+
responses(id: string) {
|
|
182
|
+
return this.sent.filter((m) => m.id === id);
|
|
183
|
+
}
|
|
184
|
+
notifications(method: string) {
|
|
185
|
+
return this.sent.filter((m) => m.method === method);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const TEST_CONFIG = {
|
|
190
|
+
apiUrl: 'http://127.0.0.1:9/v1/tunnel',
|
|
191
|
+
token: 'kortix_tnl_ABCDEFGHIJKLMNOPQRSTUVWXYZ123456',
|
|
192
|
+
tunnelId: '00000000-0000-4000-8000-000000000001',
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
function quiet<T>(fn: () => Promise<T>): Promise<T> {
|
|
196
|
+
const originalWrite = process.stdout.write;
|
|
197
|
+
process.stdout.write = (() => true) as typeof process.stdout.write;
|
|
198
|
+
return fn().finally(() => {
|
|
199
|
+
process.stdout.write = originalWrite;
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function until(check: () => boolean, timeoutMs = 3_000): Promise<void> {
|
|
204
|
+
const deadline = Date.now() + timeoutMs;
|
|
205
|
+
while (!check()) {
|
|
206
|
+
if (Date.now() > deadline) throw new Error('condition not met in time');
|
|
207
|
+
await Bun.sleep(5);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
describe('reconnect backoff (R4)', () => {
|
|
212
|
+
test('doubles from 1 s with ±20 % jitter and never exceeds 30 s', () => {
|
|
213
|
+
expect(reconnectDelay(1, 0.5)).toBe(1_000);
|
|
214
|
+
expect(reconnectDelay(1, 0)).toBe(800);
|
|
215
|
+
expect(reconnectDelay(1, 1)).toBe(1_200);
|
|
216
|
+
expect(reconnectDelay(3, 0.5)).toBe(4_000);
|
|
217
|
+
expect(reconnectDelay(40, 0)).toBe(24_000);
|
|
218
|
+
expect(reconnectDelay(40, 1)).toBe(30_000);
|
|
219
|
+
expect(reconnectDelay(1_000, 0.99)).toBeLessThanOrEqual(30_000);
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
describe('service mode never gives up (R2)', () => {
|
|
224
|
+
test('a refused credential parks the agent in `rejected` and re-probes later', async () => {
|
|
225
|
+
FakeRelay.sockets = [];
|
|
226
|
+
globalThis.WebSocket = closingWebSocket(4001, 'authentication failed') as unknown as typeof WebSocket;
|
|
227
|
+
const Socket = globalThis.WebSocket as unknown as { created: number };
|
|
228
|
+
Socket.created = 0;
|
|
229
|
+
const terminal: string[] = [];
|
|
230
|
+
const statuses: TunnelAgentStatus[] = [];
|
|
231
|
+
const agent = new TunnelAgent(
|
|
232
|
+
loadConfig(TEST_CONFIG),
|
|
233
|
+
new CapabilityRegistry(),
|
|
234
|
+
{ onStatus: (s) => statuses.push(s), onTerminalClose: (e) => terminal.push(e.reason) },
|
|
235
|
+
{ persistent: true, rejectedRetryMs: 60 },
|
|
236
|
+
);
|
|
237
|
+
await quiet(async () => {
|
|
238
|
+
try {
|
|
239
|
+
agent.connect();
|
|
240
|
+
await until(() => Socket.created >= 2);
|
|
241
|
+
} finally {
|
|
242
|
+
agent.disconnect();
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
expect(terminal).toEqual([]);
|
|
246
|
+
expect(statuses).toContain('rejected');
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test('a re-pair while `rejected` reconnects at once, without waiting out the re-probe', async () => {
|
|
250
|
+
globalThis.WebSocket = closingWebSocket(4001, 'authentication failed') as unknown as typeof WebSocket;
|
|
251
|
+
const Socket = globalThis.WebSocket as unknown as { created: number };
|
|
252
|
+
Socket.created = 0;
|
|
253
|
+
const statuses: TunnelAgentStatus[] = [];
|
|
254
|
+
let saved = loadConfig(TEST_CONFIG);
|
|
255
|
+
const agent = new TunnelAgent(loadConfig(TEST_CONFIG), new CapabilityRegistry(), { onStatus: (s) => statuses.push(s) }, {
|
|
256
|
+
persistent: true,
|
|
257
|
+
rejectedRetryMs: 60_000,
|
|
258
|
+
watchdogIntervalMs: 20,
|
|
259
|
+
reloadConfig: () => saved,
|
|
260
|
+
home: mkdtempSync(join(tmpdir(), 'agent-tunnel-repair-')),
|
|
261
|
+
});
|
|
262
|
+
await quiet(async () => {
|
|
263
|
+
try {
|
|
264
|
+
agent.connect();
|
|
265
|
+
await until(() => statuses.includes('rejected'));
|
|
266
|
+
await Bun.sleep(100);
|
|
267
|
+
expect(Socket.created).toBe(1); // the same credential is not retried early
|
|
268
|
+
saved = loadConfig({ ...TEST_CONFIG, tunnelId: '00000000-0000-4000-8000-000000000002' });
|
|
269
|
+
await until(() => Socket.created >= 2, 1_000);
|
|
270
|
+
} finally {
|
|
271
|
+
agent.disconnect();
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test('a replaced agent waits in `standby` and tries again', async () => {
|
|
277
|
+
globalThis.WebSocket = closingWebSocket(4004, 'replaced') as unknown as typeof WebSocket;
|
|
278
|
+
const Socket = globalThis.WebSocket as unknown as { created: number };
|
|
279
|
+
Socket.created = 0;
|
|
280
|
+
const statuses: TunnelAgentStatus[] = [];
|
|
281
|
+
const agent = new TunnelAgent(
|
|
282
|
+
loadConfig(TEST_CONFIG),
|
|
283
|
+
new CapabilityRegistry(),
|
|
284
|
+
{ onStatus: (s) => statuses.push(s) },
|
|
285
|
+
{ persistent: true, standbyRetryMs: 60 },
|
|
286
|
+
);
|
|
287
|
+
await quiet(async () => {
|
|
288
|
+
try {
|
|
289
|
+
agent.connect();
|
|
290
|
+
await until(() => Socket.created >= 2);
|
|
291
|
+
} finally {
|
|
292
|
+
agent.disconnect();
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
expect(statuses).toContain('standby');
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
describe('liveness watchdog (R1)', () => {
|
|
300
|
+
test('no relay ping for the liveness window closes the socket with 4000 and reconnects', async () => {
|
|
301
|
+
FakeRelay.sockets = [];
|
|
302
|
+
globalThis.WebSocket = FakeRelay as unknown as typeof WebSocket;
|
|
303
|
+
const statuses: TunnelAgentStatus[] = [];
|
|
304
|
+
const agent = new TunnelAgent(loadConfig(TEST_CONFIG), new CapabilityRegistry(), { onStatus: (s) => statuses.push(s) }, {
|
|
305
|
+
livenessTimeoutMs: 150,
|
|
306
|
+
watchdogIntervalMs: 20,
|
|
307
|
+
home: mkdtempSync(join(tmpdir(), 'agent-tunnel-watchdog-')),
|
|
308
|
+
});
|
|
309
|
+
await quiet(async () => {
|
|
310
|
+
try {
|
|
311
|
+
agent.connect();
|
|
312
|
+
await until(() => statuses.includes('online'));
|
|
313
|
+
await until(() => FakeRelay.sockets.length >= 2, 4_000);
|
|
314
|
+
} finally {
|
|
315
|
+
agent.disconnect();
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
expect(FakeRelay.sockets[0]!.closedWith).toEqual({ code: 4000, reason: 'liveness timeout' });
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test('pings keep the connection; a wall-clock jump (sleep/wake) forces a reconnect', async () => {
|
|
322
|
+
FakeRelay.sockets = [];
|
|
323
|
+
globalThis.WebSocket = FakeRelay as unknown as typeof WebSocket;
|
|
324
|
+
let clock = Date.now();
|
|
325
|
+
const statuses: TunnelAgentStatus[] = [];
|
|
326
|
+
const agent = new TunnelAgent(loadConfig(TEST_CONFIG), new CapabilityRegistry(), { onStatus: (s) => statuses.push(s) }, {
|
|
327
|
+
livenessTimeoutMs: 10_000,
|
|
328
|
+
watchdogIntervalMs: 20,
|
|
329
|
+
now: () => clock,
|
|
330
|
+
home: mkdtempSync(join(tmpdir(), 'agent-tunnel-watchdog-')),
|
|
331
|
+
});
|
|
332
|
+
await quiet(async () => {
|
|
333
|
+
try {
|
|
334
|
+
agent.connect();
|
|
335
|
+
await until(() => statuses.includes('online'));
|
|
336
|
+
FakeRelay.sockets[0]!.deliverSigned({ jsonrpc: '2.0', method: 'tunnel.ping', params: {} });
|
|
337
|
+
await until(() => FakeRelay.sockets[0]!.notifications('tunnel.pong').length === 1);
|
|
338
|
+
await Bun.sleep(100);
|
|
339
|
+
expect(FakeRelay.sockets).toHaveLength(1);
|
|
340
|
+
clock += 60_000; // the machine slept for a minute
|
|
341
|
+
await until(() => FakeRelay.sockets.length >= 2, 4_000);
|
|
342
|
+
} finally {
|
|
343
|
+
agent.disconnect();
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
expect(FakeRelay.sockets[0]!.closedWith?.code).toBe(4000);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
test('after a sleep the agent reconnects at once, whatever backoff it had built up before', async () => {
|
|
350
|
+
FakeRelay.sockets = [];
|
|
351
|
+
globalThis.WebSocket = FakeRelay as unknown as typeof WebSocket;
|
|
352
|
+
let clock = Date.now();
|
|
353
|
+
const statuses: TunnelAgentStatus[] = [];
|
|
354
|
+
const agent = new TunnelAgent(loadConfig(TEST_CONFIG), new CapabilityRegistry(), { onStatus: (s) => statuses.push(s) }, {
|
|
355
|
+
livenessTimeoutMs: 10_000,
|
|
356
|
+
watchdogIntervalMs: 20,
|
|
357
|
+
now: () => clock,
|
|
358
|
+
home: mkdtempSync(join(tmpdir(), 'agent-tunnel-watchdog-')),
|
|
359
|
+
});
|
|
360
|
+
await quiet(async () => {
|
|
361
|
+
try {
|
|
362
|
+
agent.connect();
|
|
363
|
+
await until(() => statuses.includes('online'));
|
|
364
|
+
// Flapping before the sleep left the backoff at its 30 s cap.
|
|
365
|
+
(agent as unknown as { reconnectAttempts: number }).reconnectAttempts = 12;
|
|
366
|
+
clock += 120_000;
|
|
367
|
+
await until(() => FakeRelay.sockets.length >= 2, 2_000);
|
|
368
|
+
} finally {
|
|
369
|
+
agent.disconnect();
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
describe('machine-side access control (A2, A3, X1, X2)', () => {
|
|
376
|
+
function accessAgent(home: string, holdMs = 200) {
|
|
377
|
+
const registry = new CapabilityRegistry();
|
|
378
|
+
registry.register({
|
|
379
|
+
name: 'filesystem',
|
|
380
|
+
methods: new Map([['fs.stat', async () => ({ ok: true })]]),
|
|
381
|
+
} as any);
|
|
382
|
+
return new TunnelAgent(loadConfig(TEST_CONFIG), registry, {}, { home, accessHoldMs: holdMs, watchdogIntervalMs: 20 });
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async function withOnlineAgent(home: string, run: (relay: FakeRelay) => Promise<void>, holdMs?: number) {
|
|
386
|
+
FakeRelay.sockets = [];
|
|
387
|
+
globalThis.WebSocket = FakeRelay as unknown as typeof WebSocket;
|
|
388
|
+
const agent = accessAgent(home, holdMs);
|
|
389
|
+
await quiet(async () => {
|
|
390
|
+
try {
|
|
391
|
+
agent.connect();
|
|
392
|
+
await until(() => FakeRelay.sockets[0]?.sent.some((m) => m.type === 'auth') === true);
|
|
393
|
+
const relay = FakeRelay.sockets[0]!;
|
|
394
|
+
relay.deliverSigned({
|
|
395
|
+
jsonrpc: '2.0',
|
|
396
|
+
method: 'tunnel.permissions.sync',
|
|
397
|
+
params: { permissions: [{ permissionId: 'perm-fs', capability: 'filesystem', scope: {} }] },
|
|
398
|
+
});
|
|
399
|
+
await Bun.sleep(20);
|
|
400
|
+
await run(relay);
|
|
401
|
+
} finally {
|
|
402
|
+
agent.disconnect();
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const call = (relay: FakeRelay, id: string) =>
|
|
408
|
+
relay.deliverSigned({ jsonrpc: '2.0', id, method: 'fs.stat', params: { permissionId: 'perm-fs', path: '/' } });
|
|
409
|
+
|
|
410
|
+
test('always runs the call; off refuses it with -32012', async () => {
|
|
411
|
+
const home = mkdtempSync(join(tmpdir(), 'agent-tunnel-access-'));
|
|
412
|
+
try {
|
|
413
|
+
writeAccess({ mode: 'always', grantedUntil: null, deniedUntil: null, keepAwake: false }, home);
|
|
414
|
+
await withOnlineAgent(home, async (relay) => {
|
|
415
|
+
call(relay, 'r1');
|
|
416
|
+
await until(() => relay.responses('r1').length === 1);
|
|
417
|
+
expect(relay.responses('r1')[0].result).toEqual({ ok: true });
|
|
418
|
+
|
|
419
|
+
writeAccess({ mode: 'off', grantedUntil: null, deniedUntil: null, keepAwake: false }, home);
|
|
420
|
+
call(relay, 'r2');
|
|
421
|
+
await until(() => relay.responses('r2').length === 1);
|
|
422
|
+
expect(relay.responses('r2')[0].error.code).toBe(-32012);
|
|
423
|
+
expect(relay.responses('r2')[0].error.message).toStartWith('computer_access_off:');
|
|
424
|
+
});
|
|
425
|
+
} finally {
|
|
426
|
+
rmSync(home, { recursive: true, force: true });
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
test('ask with no answer holds the call, writes a request, then returns -32010', async () => {
|
|
431
|
+
const home = mkdtempSync(join(tmpdir(), 'agent-tunnel-access-'));
|
|
432
|
+
try {
|
|
433
|
+
writeAccess({ mode: 'ask', grantedUntil: null, deniedUntil: null, keepAwake: false }, home);
|
|
434
|
+
await withOnlineAgent(home, async (relay) => {
|
|
435
|
+
const started = Date.now();
|
|
436
|
+
call(relay, 'r1');
|
|
437
|
+
await until(() => fileExists(accessRequestPath(home)));
|
|
438
|
+
await until(() => relay.responses('r1').length === 1);
|
|
439
|
+
expect(Date.now() - started).toBeGreaterThanOrEqual(180);
|
|
440
|
+
expect(relay.responses('r1')[0].error.code).toBe(-32010);
|
|
441
|
+
expect(relay.responses('r1')[0].error.message).toStartWith('computer_access_pending:');
|
|
442
|
+
});
|
|
443
|
+
} finally {
|
|
444
|
+
rmSync(home, { recursive: true, force: true });
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
test('ask: a grant given during the hold runs the held call', async () => {
|
|
449
|
+
const home = mkdtempSync(join(tmpdir(), 'agent-tunnel-access-'));
|
|
450
|
+
try {
|
|
451
|
+
writeAccess({ mode: 'ask', grantedUntil: null, deniedUntil: null, keepAwake: false }, home);
|
|
452
|
+
await withOnlineAgent(
|
|
453
|
+
home,
|
|
454
|
+
async (relay) => {
|
|
455
|
+
call(relay, 'r1');
|
|
456
|
+
await until(() => fileExists(accessRequestPath(home)));
|
|
457
|
+
writeAccess(
|
|
458
|
+
{ mode: 'ask', grantedUntil: new Date(Date.now() + 3_600_000).toISOString(), deniedUntil: null, keepAwake: false },
|
|
459
|
+
home,
|
|
460
|
+
);
|
|
461
|
+
await until(() => relay.responses('r1').length === 1);
|
|
462
|
+
expect(relay.responses('r1')[0].result).toEqual({ ok: true });
|
|
463
|
+
expect(fileExists(accessRequestPath(home))).toBe(false);
|
|
464
|
+
},
|
|
465
|
+
5_000,
|
|
466
|
+
);
|
|
467
|
+
} finally {
|
|
468
|
+
rmSync(home, { recursive: true, force: true });
|
|
469
|
+
}
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
test('ask: a denial fails the held call with -32011, and later calls fail fast', async () => {
|
|
473
|
+
const home = mkdtempSync(join(tmpdir(), 'agent-tunnel-access-'));
|
|
474
|
+
try {
|
|
475
|
+
writeAccess({ mode: 'ask', grantedUntil: null, deniedUntil: null, keepAwake: false }, home);
|
|
476
|
+
await withOnlineAgent(
|
|
477
|
+
home,
|
|
478
|
+
async (relay) => {
|
|
479
|
+
call(relay, 'r1');
|
|
480
|
+
await until(() => fileExists(accessRequestPath(home)));
|
|
481
|
+
writeAccess(
|
|
482
|
+
{ mode: 'ask', grantedUntil: null, deniedUntil: new Date(Date.now() + 600_000).toISOString(), keepAwake: false },
|
|
483
|
+
home,
|
|
484
|
+
);
|
|
485
|
+
await until(() => relay.responses('r1').length === 1);
|
|
486
|
+
expect(relay.responses('r1')[0].error.code).toBe(-32011);
|
|
487
|
+
const started = Date.now();
|
|
488
|
+
call(relay, 'r2');
|
|
489
|
+
await until(() => relay.responses('r2').length === 1);
|
|
490
|
+
expect(relay.responses('r2')[0].error.code).toBe(-32011);
|
|
491
|
+
expect(Date.now() - started).toBeLessThan(1_000);
|
|
492
|
+
},
|
|
493
|
+
5_000,
|
|
494
|
+
);
|
|
495
|
+
} finally {
|
|
496
|
+
rmSync(home, { recursive: true, force: true });
|
|
497
|
+
}
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
test('reports the access state to the relay on connect and on every change', async () => {
|
|
501
|
+
const home = mkdtempSync(join(tmpdir(), 'agent-tunnel-access-'));
|
|
502
|
+
try {
|
|
503
|
+
writeAccess({ mode: 'ask', grantedUntil: null, deniedUntil: null, keepAwake: false }, home);
|
|
504
|
+
await withOnlineAgent(home, async (relay) => {
|
|
505
|
+
await until(() => relay.notifications('tunnel.access.state').length === 1);
|
|
506
|
+
expect(relay.notifications('tunnel.access.state')[0].params).toEqual({ mode: 'ask', grantedUntil: null });
|
|
507
|
+
expect(typeof relay.notifications('tunnel.access.state')[0]._sig).toBe('string');
|
|
508
|
+
writeAccess({ mode: 'off', grantedUntil: null, deniedUntil: null, keepAwake: false }, home);
|
|
509
|
+
await until(() => relay.notifications('tunnel.access.state').length === 2);
|
|
510
|
+
expect(relay.notifications('tunnel.access.state')[1].params).toEqual({ mode: 'off', grantedUntil: null });
|
|
511
|
+
});
|
|
512
|
+
} finally {
|
|
513
|
+
rmSync(home, { recursive: true, force: true });
|
|
514
|
+
}
|
|
515
|
+
});
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
describe('contract v2 review fixes', () => {
|
|
519
|
+
test('standby backs off, doubling, so two holders of one credential stop trading the socket', async () => {
|
|
520
|
+
globalThis.WebSocket = closingWebSocket(4004, 'replaced') as unknown as typeof WebSocket;
|
|
521
|
+
const Socket = globalThis.WebSocket as unknown as { created: number };
|
|
522
|
+
Socket.created = 0;
|
|
523
|
+
const agent = new TunnelAgent(loadConfig(TEST_CONFIG), new CapabilityRegistry(), {}, { persistent: true, standbyRetryMs: 40 });
|
|
524
|
+
await quiet(async () => {
|
|
525
|
+
try {
|
|
526
|
+
agent.connect();
|
|
527
|
+
await Bun.sleep(400);
|
|
528
|
+
} finally {
|
|
529
|
+
agent.disconnect();
|
|
530
|
+
}
|
|
531
|
+
});
|
|
532
|
+
// A fixed 40 ms retry would open about 10 sockets; 40, 80, 160 ms opens 4.
|
|
533
|
+
expect(Socket.created).toBeGreaterThanOrEqual(3);
|
|
534
|
+
expect(Socket.created).toBeLessThanOrEqual(5);
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
test('a handshake that never reaches auth_ok is dropped and retried', async () => {
|
|
538
|
+
class SilentRelay extends EventTarget {
|
|
539
|
+
static OPEN = 1;
|
|
540
|
+
static created = 0;
|
|
541
|
+
readyState = 1;
|
|
542
|
+
closedWith: number | undefined;
|
|
543
|
+
constructor(_url: URL) {
|
|
544
|
+
super();
|
|
545
|
+
SilentRelay.created++;
|
|
546
|
+
setTimeout(() => this.dispatchEvent(new Event('open')), 1);
|
|
547
|
+
}
|
|
548
|
+
send() {}
|
|
549
|
+
close(code?: number) { this.closedWith = code; }
|
|
550
|
+
}
|
|
551
|
+
globalThis.WebSocket = SilentRelay as unknown as typeof WebSocket;
|
|
552
|
+
const agent = new TunnelAgent(loadConfig(TEST_CONFIG), new CapabilityRegistry(), {}, {
|
|
553
|
+
connectDeadlineMs: 100,
|
|
554
|
+
watchdogIntervalMs: 20,
|
|
555
|
+
home: mkdtempSync(join(tmpdir(), 'agent-tunnel-handshake-')),
|
|
556
|
+
});
|
|
557
|
+
await quiet(async () => {
|
|
558
|
+
try {
|
|
559
|
+
agent.connect();
|
|
560
|
+
await until(() => SilentRelay.created >= 2, 3_000);
|
|
561
|
+
} finally {
|
|
562
|
+
agent.disconnect();
|
|
563
|
+
}
|
|
564
|
+
});
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
test('onStatus names the credential in use, so state.json follows a re-pair', async () => {
|
|
568
|
+
globalThis.WebSocket = closingWebSocket(4001, 'authentication failed') as unknown as typeof WebSocket;
|
|
569
|
+
const seen: string[] = [];
|
|
570
|
+
let saved = loadConfig(TEST_CONFIG);
|
|
571
|
+
const agent = new TunnelAgent(loadConfig(TEST_CONFIG), new CapabilityRegistry(), {
|
|
572
|
+
onStatus: (status, config) => seen.push(`${status}:${config.tunnelId.slice(-1)}`),
|
|
573
|
+
}, {
|
|
574
|
+
persistent: true,
|
|
575
|
+
rejectedRetryMs: 60_000,
|
|
576
|
+
watchdogIntervalMs: 20,
|
|
577
|
+
reloadConfig: () => saved,
|
|
578
|
+
home: mkdtempSync(join(tmpdir(), 'agent-tunnel-state-')),
|
|
579
|
+
});
|
|
580
|
+
await quiet(async () => {
|
|
581
|
+
try {
|
|
582
|
+
agent.connect();
|
|
583
|
+
await until(() => seen.includes('rejected:1'));
|
|
584
|
+
saved = loadConfig({ ...TEST_CONFIG, tunnelId: '00000000-0000-4000-8000-000000000002' });
|
|
585
|
+
await until(() => seen.includes('connecting:2'), 1_000);
|
|
586
|
+
} finally {
|
|
587
|
+
agent.disconnect();
|
|
588
|
+
}
|
|
589
|
+
});
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
test('a lapsed grant is reported as no grant', async () => {
|
|
593
|
+
FakeRelay.sockets = [];
|
|
594
|
+
globalThis.WebSocket = FakeRelay as unknown as typeof WebSocket;
|
|
595
|
+
const home = mkdtempSync(join(tmpdir(), 'agent-tunnel-lapse-'));
|
|
596
|
+
const grant = new Date(Date.now() + 300).toISOString();
|
|
597
|
+
writeAccess({ mode: 'ask', grantedUntil: grant, deniedUntil: null, keepAwake: false }, home);
|
|
598
|
+
const agent = new TunnelAgent(loadConfig(TEST_CONFIG), new CapabilityRegistry(), {}, { home, watchdogIntervalMs: 20 });
|
|
599
|
+
await quiet(async () => {
|
|
600
|
+
try {
|
|
601
|
+
agent.connect();
|
|
602
|
+
await until(() => FakeRelay.sockets[0]?.notifications('tunnel.access.state').length === 1);
|
|
603
|
+
expect(FakeRelay.sockets[0]!.notifications('tunnel.access.state')[0].params).toEqual({ mode: 'ask', grantedUntil: grant });
|
|
604
|
+
await until(() => FakeRelay.sockets[0]!.notifications('tunnel.access.state').length === 2, 2_000);
|
|
605
|
+
expect(FakeRelay.sockets[0]!.notifications('tunnel.access.state')[1].params).toEqual({ mode: 'ask', grantedUntil: null });
|
|
606
|
+
} finally {
|
|
607
|
+
agent.disconnect();
|
|
608
|
+
rmSync(home, { recursive: true, force: true });
|
|
609
|
+
}
|
|
610
|
+
});
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
test('concurrent ask-mode calls share one pending request (one prompt, one app launch)', async () => {
|
|
614
|
+
FakeRelay.sockets = [];
|
|
615
|
+
globalThis.WebSocket = FakeRelay as unknown as typeof WebSocket;
|
|
616
|
+
const home = mkdtempSync(join(tmpdir(), 'agent-tunnel-shared-request-'));
|
|
617
|
+
writeAccess({ mode: 'ask', grantedUntil: null, deniedUntil: null, keepAwake: false }, home);
|
|
618
|
+
const registry = new CapabilityRegistry();
|
|
619
|
+
registry.register({ name: 'filesystem', methods: new Map([['fs.stat', async () => ({ ok: true })]]) } as any);
|
|
620
|
+
const agent = new TunnelAgent(loadConfig(TEST_CONFIG), registry, {}, { home, accessHoldMs: 500, watchdogIntervalMs: 20 });
|
|
621
|
+
await quiet(async () => {
|
|
622
|
+
try {
|
|
623
|
+
agent.connect();
|
|
624
|
+
await until(() => FakeRelay.sockets[0]?.sent.some((m) => m.type === 'auth') === true);
|
|
625
|
+
const relay = FakeRelay.sockets[0]!;
|
|
626
|
+
relay.deliverSigned({ jsonrpc: '2.0', method: 'tunnel.permissions.sync', params: { permissions: [{ permissionId: 'perm-fs', capability: 'filesystem', scope: {} }] } });
|
|
627
|
+
await Bun.sleep(20);
|
|
628
|
+
relay.deliverSigned({ jsonrpc: '2.0', id: 'a', method: 'fs.stat', params: { permissionId: 'perm-fs', path: '/' } });
|
|
629
|
+
await until(() => readAccessRequest(home) !== null);
|
|
630
|
+
const first = readAccessRequest(home)!.id;
|
|
631
|
+
relay.deliverSigned({ jsonrpc: '2.0', id: 'b', method: 'fs.stat', params: { permissionId: 'perm-fs', path: '/' } });
|
|
632
|
+
await Bun.sleep(100);
|
|
633
|
+
expect(readAccessRequest(home)!.id).toBe(first);
|
|
634
|
+
await until(() => relay.responses('b').length === 1, 2_000);
|
|
635
|
+
} finally {
|
|
636
|
+
agent.disconnect();
|
|
637
|
+
rmSync(home, { recursive: true, force: true });
|
|
638
|
+
}
|
|
639
|
+
});
|
|
640
|
+
});
|
|
641
|
+
});
|