@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
package/src/agent/agent.ts
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from 'child_process';
|
|
1
2
|
import { hostname, platform, arch, release } from 'os';
|
|
2
3
|
import { buildTunnelWsUrl, trustedCredential, type TunnelConfig } from './config';
|
|
4
|
+
import {
|
|
5
|
+
ACCESS_HOLD_MS,
|
|
6
|
+
clearAccessRequest,
|
|
7
|
+
decideAccess,
|
|
8
|
+
keepAwakeCommand,
|
|
9
|
+
readAccess,
|
|
10
|
+
readAccessRequest,
|
|
11
|
+
wakeDesktopApp,
|
|
12
|
+
writeAccessRequest,
|
|
13
|
+
} from './access';
|
|
14
|
+
import { agentTunnelHome } from './service-paths';
|
|
15
|
+
import { machineDisplayName } from './device-auth';
|
|
16
|
+
import { capabilityForMethod } from '../shared/permissions';
|
|
17
|
+
import { TunnelErrorCode } from '../shared/types';
|
|
3
18
|
import { agentTunnelVersion } from './version';
|
|
4
19
|
import { c } from './terminal';
|
|
5
20
|
import { CapabilityRegistry } from './capabilities/index';
|
|
@@ -15,6 +30,18 @@ export const AGENT_VERSION = agentTunnelVersion();
|
|
|
15
30
|
*/
|
|
16
31
|
export const AUTH_REJECTED_CLOSE_CODES: readonly number[] = [4001, 4003];
|
|
17
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Relays before 0.1.3 also closed with 4001 when THEY failed (auth timeout,
|
|
35
|
+
* database error during a deploy). Those reasons are retryable: treating them
|
|
36
|
+
* as a bad credential stopped the background service for good.
|
|
37
|
+
*/
|
|
38
|
+
const RELAY_FAULT_4001_REASONS = new Set(['auth timeout', 'authentication error', 'authentication response failed']);
|
|
39
|
+
|
|
40
|
+
export function isCredentialRejection(code: number, reason = ''): boolean {
|
|
41
|
+
if (!AUTH_REJECTED_CLOSE_CODES.includes(code)) return false;
|
|
42
|
+
return !(code === 4001 && RELAY_FAULT_4001_REASONS.has(reason));
|
|
43
|
+
}
|
|
44
|
+
|
|
18
45
|
/**
|
|
19
46
|
* The relay closes an already-registered socket with this code when a second
|
|
20
47
|
* process authenticates with the same credential. Only one agent may hold a
|
|
@@ -48,7 +75,69 @@ function log(icon: string, msg: string) {
|
|
|
48
75
|
process.stdout.write(` ${safeIcon} ${c.dim}${safeMsg}${c.reset}\n`);
|
|
49
76
|
}
|
|
50
77
|
|
|
78
|
+
/**
|
|
79
|
+
* `rejected` and `standby` exist only in service mode: the credential was
|
|
80
|
+
* refused, or another process holds the tunnel. The agent keeps retrying.
|
|
81
|
+
*/
|
|
82
|
+
export type TunnelAgentStatus = 'connecting' | 'online' | 'offline' | 'rejected' | 'standby';
|
|
83
|
+
|
|
84
|
+
export interface TunnelAgentOptions {
|
|
85
|
+
/** Service mode (R2): never stop on a refused credential or on being replaced. */
|
|
86
|
+
persistent?: boolean;
|
|
87
|
+
/** Re-read the credential before a retry, so a re-pair heals a `rejected` agent. */
|
|
88
|
+
reloadConfig?: () => TunnelConfig;
|
|
89
|
+
/** Config directory holding access.json. */
|
|
90
|
+
home?: string;
|
|
91
|
+
livenessTimeoutMs?: number;
|
|
92
|
+
watchdogIntervalMs?: number;
|
|
93
|
+
rejectedRetryMs?: number;
|
|
94
|
+
standbyRetryMs?: number;
|
|
95
|
+
connectDeadlineMs?: number;
|
|
96
|
+
accessHoldMs?: number;
|
|
97
|
+
now?: () => number;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The relay pings every 30 s; 75 s of silence means the socket is dead (R1). */
|
|
101
|
+
const LIVENESS_TIMEOUT_MS = 75_000;
|
|
102
|
+
const WATCHDOG_INTERVAL_MS = 5_000;
|
|
103
|
+
/** A tick that arrives this much later than scheduled means the machine slept. */
|
|
104
|
+
const CLOCK_JUMP_MS = 30_000;
|
|
105
|
+
const REJECTED_RETRY_MS = 5 * 60_000;
|
|
106
|
+
/** Standby backs off from this, doubling, so two holders of one credential stop trading the socket. */
|
|
107
|
+
const STANDBY_RETRY_MS = 60_000;
|
|
108
|
+
const MAX_STANDBY_RETRY_MS = 30 * 60_000;
|
|
109
|
+
/** A handshake that has not reached auth_ok by now is dead (frozen relay, dropped Wi-Fi). */
|
|
110
|
+
const CONNECT_DEADLINE_MS = 20_000;
|
|
111
|
+
/** A keep-awake helper that exits sooner than this could not take the lock. */
|
|
112
|
+
const KEEP_AWAKE_MIN_LIFETIME_MS = 5_000;
|
|
113
|
+
const REJECTED_LOG_EVERY_MS = 3_600_000;
|
|
114
|
+
const BASE_RECONNECT_DELAY_MS = 1_000;
|
|
115
|
+
const MAX_RECONNECT_DELAY_MS = 30_000;
|
|
116
|
+
|
|
117
|
+
/** R4: exponential from 1 s, ±20 % jitter, never above 30 s. */
|
|
118
|
+
export function reconnectDelay(attempt: number, random = Math.random()): number {
|
|
119
|
+
const raw = Math.min(BASE_RECONNECT_DELAY_MS * 2 ** Math.min(attempt - 1, 30), MAX_RECONNECT_DELAY_MS);
|
|
120
|
+
return Math.round(Math.min(raw * (0.8 + 0.4 * random), MAX_RECONNECT_DELAY_MS));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const ACCESS_ERRORS = {
|
|
124
|
+
pending: {
|
|
125
|
+
code: TunnelErrorCode.ACCESS_PENDING,
|
|
126
|
+
message: 'computer_access_pending: The owner has not allowed access yet. Ask them to approve the prompt on their computer, then try again.',
|
|
127
|
+
},
|
|
128
|
+
denied: {
|
|
129
|
+
code: TunnelErrorCode.ACCESS_DENIED,
|
|
130
|
+
message: 'computer_access_denied: The owner denied access on their computer. Try again later.',
|
|
131
|
+
},
|
|
132
|
+
off: {
|
|
133
|
+
code: TunnelErrorCode.ACCESS_OFF,
|
|
134
|
+
message: 'computer_access_off: The owner turned off access to this computer.',
|
|
135
|
+
},
|
|
136
|
+
} as const;
|
|
137
|
+
|
|
51
138
|
export interface TunnelAgentHooks {
|
|
139
|
+
/** Fires on every change of connection status, with the credential in use now. */
|
|
140
|
+
onStatus?: (status: TunnelAgentStatus, config: TunnelConfig) => void;
|
|
52
141
|
/**
|
|
53
142
|
* Fires when the relay closes the connection for a reason reconnecting cannot
|
|
54
143
|
* fix. The agent has already stopped retrying by this point.
|
|
@@ -60,17 +149,31 @@ export type TerminalCloseReason = 'credential-rejected' | 'replaced';
|
|
|
60
149
|
|
|
61
150
|
export class TunnelAgent {
|
|
62
151
|
private hooks: TunnelAgentHooks;
|
|
152
|
+
private options: TunnelAgentOptions;
|
|
153
|
+
private home: string;
|
|
154
|
+
private now: () => number;
|
|
63
155
|
private ws: WebSocket | null = null;
|
|
64
156
|
private registry: CapabilityRegistry;
|
|
65
157
|
private permissionGuard: PermissionGuard;
|
|
66
158
|
private config: TunnelConfig;
|
|
67
159
|
private reconnectAttempts = 0;
|
|
68
|
-
private
|
|
69
|
-
private
|
|
160
|
+
private closeCurrentSocket: ((event: { code: number; reason?: string }) => void) | null = null;
|
|
161
|
+
private watchdog: ReturnType<typeof setInterval> | null = null;
|
|
162
|
+
private lastTick = 0;
|
|
163
|
+
private lastPingAt = 0;
|
|
164
|
+
private connectStartedAt = 0;
|
|
165
|
+
private standbyAttempts = 0;
|
|
166
|
+
private lastRejectedLogAt = 0;
|
|
167
|
+
private sentAccessKey: string | null = null;
|
|
168
|
+
private keepAwake: ChildProcess | null = null;
|
|
169
|
+
private keepAwakeFailed = false;
|
|
70
170
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
71
171
|
private stableConnectionTimer: ReturnType<typeof setTimeout> | null = null;
|
|
72
172
|
private isShuttingDown = false;
|
|
173
|
+
private status: TunnelAgentStatus | null = null;
|
|
73
174
|
private uptime = 0;
|
|
175
|
+
/** Read once: on macOS it runs `scutil`, and the pong repeats every 30 s. */
|
|
176
|
+
private displayName?: string;
|
|
74
177
|
private uptimeInterval: ReturnType<typeof setInterval> | null = null;
|
|
75
178
|
|
|
76
179
|
// HMAC signature verification
|
|
@@ -78,20 +181,31 @@ export class TunnelAgent {
|
|
|
78
181
|
private lastNonce = 0;
|
|
79
182
|
private responseNonce = 0;
|
|
80
183
|
|
|
81
|
-
constructor(
|
|
184
|
+
constructor(
|
|
185
|
+
config: TunnelConfig,
|
|
186
|
+
registry: CapabilityRegistry,
|
|
187
|
+
hooks: TunnelAgentHooks = {},
|
|
188
|
+
options: TunnelAgentOptions = {},
|
|
189
|
+
) {
|
|
82
190
|
this.config = config;
|
|
83
191
|
this.registry = registry;
|
|
84
192
|
this.permissionGuard = new PermissionGuard();
|
|
85
193
|
this.hooks = hooks;
|
|
194
|
+
this.options = options;
|
|
195
|
+
this.home = options.home ?? agentTunnelHome();
|
|
196
|
+
this.now = options.now ?? Date.now;
|
|
86
197
|
}
|
|
87
198
|
|
|
88
199
|
connect(): void {
|
|
89
200
|
if (this.ws) {
|
|
90
201
|
this.ws.close();
|
|
91
202
|
}
|
|
203
|
+
this.startWatchdog();
|
|
92
204
|
|
|
93
205
|
const wsUrl = this.buildWsUrl();
|
|
94
206
|
log(`${c.cyan}◆${c.reset}`, `Connecting…`);
|
|
207
|
+
this.connectStartedAt = this.now();
|
|
208
|
+
this.setStatus('connecting');
|
|
95
209
|
|
|
96
210
|
try {
|
|
97
211
|
// lgtm[js/file-access-to-http] Tunnel endpoint is intentionally loaded from trusted local config.
|
|
@@ -115,6 +229,11 @@ export class TunnelAgent {
|
|
|
115
229
|
clearInterval(this.uptimeInterval);
|
|
116
230
|
this.uptimeInterval = null;
|
|
117
231
|
}
|
|
232
|
+
if (this.watchdog) {
|
|
233
|
+
clearInterval(this.watchdog);
|
|
234
|
+
this.watchdog = null;
|
|
235
|
+
}
|
|
236
|
+
this.syncKeepAwake(false);
|
|
118
237
|
|
|
119
238
|
if (this.ws) {
|
|
120
239
|
try { this.ws.close(1000, 'client shutdown'); } catch {}
|
|
@@ -123,6 +242,13 @@ export class TunnelAgent {
|
|
|
123
242
|
|
|
124
243
|
this.permissionGuard.clear();
|
|
125
244
|
log(`${c.gray}○${c.reset}`, `Disconnected`);
|
|
245
|
+
this.setStatus('offline');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private setStatus(status: TunnelAgentStatus): void {
|
|
249
|
+
if (this.status === status) return;
|
|
250
|
+
this.status = status;
|
|
251
|
+
try { this.hooks.onStatus?.(status, this.config); } catch {}
|
|
126
252
|
}
|
|
127
253
|
|
|
128
254
|
isConnected(): boolean {
|
|
@@ -145,6 +271,7 @@ export class TunnelAgent {
|
|
|
145
271
|
token: trustedCredential(this.config.token, 'token'),
|
|
146
272
|
capabilities: this.registry.getCapabilityNames(),
|
|
147
273
|
agentVersion: AGENT_VERSION,
|
|
274
|
+
reportsAccess: true,
|
|
148
275
|
});
|
|
149
276
|
});
|
|
150
277
|
|
|
@@ -152,7 +279,12 @@ export class TunnelAgent {
|
|
|
152
279
|
this.handleMessage(event.data as string);
|
|
153
280
|
});
|
|
154
281
|
|
|
155
|
-
|
|
282
|
+
// Handled once per socket: the 'close' event, or the stand-in below.
|
|
283
|
+
let closeHandled = false;
|
|
284
|
+
const onClose = (event: { code: number; reason?: string }) => {
|
|
285
|
+
if (closeHandled) return;
|
|
286
|
+
closeHandled = true;
|
|
287
|
+
this.setStatus('offline');
|
|
156
288
|
if (this.uptimeInterval) {
|
|
157
289
|
clearInterval(this.uptimeInterval);
|
|
158
290
|
this.uptimeInterval = null;
|
|
@@ -163,7 +295,23 @@ export class TunnelAgent {
|
|
|
163
295
|
}
|
|
164
296
|
|
|
165
297
|
if (!this.isShuttingDown) {
|
|
166
|
-
if (event.code
|
|
298
|
+
if (this.options.persistent && isCredentialRejection(event.code, event.reason)) {
|
|
299
|
+
this.setStatus('rejected');
|
|
300
|
+
if (this.now() - this.lastRejectedLogAt >= REJECTED_LOG_EVERY_MS) {
|
|
301
|
+
this.lastRejectedLogAt = this.now();
|
|
302
|
+
log(`${c.red}✗${c.reset}`, `Credential rejected — waiting for this computer to be paired again (retrying every 5 min)`);
|
|
303
|
+
}
|
|
304
|
+
this.retryAfter(this.options.rejectedRetryMs ?? REJECTED_RETRY_MS);
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
if (this.options.persistent && event.code === AGENT_REPLACED_CLOSE_CODE) {
|
|
308
|
+
this.setStatus('standby');
|
|
309
|
+
const wait = Math.min((this.options.standbyRetryMs ?? STANDBY_RETRY_MS) * 2 ** this.standbyAttempts++, MAX_STANDBY_RETRY_MS);
|
|
310
|
+
log(`${c.yellow}○${c.reset}`, `Another Agent Tunnel process holds this computer — standing by, next try in ${Math.round(wait / 1000)}s`);
|
|
311
|
+
this.retryAfter(wait);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
if (event.code === 4001 && isCredentialRejection(event.code, event.reason)) {
|
|
167
315
|
this.isShuttingDown = true;
|
|
168
316
|
log(`${c.red}✗${c.reset}`, `Credential rejected — run \`agent-tunnel connect --reauth\` to pair again`);
|
|
169
317
|
this.hooks.onTerminalClose?.({ code: event.code, reason: 'credential-rejected' });
|
|
@@ -187,10 +335,18 @@ export class TunnelAgent {
|
|
|
187
335
|
log(`${c.yellow}○${c.reset}`, `Disconnected ${c.gray}(code: ${event.code})${c.reset}`);
|
|
188
336
|
this.scheduleReconnect();
|
|
189
337
|
}
|
|
190
|
-
}
|
|
338
|
+
};
|
|
339
|
+
this.ws.addEventListener('close', onClose);
|
|
340
|
+
this.closeCurrentSocket = onClose;
|
|
191
341
|
|
|
192
342
|
this.ws.addEventListener('error', () => {
|
|
193
343
|
log(`${c.red}✗${c.reset}`, `WebSocket error`);
|
|
344
|
+
// Node's built-in WebSocket fires no 'close' after a failed handshake
|
|
345
|
+
// (relay unreachable). With nothing left pending the process then exits
|
|
346
|
+
// 0, which no supervisor restarts: a machine that booted offline stayed
|
|
347
|
+
// offline. An error only ever precedes an abnormal closure, so stand in
|
|
348
|
+
// for it with 1006 when the real event does not follow.
|
|
349
|
+
setTimeout(() => onClose({ code: 1006 }), 1_000);
|
|
194
350
|
});
|
|
195
351
|
}
|
|
196
352
|
|
|
@@ -221,6 +377,11 @@ export class TunnelAgent {
|
|
|
221
377
|
} else {
|
|
222
378
|
log(`${c.green}●${c.reset}`, `Connected ${c.reset}${c.gray}(${capabilityNames.join(', ')})${c.reset}`);
|
|
223
379
|
}
|
|
380
|
+
this.lastPingAt = this.now();
|
|
381
|
+
this.sentAccessKey = null;
|
|
382
|
+
this.standbyAttempts = 0;
|
|
383
|
+
this.setStatus('online');
|
|
384
|
+
this.reportAccess();
|
|
224
385
|
if (this.stableConnectionTimer) clearTimeout(this.stableConnectionTimer);
|
|
225
386
|
this.stableConnectionTimer = setTimeout(() => {
|
|
226
387
|
this.reconnectAttempts = 0;
|
|
@@ -243,6 +404,7 @@ export class TunnelAgent {
|
|
|
243
404
|
|
|
244
405
|
// ── Heartbeat ping (signature verified above) ────────────────────
|
|
245
406
|
if ('method' in msg && msg.method === 'tunnel.ping') {
|
|
407
|
+
this.lastPingAt = this.now();
|
|
246
408
|
this.sendPong();
|
|
247
409
|
return;
|
|
248
410
|
}
|
|
@@ -333,6 +495,12 @@ export class TunnelAgent {
|
|
|
333
495
|
return;
|
|
334
496
|
}
|
|
335
497
|
|
|
498
|
+
const refusal = await this.awaitAccess(method);
|
|
499
|
+
if (refusal) {
|
|
500
|
+
this.sendSignedError(id, refusal.code, refusal.message);
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
|
|
336
504
|
try {
|
|
337
505
|
const result = await handler({
|
|
338
506
|
...params,
|
|
@@ -407,27 +575,182 @@ export class TunnelAgent {
|
|
|
407
575
|
capabilities: this.registry.getCapabilityNames(),
|
|
408
576
|
machineInfo: {
|
|
409
577
|
hostname: hostname(),
|
|
578
|
+
displayName: (this.displayName ??= machineDisplayName()),
|
|
410
579
|
platform: platform(),
|
|
411
580
|
arch: arch(),
|
|
412
581
|
osVersion: release(),
|
|
413
582
|
agentVersion: AGENT_VERSION,
|
|
583
|
+
// Where file and shell work may happen, so a cloud agent starts in
|
|
584
|
+
// the right place instead of probing paths the local ceiling denies.
|
|
585
|
+
homeDir: this.config.workingDir,
|
|
586
|
+
allowedPaths: this.config.allowedPaths,
|
|
414
587
|
},
|
|
415
588
|
},
|
|
416
589
|
});
|
|
417
590
|
}
|
|
418
591
|
|
|
592
|
+
/**
|
|
593
|
+
* A2/A3: the owner's standing answer in access.json decides. `ask` without a
|
|
594
|
+
* grant writes access-request.json, wakes the desktop app, and holds the call
|
|
595
|
+
* until the owner answers or the hold runs out.
|
|
596
|
+
*/
|
|
597
|
+
private async awaitAccess(method: string): Promise<(typeof ACCESS_ERRORS)[keyof typeof ACCESS_ERRORS] | null> {
|
|
598
|
+
const first = decideAccess(readAccess(this.home));
|
|
599
|
+
if (first === 'run') return null;
|
|
600
|
+
if (first !== 'ask') return ACCESS_ERRORS[first];
|
|
601
|
+
|
|
602
|
+
const hold = this.options.accessHoldMs ?? ACCESS_HOLD_MS;
|
|
603
|
+
let requestId: string | null = null;
|
|
604
|
+
try {
|
|
605
|
+
// Concurrent calls share one pending request: one prompt, one app launch.
|
|
606
|
+
const pending = readAccessRequest(this.home);
|
|
607
|
+
if (pending && Date.now() - Date.parse(pending.requestedAt) < hold) {
|
|
608
|
+
requestId = pending.id;
|
|
609
|
+
} else {
|
|
610
|
+
requestId = writeAccessRequest({ capability: capabilityForMethod(method) ?? method, method }, this.home).id;
|
|
611
|
+
wakeDesktopApp(this.home);
|
|
612
|
+
}
|
|
613
|
+
} catch {
|
|
614
|
+
// Without a request file nobody can answer; the hold simply runs out.
|
|
615
|
+
}
|
|
616
|
+
const deadline = Date.now() + hold;
|
|
617
|
+
while (Date.now() < deadline) {
|
|
618
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(100, hold / 4)));
|
|
619
|
+
const decision = decideAccess(readAccess(this.home));
|
|
620
|
+
if (decision === 'ask') continue;
|
|
621
|
+
if (requestId) clearAccessRequest(requestId, this.home);
|
|
622
|
+
return decision === 'run' ? null : ACCESS_ERRORS[decision];
|
|
623
|
+
}
|
|
624
|
+
// The request stays: a late answer still applies to the next call.
|
|
625
|
+
return ACCESS_ERRORS.pending;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/** X2: optional signed notification; relays that do not know it ignore it. */
|
|
629
|
+
private reportAccess(): void {
|
|
630
|
+
const access = readAccess(this.home);
|
|
631
|
+
const { mode } = access;
|
|
632
|
+
// A lapsed grant is no grant: the watchdog reports again when it expires.
|
|
633
|
+
const grantedUntil = access.grantedUntil && Date.parse(access.grantedUntil) > this.now() ? access.grantedUntil : null;
|
|
634
|
+
const key = `${mode}|${grantedUntil}`;
|
|
635
|
+
if (key === this.sentAccessKey || this.status !== 'online') return;
|
|
636
|
+
this.sentAccessKey = key;
|
|
637
|
+
this.sendSigned({ jsonrpc: '2.0', method: 'tunnel.access.state', params: { mode, grantedUntil } });
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/** R6: hold a sleep blocker while access.json asks for it. */
|
|
641
|
+
private syncKeepAwake(want: boolean): void {
|
|
642
|
+
if (!want) {
|
|
643
|
+
this.keepAwake?.kill();
|
|
644
|
+
this.keepAwake = null;
|
|
645
|
+
// Toggling keep awake off and on again is the owner's retry.
|
|
646
|
+
this.keepAwakeFailed = false;
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
if (this.keepAwake || this.keepAwakeFailed) return;
|
|
650
|
+
const blocker = keepAwakeCommand(platform(), process.pid);
|
|
651
|
+
if (!blocker) return;
|
|
652
|
+
const child = spawn(blocker.command, blocker.args, { stdio: 'ignore' });
|
|
653
|
+
child.on('error', () => {
|
|
654
|
+
this.keepAwakeFailed = true;
|
|
655
|
+
log(`${c.yellow}!${c.reset}`, `Keep awake is unavailable: ${blocker.command} could not start`);
|
|
656
|
+
});
|
|
657
|
+
const startedAt = this.now();
|
|
658
|
+
child.on('exit', (code) => {
|
|
659
|
+
if (this.keepAwake !== child) return; // we killed it
|
|
660
|
+
this.keepAwake = null;
|
|
661
|
+
// e.g. polkit refused systemd-inhibit: respawning every 5 s would never help.
|
|
662
|
+
if (code !== 0 || this.now() - startedAt < KEEP_AWAKE_MIN_LIFETIME_MS) {
|
|
663
|
+
this.keepAwakeFailed = true;
|
|
664
|
+
log(`${c.yellow}!${c.reset}`, `Keep awake is unavailable: ${blocker.command} exited (code ${code})`);
|
|
665
|
+
}
|
|
666
|
+
});
|
|
667
|
+
this.keepAwake = child;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/** R1: liveness, wake-from-sleep, and access.json changes, every 5 s. */
|
|
671
|
+
private startWatchdog(): void {
|
|
672
|
+
if (this.watchdog) return;
|
|
673
|
+
const interval = this.options.watchdogIntervalMs ?? WATCHDOG_INTERVAL_MS;
|
|
674
|
+
this.lastTick = this.now();
|
|
675
|
+
this.watchdog = setInterval(() => {
|
|
676
|
+
const now = this.now();
|
|
677
|
+
const slept = now - this.lastTick > interval + CLOCK_JUMP_MS;
|
|
678
|
+
this.lastTick = now;
|
|
679
|
+
if (slept) {
|
|
680
|
+
// A wake is a fresh start: backoff built up before the sleep must not delay it.
|
|
681
|
+
this.reconnectAttempts = 0;
|
|
682
|
+
this.forceReconnect('clock jump');
|
|
683
|
+
}
|
|
684
|
+
else if (this.status === 'online' && now - this.lastPingAt > (this.options.livenessTimeoutMs ?? LIVENESS_TIMEOUT_MS)) {
|
|
685
|
+
this.forceReconnect('liveness timeout');
|
|
686
|
+
} else if (this.status === 'connecting' && this.ws && now - this.connectStartedAt > (this.options.connectDeadlineMs ?? CONNECT_DEADLINE_MS)) {
|
|
687
|
+
this.forceReconnect('handshake timeout');
|
|
688
|
+
} else if (this.status === 'rejected' && this.credentialChanged()) {
|
|
689
|
+
this.forceReconnect('new credential');
|
|
690
|
+
}
|
|
691
|
+
try {
|
|
692
|
+
const access = readAccess(this.home);
|
|
693
|
+
this.syncKeepAwake(access.keepAwake);
|
|
694
|
+
this.reportAccess();
|
|
695
|
+
} catch {}
|
|
696
|
+
}, interval);
|
|
697
|
+
this.watchdog.unref?.();
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/** R2: a re-pair wrote a new credential; a `rejected` agent uses it at once. */
|
|
701
|
+
private credentialChanged(): boolean {
|
|
702
|
+
try {
|
|
703
|
+
const next = this.options.reloadConfig?.();
|
|
704
|
+
if (!next || (next.token === this.config.token && next.tunnelId === this.config.tunnelId)) return false;
|
|
705
|
+
this.config = next;
|
|
706
|
+
return true;
|
|
707
|
+
} catch {
|
|
708
|
+
return false;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/** Drops a socket that may be half-open and reconnects without waiting for its close. */
|
|
713
|
+
private forceReconnect(reason: string): void {
|
|
714
|
+
if (this.isShuttingDown) return;
|
|
715
|
+
if (!this.ws) {
|
|
716
|
+
// Waiting out a backoff: after a sleep, try at once.
|
|
717
|
+
if (this.reconnectTimer) {
|
|
718
|
+
clearTimeout(this.reconnectTimer);
|
|
719
|
+
this.reconnectTimer = null;
|
|
720
|
+
this.connect();
|
|
721
|
+
}
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
log(`${c.yellow}○${c.reset}`, `Reconnecting (${reason})`);
|
|
725
|
+
try { this.ws.close(4000, reason); } catch {}
|
|
726
|
+
this.ws = null;
|
|
727
|
+
this.closeCurrentSocket?.({ code: 4000, reason });
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
private retryAfter(ms: number): void {
|
|
731
|
+
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
|
732
|
+
this.ws = null;
|
|
733
|
+
this.reconnectTimer = setTimeout(() => {
|
|
734
|
+
this.reconnectTimer = null;
|
|
735
|
+
try {
|
|
736
|
+
if (this.options.reloadConfig) this.config = this.options.reloadConfig();
|
|
737
|
+
} catch {}
|
|
738
|
+
this.connect();
|
|
739
|
+
}, ms);
|
|
740
|
+
}
|
|
741
|
+
|
|
419
742
|
private scheduleReconnect(): void {
|
|
420
743
|
if (this.isShuttingDown) return;
|
|
421
744
|
|
|
422
745
|
this.reconnectAttempts++;
|
|
423
|
-
const delay =
|
|
424
|
-
|
|
425
|
-
this.maxReconnectDelay,
|
|
426
|
-
);
|
|
746
|
+
const delay = reconnectDelay(this.reconnectAttempts);
|
|
747
|
+
this.ws = null;
|
|
427
748
|
|
|
428
749
|
log(`${c.cyan}◆${c.reset}`, `Reconnecting in ${c.reset}${c.white}${(delay / 1000).toFixed(1)}s${c.dim} (attempt ${this.reconnectAttempts})`);
|
|
429
750
|
|
|
751
|
+
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
|
430
752
|
this.reconnectTimer = setTimeout(() => {
|
|
753
|
+
this.reconnectTimer = null;
|
|
431
754
|
this.connect();
|
|
432
755
|
}, delay);
|
|
433
756
|
}
|
|
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
|
2
2
|
import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
|
-
import type
|
|
5
|
+
import { protectedAgentPaths, type TunnelConfig } from '../config';
|
|
6
6
|
import { createDesktopCapability } from './desktop';
|
|
7
7
|
import { createEnabledCapabilityRegistry } from './enabled-registry';
|
|
8
8
|
import { CuaDriver } from './desktop/cua-driver';
|
|
@@ -88,6 +88,25 @@ describe('local capability permission enforcement', () => {
|
|
|
88
88
|
).rejects.toThrow('outside allowed directories');
|
|
89
89
|
});
|
|
90
90
|
|
|
91
|
+
test('a file grant cannot rewrite the agent home (access.json, desktop-app.json)', async () => {
|
|
92
|
+
const home = join(root, 'agent-tunnel', 'abcd1234');
|
|
93
|
+
const write = createFilesystemCapability({
|
|
94
|
+
...config(),
|
|
95
|
+
blockedPaths: protectedAgentPaths(home),
|
|
96
|
+
}).methods.get('fs.write')!;
|
|
97
|
+
const permission = {
|
|
98
|
+
permissionId: 'permission-1',
|
|
99
|
+
capability: 'filesystem',
|
|
100
|
+
scope: { operations: ['write'] },
|
|
101
|
+
};
|
|
102
|
+
for (const target of [join(home, 'access.json'), join(home, 'desktop-app.json'), join(root, 'agent-tunnel', 'other', 'access.json')]) {
|
|
103
|
+
await expect(
|
|
104
|
+
write({ path: target, content: '{"mode":"always"}', __permission: permission }),
|
|
105
|
+
).rejects.toThrow('blocked path');
|
|
106
|
+
}
|
|
107
|
+
await write({ path: join(root, 'notes.txt'), content: 'ok', __permission: permission });
|
|
108
|
+
});
|
|
109
|
+
|
|
91
110
|
test('filesystem operations are checked again on the machine', async () => {
|
|
92
111
|
const handler = createFilesystemCapability(config()).methods.get('fs.read')!;
|
|
93
112
|
await expect(
|
|
@@ -176,4 +176,118 @@ describe('agent tunnel device authorization CLI', () => {
|
|
|
176
176
|
server.stop(true);
|
|
177
177
|
}
|
|
178
178
|
});
|
|
179
|
+
|
|
180
|
+
test('--json emits NDJSON events, sends --project-id, and honours AGENT_TUNNEL_HOME', async () => {
|
|
181
|
+
const projectId = '11111111-2222-4333-8444-555555555555';
|
|
182
|
+
let requestBody: Record<string, unknown> | null = null;
|
|
183
|
+
const server = Bun.serve({
|
|
184
|
+
port: 0,
|
|
185
|
+
async fetch(request) {
|
|
186
|
+
const url = new URL(request.url);
|
|
187
|
+
if (request.method === 'POST' && url.pathname === '/v1/tunnel/device-auth') {
|
|
188
|
+
requestBody = (await request.json()) as Record<string, unknown>;
|
|
189
|
+
return Response.json(
|
|
190
|
+
{
|
|
191
|
+
deviceCode: 'TEST-0003',
|
|
192
|
+
deviceSecret: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ123456',
|
|
193
|
+
verificationUrl: 'https://dev.kortix.com/tunnel/authorize/TEST-0003',
|
|
194
|
+
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
195
|
+
pollIntervalMs: 250,
|
|
196
|
+
},
|
|
197
|
+
{ status: 201 },
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
if (request.method === 'GET' && url.pathname.endsWith('/TEST-0003/status')) {
|
|
201
|
+
return Response.json({
|
|
202
|
+
status: 'approved',
|
|
203
|
+
tunnelId: '00000000-0000-4000-8000-000000000003',
|
|
204
|
+
token: 'kortix_tnl_ABCDEFGHIJKLMNOPQRSTUVWXYZ123456',
|
|
205
|
+
capabilities: ['filesystem'],
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
return new Response('not found', { status: 404 });
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const home = await mkdtemp(join(tmpdir(), 'agent-tunnel-json-home-'));
|
|
213
|
+
const agentHome = join(home, 'isolated-agent-home');
|
|
214
|
+
temporaryHomes.add(home);
|
|
215
|
+
const child = spawn(
|
|
216
|
+
process.execPath,
|
|
217
|
+
[
|
|
218
|
+
'run', CLI_PATH, 'connect', '--json', '--foreground',
|
|
219
|
+
'--project-id', projectId,
|
|
220
|
+
'--api-url', `http://127.0.0.1:${server.port}/v1/tunnel`,
|
|
221
|
+
],
|
|
222
|
+
{
|
|
223
|
+
// No KORTIX_AGENT_TUNNEL_NO_BROWSER: --json alone must never open one.
|
|
224
|
+
env: { ...process.env, HOME: home, AGENT_TUNNEL_HOME: agentHome },
|
|
225
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
226
|
+
},
|
|
227
|
+
);
|
|
228
|
+
children.add(child);
|
|
229
|
+
let stdout = '';
|
|
230
|
+
child.stdout?.on('data', (chunk) => { stdout += String(chunk); });
|
|
231
|
+
|
|
232
|
+
try {
|
|
233
|
+
const statePath = join(agentHome, 'state.json');
|
|
234
|
+
await waitForFile(statePath);
|
|
235
|
+
const events = stdout.trim().split('\n').map((line) => JSON.parse(line));
|
|
236
|
+
expect(events).toEqual([
|
|
237
|
+
{
|
|
238
|
+
event: 'challenge',
|
|
239
|
+
deviceCode: 'TEST-0003',
|
|
240
|
+
verificationUrl: 'https://dev.kortix.com/tunnel/authorize/TEST-0003',
|
|
241
|
+
expiresAt: expect.any(String),
|
|
242
|
+
},
|
|
243
|
+
{ event: 'approved', tunnelId: '00000000-0000-4000-8000-000000000003', capabilities: ['filesystem'] },
|
|
244
|
+
]);
|
|
245
|
+
expect(requestBody).toMatchObject({ project_id: projectId });
|
|
246
|
+
|
|
247
|
+
// Credentials and state live in AGENT_TUNNEL_HOME, not ~/.agent-tunnel.
|
|
248
|
+
const config = JSON.parse(await readFile(join(agentHome, 'config.json'), 'utf8'));
|
|
249
|
+
expect(config.tunnelId).toBe('00000000-0000-4000-8000-000000000003');
|
|
250
|
+
await expect(access(join(home, '.agent-tunnel', 'config.json'))).rejects.toThrow();
|
|
251
|
+
const state = JSON.parse(await readFile(statePath, 'utf8'));
|
|
252
|
+
expect(state).toMatchObject({ tunnelId: '00000000-0000-4000-8000-000000000003', pid: child.pid });
|
|
253
|
+
expect(['connecting', 'offline']).toContain(state.status);
|
|
254
|
+
|
|
255
|
+
const exited = new Promise((resolve) => child.once('exit', resolve));
|
|
256
|
+
child.kill('SIGTERM');
|
|
257
|
+
await exited;
|
|
258
|
+
expect(JSON.parse(await readFile(statePath, 'utf8')).status).toBe('offline');
|
|
259
|
+
} finally {
|
|
260
|
+
child.kill('SIGTERM');
|
|
261
|
+
children.delete(child);
|
|
262
|
+
server.stop(true);
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test('--json reports a malformed --project-id as an error event', async () => {
|
|
267
|
+
const home = await mkdtemp(join(tmpdir(), 'agent-tunnel-json-home-'));
|
|
268
|
+
temporaryHomes.add(home);
|
|
269
|
+
const result = Bun.spawnSync(
|
|
270
|
+
[process.execPath, 'run', CLI_PATH, 'connect', '--json', '--project-id', 'nope', '--api-url', 'http://127.0.0.1:9/v1/tunnel'],
|
|
271
|
+
{ env: { ...process.env, HOME: home, AGENT_TUNNEL_HOME: join(home, 'agent') } },
|
|
272
|
+
);
|
|
273
|
+
expect(result.exitCode).toBe(1);
|
|
274
|
+
expect(JSON.parse(result.stdout.toString().trim())).toEqual({
|
|
275
|
+
event: 'error',
|
|
276
|
+
message: '--project-id must be a project UUID',
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test('service-status --json names the isolated home and its suffixed label', async () => {
|
|
281
|
+
const home = await mkdtemp(join(tmpdir(), 'agent-tunnel-json-home-'));
|
|
282
|
+
temporaryHomes.add(home);
|
|
283
|
+
const agentHome = join(home, 'agent');
|
|
284
|
+
const result = Bun.spawnSync([process.execPath, 'run', CLI_PATH, 'service-status', '--json'], {
|
|
285
|
+
env: { ...process.env, HOME: home, AGENT_TUNNEL_HOME: agentHome },
|
|
286
|
+
});
|
|
287
|
+
expect(result.exitCode).toBe(0);
|
|
288
|
+
const status = JSON.parse(result.stdout.toString());
|
|
289
|
+
expect(status).toMatchObject({ paired: false, home: agentHome, state: null });
|
|
290
|
+
expect(status.serviceLabel).toMatch(/^ai\.kortix\.agent-tunnel\.[0-9a-f]{8}$/);
|
|
291
|
+
expect(status.service.installed).toBe(false);
|
|
292
|
+
});
|
|
179
293
|
});
|