@kortix/agent-tunnel 0.13.41 → 0.13.43

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.
@@ -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 maxReconnectDelay = 30_000;
69
- private baseReconnectDelay = 1_000;
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(config: TunnelConfig, registry: CapabilityRegistry, hooks: TunnelAgentHooks = {}) {
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
- this.ws.addEventListener('close', (event) => {
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 === 4001) {
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 = Math.min(
424
- this.baseReconnectDelay * Math.pow(2, this.reconnectAttempts - 1),
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 { TunnelConfig } from '../config';
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
  });