@kortix/agent-tunnel 0.12.7 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kortix/agent-tunnel",
3
- "version": "0.12.7",
3
+ "version": "0.13.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Tunnel relay between cloud AI agents and local machines — server relay, local agent, client SDK, JSON-RPC, HMAC signing",
@@ -1,11 +1,26 @@
1
1
  import { hostname, platform, arch, release } from 'os';
2
- import { trustedCredential, trustedHttpUrl, type TunnelConfig } from './config';
2
+ import { buildTunnelWsUrl, trustedCredential, type TunnelConfig } from './config';
3
+ import { agentTunnelVersion } from './version';
4
+ import { c } from './terminal';
3
5
  import { CapabilityRegistry } from './capabilities/index';
4
6
  import { PermissionGuard } from './security/permission-guard';
5
7
  import type { LocalPermission } from './security/permission-guard';
6
8
  import { signMessage, verifyMessageSignature } from '../shared/crypto';
7
9
 
8
- const AGENT_VERSION = '0.1.2';
10
+ export const AGENT_VERSION = agentTunnelVersion();
11
+
12
+ /**
13
+ * Relay close codes that mean the credential itself is bad. They are terminal:
14
+ * reconnecting with the same token can never succeed.
15
+ */
16
+ export const AUTH_REJECTED_CLOSE_CODES: readonly number[] = [4001, 4003];
17
+
18
+ /**
19
+ * The relay closes an already-registered socket with this code when a second
20
+ * process authenticates with the same credential. Only one agent may hold a
21
+ * tunnel, so this is terminal for the displaced process.
22
+ */
23
+ export const AGENT_REPLACED_CLOSE_CODE = 4004;
9
24
 
10
25
  interface JsonRpcRequest {
11
26
  jsonrpc: '2.0';
@@ -27,25 +42,24 @@ interface JsonRpcNotification {
27
42
  type IncomingMessage = JsonRpcRequest | JsonRpcNotification;
28
43
  const MAX_RPC_MESSAGE_SIZE = 5 * 1024 * 1024;
29
44
 
30
- const c = {
31
- reset: '\x1b[0m',
32
- bold: '\x1b[1m',
33
- dim: '\x1b[2m',
34
- cyan: '\x1b[36m',
35
- green: '\x1b[32m',
36
- yellow: '\x1b[33m',
37
- red: '\x1b[31m',
38
- white: '\x1b[97m',
39
- gray: '\x1b[90m',
40
- };
41
-
42
45
  function log(icon: string, msg: string) {
43
46
  const safeIcon = icon.replace(/[\r\n]/g, ' ');
44
47
  const safeMsg = msg.replace(/[\r\n]/g, ' ');
45
48
  process.stdout.write(` ${safeIcon} ${c.dim}${safeMsg}${c.reset}\n`);
46
49
  }
47
50
 
51
+ export interface TunnelAgentHooks {
52
+ /**
53
+ * Fires when the relay closes the connection for a reason reconnecting cannot
54
+ * fix. The agent has already stopped retrying by this point.
55
+ */
56
+ onTerminalClose?: (info: { code: number; reason: TerminalCloseReason }) => void;
57
+ }
58
+
59
+ export type TerminalCloseReason = 'credential-rejected' | 'replaced';
60
+
48
61
  export class TunnelAgent {
62
+ private hooks: TunnelAgentHooks;
49
63
  private ws: WebSocket | null = null;
50
64
  private registry: CapabilityRegistry;
51
65
  private permissionGuard: PermissionGuard;
@@ -64,10 +78,11 @@ export class TunnelAgent {
64
78
  private lastNonce = 0;
65
79
  private responseNonce = 0;
66
80
 
67
- constructor(config: TunnelConfig, registry: CapabilityRegistry) {
81
+ constructor(config: TunnelConfig, registry: CapabilityRegistry, hooks: TunnelAgentHooks = {}) {
68
82
  this.config = config;
69
83
  this.registry = registry;
70
84
  this.permissionGuard = new PermissionGuard();
85
+ this.hooks = hooks;
71
86
  }
72
87
 
73
88
  connect(): void {
@@ -149,19 +164,24 @@ export class TunnelAgent {
149
164
 
150
165
  if (!this.isShuttingDown) {
151
166
  if (event.code === 4001) {
152
- log(`${c.red}✗${c.reset}`, `Authentication failed — check your token`);
167
+ this.isShuttingDown = true;
168
+ log(`${c.red}✗${c.reset}`, `Credential rejected — run \`agent-tunnel connect --reauth\` to pair again`);
169
+ this.hooks.onTerminalClose?.({ code: event.code, reason: 'credential-rejected' });
153
170
  return; // Don't reconnect on auth failure
154
171
  }
155
172
  if (event.code === 4003) {
156
- log(`${c.red}✗${c.reset}`, `Device credential was revoked — run connect again`);
173
+ this.isShuttingDown = true;
174
+ log(`${c.red}✗${c.reset}`, `Device credential was revoked — run \`agent-tunnel connect --reauth\` to pair again`);
175
+ this.hooks.onTerminalClose?.({ code: event.code, reason: 'credential-rejected' });
157
176
  return;
158
177
  }
159
- if (event.code === 4004) {
178
+ if (event.code === AGENT_REPLACED_CLOSE_CODE) {
160
179
  this.isShuttingDown = true;
161
180
  log(
162
181
  `${c.yellow}○${c.reset}`,
163
182
  `Another Agent Tunnel process connected with these credentials — stopping this process`,
164
183
  );
184
+ this.hooks.onTerminalClose?.({ code: event.code, reason: 'replaced' });
165
185
  return;
166
186
  }
167
187
  log(`${c.yellow}○${c.reset}`, `Disconnected ${c.gray}(code: ${event.code})${c.reset}`);
@@ -191,7 +211,16 @@ export class TunnelAgent {
191
211
  // Handle auth_ok — server sends signing key after successful auth
192
212
  if (msg.type === 'auth_ok' && msg.signingKey) {
193
213
  this.signingKey = msg.signingKey;
194
- log(`${c.green}●${c.reset}`, `Connected ${c.reset}${c.gray}(${this.registry.getCapabilityNames().join(', ')})${c.reset}`);
214
+ const capabilityNames = this.registry.getCapabilityNames();
215
+ if (capabilityNames.length === 0) {
216
+ // Reporting a bare "Connected ()" hides that this tunnel is inert.
217
+ log(
218
+ `${c.yellow}!${c.reset}`,
219
+ `Connected, but no capabilities are enabled — this tunnel cannot do anything. Run \`agent-tunnel connect --reauth\` to pair again.`,
220
+ );
221
+ } else {
222
+ log(`${c.green}●${c.reset}`, `Connected ${c.reset}${c.gray}(${capabilityNames.join(', ')})${c.reset}`);
223
+ }
195
224
  if (this.stableConnectionTimer) clearTimeout(this.stableConnectionTimer);
196
225
  this.stableConnectionTimer = setTimeout(() => {
197
226
  this.reconnectAttempts = 0;
@@ -358,6 +387,10 @@ export class TunnelAgent {
358
387
  private send(data: unknown): void {
359
388
  if (this.ws?.readyState === WebSocket.OPEN) {
360
389
  try {
390
+ // The auth handshake carries the credential read from the local config
391
+ // file to the relay by design. loadConfig() validates the file and
392
+ // trustedCredential() rejects control characters before it gets here.
393
+ // lgtm[js/file-access-to-http]
361
394
  this.ws.send(JSON.stringify(data));
362
395
  } catch (err) {
363
396
  log(`${c.red}✗${c.reset}`, `Send failed`);
@@ -400,15 +433,6 @@ export class TunnelAgent {
400
433
  }
401
434
 
402
435
  private buildWsUrl(): string {
403
- const base = trustedHttpUrl(this.config.apiUrl)
404
- .replace(/^http:/, 'ws:')
405
- .replace(/^https:/, 'wss:');
406
-
407
- const wsPath = this.config.wsPath || '/ws';
408
- const params = new URLSearchParams({
409
- tunnelId: trustedCredential(this.config.tunnelId, 'tunnelId'),
410
- });
411
-
412
- return `${base}${wsPath}?${params.toString()}`;
436
+ return buildTunnelWsUrl(this.config);
413
437
  }
414
438
  }
@@ -0,0 +1,82 @@
1
+ import { arch, hostname, platform } from 'os';
2
+ import { c, sleep, visibleLength } from './terminal';
3
+
4
+ const BOX_WIDTH = 60;
5
+ const BAR_WIDTH = 50;
6
+ const BAR_FRAMES = 14;
7
+
8
+ const WORDMARK = [
9
+ `${c.cyan}▄▀█ █▀▀ █▀▀ █▄ █ ▀█▀${c.reset} ${c.cyan}▀█▀ █ █ █▄ █ █▄ █ █▀▀ █ ${c.reset}`,
10
+ `${c.cyan}█▀█ █▄█ ██▄ █ ▀█ █${c.reset} ${c.cyan} █ █▄█ █ ▀█ █ ▀█ ██▄ █▄▄${c.reset}`,
11
+ ];
12
+
13
+ function truncate(value: string, max: number): string {
14
+ return value.length > max ? `${value.slice(0, max)}…` : value;
15
+ }
16
+
17
+ async function animateConnectingBar(): Promise<void> {
18
+ for (let frame = 0; frame <= BAR_FRAMES; frame++) {
19
+ const filled = Math.round((frame / BAR_FRAMES) * BAR_WIDTH);
20
+ process.stdout.write(
21
+ `\r ${c.cyan}◇${c.reset} ${c.cyan}${'═'.repeat(filled)}${c.reset}${c.gray}${'─'.repeat(BAR_WIDTH - filled)}${c.reset} `,
22
+ );
23
+ await sleep(20);
24
+ }
25
+ process.stdout.write(`\r ${c.cyan}◇ ${'═'.repeat(BAR_WIDTH)} ◆${c.reset} \n`);
26
+ await sleep(120);
27
+ }
28
+
29
+ export interface BannerDetails {
30
+ tunnelId: string;
31
+ apiUrl: string;
32
+ capabilities: string[];
33
+ version: string;
34
+ }
35
+
36
+ export async function printStartupBanner({
37
+ tunnelId,
38
+ apiUrl,
39
+ capabilities,
40
+ version,
41
+ }: BannerDetails): Promise<void> {
42
+ console.log('');
43
+ for (const line of WORDMARK) console.log(` ${line}`);
44
+ console.log('');
45
+
46
+ await animateConnectingBar();
47
+
48
+ const row = (content: string) => {
49
+ const pad = Math.max(0, BOX_WIDTH - visibleLength(content));
50
+ console.log(` ${c.gray}│${c.reset}${content}${' '.repeat(pad)}${c.gray}│${c.reset}`);
51
+ };
52
+ const blank = () => row('');
53
+
54
+ const titleLeft =` ${c.cyan}◆${c.reset} ${c.bold}${c.white}Agent Tunnel${c.reset}`;
55
+ const titleRight = `${c.dim}v${version}${c.reset} `;
56
+ const titlePad = Math.max(1, BOX_WIDTH - visibleLength(titleLeft) - visibleLength(titleRight));
57
+
58
+ // An empty capability set means the tunnel can connect but do nothing, so it
59
+ // is stated outright rather than rendered as an empty gap.
60
+ const capabilityRow =
61
+ capabilities.length > 0
62
+ ? capabilities.map((name) => `${c.green}●${c.reset} ${c.white}${name}${c.reset}`).join(' ')
63
+ : `${c.yellow}none — this tunnel cannot act${c.reset}`;
64
+
65
+ const brand = 'created by kortix';
66
+
67
+ console.log('');
68
+ console.log(` ${c.gray}╭${'─'.repeat(BOX_WIDTH)}╮${c.reset}`);
69
+ blank();
70
+ row(`${titleLeft}${' '.repeat(titlePad)}${titleRight}`);
71
+ row(` ${c.dim}Bridge between AI agents & local machines${c.reset}`);
72
+ blank();
73
+ row(` ${c.dim}tunnel${c.reset} ${c.white}${truncate(tunnelId, 40)}${c.reset}`);
74
+ row(` ${c.dim}relay${c.reset} ${c.white}${truncate(apiUrl, 40)}${c.reset}`);
75
+ row(` ${c.dim}machine${c.reset} ${c.white}${truncate(hostname(), 28)}${c.reset} ${c.dim}(${platform()} ${arch()})${c.reset}`);
76
+ row(` ${c.dim}access${c.reset} ${capabilityRow}`);
77
+ blank();
78
+ console.log(
79
+ ` ${c.gray}╰${'─'.repeat(BOX_WIDTH - brand.length - 3)} ${c.dim}created by ${c.cyan}kortix${c.reset} ${c.gray}─╯${c.reset}`,
80
+ );
81
+ console.log('');
82
+ }
@@ -1,25 +1,89 @@
1
- import { describe, expect, test } from 'bun:test';
2
- import { spawnSync } from 'child_process';
3
- import { resolve } from 'path';
1
+ import { describe, expect, test } from "bun:test";
2
+ import { spawnSync } from "child_process";
3
+ import { mkdtempSync, rmSync } from "fs";
4
+ import { tmpdir } from "os";
5
+ import { join, resolve } from "path";
4
6
 
5
- const CLI_PATH = resolve(import.meta.dir, 'cli.ts');
7
+ const CLI_PATH = resolve(import.meta.dir, "cli.ts");
6
8
 
7
- describe('agent tunnel service UX', () => {
8
- test('offers persistent daemon mode without an unsupported keep-awake flag', () => {
9
- const result = spawnSync('bun', ['run', CLI_PATH, 'help'], { encoding: 'utf8' });
9
+ /**
10
+ * The published CLI's help text is its documented surface. tests/bin/package-quality.ts
11
+ * asserts the same list against the packed tarball; duplicating it here makes an
12
+ * accidental removal fail in this package's own suite instead of only in CI.
13
+ */
14
+ const DOCUMENTED_SURFACE = [
15
+ "connect",
16
+ "run",
17
+ "install-service",
18
+ "service-status",
19
+ "uninstall-service",
20
+ "status",
21
+ "logs",
22
+ "logout",
23
+ "--daemon",
24
+ "--foreground",
25
+ ] as const;
10
26
 
11
- expect(result.status).toBe(0);
12
- expect(result.stdout).toContain('--daemon');
13
- expect(result.stdout).toContain('--foreground');
14
- expect(result.stdout).not.toContain('--keep-awake');
27
+ describe("agent tunnel documented CLI surface", () => {
28
+ for (const entry of DOCUMENTED_SURFACE) {
29
+ test(`help lists ${entry}`, () => {
30
+ const result = spawnSync("bun", ["run", CLI_PATH, "help"], {
31
+ encoding: "utf8",
32
+ });
33
+ expect(result.status).toBe(0);
34
+ expect(result.stdout).toContain(entry);
35
+ });
36
+ }
37
+
38
+ // Only read-only commands are executed here. start/stop/restart/logout/unpair
39
+ // mutate the real launchd domain and credential file — running them from a
40
+ // test suite would stop a developer's live service or delete their pairing.
41
+ for (const command of ["status", "service-status"] as const) {
42
+ test(`${command} routes to the status view`, () => {
43
+ const home = mkdtempSync(join(tmpdir(), "agent-tunnel-help-"));
44
+ try {
45
+ const result = spawnSync("bun", ["run", CLI_PATH, command, "--json"], {
46
+ encoding: "utf8",
47
+ env: { ...process.env, HOME: home },
48
+ });
49
+ expect(result.status).toBe(0);
50
+ expect(JSON.parse(result.stdout)).toMatchObject({ paired: false });
51
+ } finally {
52
+ rmSync(home, { recursive: true, force: true });
53
+ }
54
+ });
55
+ }
56
+
57
+ test("an unknown command falls back to help", () => {
58
+ const result = spawnSync("bun", ["run", CLI_PATH, "not-a-command"], {
59
+ encoding: "utf8",
60
+ });
61
+ expect(result.stdout).toContain("Secure bridge between AI agents");
15
62
  });
63
+ });
16
64
 
17
- test('rejects the removed keep-awake flag instead of silently ignoring it', () => {
18
- const result = spawnSync('bun', ['run', CLI_PATH, 'connect', '--keep-awake'], {
19
- encoding: 'utf8',
65
+ describe("agent tunnel service UX", () => {
66
+ test("offers persistent daemon mode without an unsupported keep-awake flag", () => {
67
+ const result = spawnSync("bun", ["run", CLI_PATH, "help"], {
68
+ encoding: "utf8",
20
69
  });
21
70
 
71
+ expect(result.status).toBe(0);
72
+ expect(result.stdout).toContain("--daemon");
73
+ expect(result.stdout).toContain("--foreground");
74
+ expect(result.stdout).not.toContain("--keep-awake");
75
+ });
76
+
77
+ test("rejects the removed keep-awake flag instead of silently ignoring it", () => {
78
+ const result = spawnSync(
79
+ "bun",
80
+ ["run", CLI_PATH, "connect", "--keep-awake"],
81
+ {
82
+ encoding: "utf8",
83
+ },
84
+ );
85
+
22
86
  expect(result.status).toBe(2);
23
- expect(result.stderr).toContain('--keep-awake is not supported');
87
+ expect(result.stderr).toContain("--keep-awake is not supported");
24
88
  });
25
89
  });
@@ -0,0 +1,205 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test';
2
+ import { spawn, type ChildProcess } from 'node:child_process';
3
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join, resolve } from 'node:path';
6
+
7
+ const CLI_PATH = resolve(import.meta.dir, 'cli.ts');
8
+ const children = new Set<ChildProcess>();
9
+ const temporaryHomes = new Set<string>();
10
+
11
+ const STALE_TUNNEL_ID = '00000000-0000-4000-8000-0000000000ff';
12
+ const STALE_TOKEN = 'kortix_tnl_STALEAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
13
+ const FRESH_TUNNEL_ID = '00000000-0000-4000-8000-000000000042';
14
+ const FRESH_TOKEN = 'kortix_tnl_FRESHBBBBBBBBBBBBBBBBBBBBBBBBBBBB';
15
+
16
+ afterEach(async () => {
17
+ for (const child of children) child.kill('SIGTERM');
18
+ children.clear();
19
+ await Promise.all(
20
+ [...temporaryHomes].map((path) => rm(path, { recursive: true, force: true })),
21
+ );
22
+ temporaryHomes.clear();
23
+ });
24
+
25
+ async function seedStaleCredentials(apiUrl: string): Promise<string> {
26
+ const home = await mkdtemp(join(tmpdir(), 'agent-tunnel-reauth-home-'));
27
+ temporaryHomes.add(home);
28
+ const configDir = join(home, '.agent-tunnel');
29
+ await mkdir(configDir, { recursive: true, mode: 0o700 });
30
+ await writeFile(
31
+ join(configDir, 'config.json'),
32
+ JSON.stringify({ tunnelId: STALE_TUNNEL_ID, token: STALE_TOKEN, apiUrl }),
33
+ { mode: 0o600 },
34
+ );
35
+ return home;
36
+ }
37
+
38
+ async function waitFor<T>(read: () => Promise<T>, timeoutMs = 15_000): Promise<T> {
39
+ const deadline = Date.now() + timeoutMs;
40
+ let lastError: unknown;
41
+ while (Date.now() < deadline) {
42
+ try {
43
+ return await read();
44
+ } catch (error) {
45
+ lastError = error;
46
+ await Bun.sleep(50);
47
+ }
48
+ }
49
+ throw lastError ?? new Error('timed out');
50
+ }
51
+
52
+ /** Relay that refuses every credential, and a device-auth endpoint that issues a fresh one. */
53
+ function startRejectingRelay(options: { serveDeviceAuth: boolean }) {
54
+ let deviceAuthCalls = 0;
55
+ const server = Bun.serve({
56
+ port: 0,
57
+ fetch(request, server) {
58
+ const url = new URL(request.url);
59
+ if (url.pathname === '/v1/tunnel/ws') {
60
+ return server.upgrade(request) ? undefined : new Response('no upgrade', { status: 400 });
61
+ }
62
+ if (!options.serveDeviceAuth) return new Response('not found', { status: 404 });
63
+
64
+ if (request.method === 'POST' && url.pathname === '/v1/tunnel/device-auth') {
65
+ deviceAuthCalls++;
66
+ return Response.json(
67
+ {
68
+ deviceCode: 'RAUT-0001',
69
+ deviceSecret: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ123456',
70
+ verificationUrl: 'https://dev.kortix.com/tunnel/authorize/RAUT-0001',
71
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
72
+ pollIntervalMs: 250,
73
+ },
74
+ { status: 201 },
75
+ );
76
+ }
77
+ if (request.method === 'GET' && url.pathname.endsWith('/RAUT-0001/status')) {
78
+ return Response.json({
79
+ status: 'approved',
80
+ tunnelId: FRESH_TUNNEL_ID,
81
+ token: FRESH_TOKEN,
82
+ capabilities: ['filesystem'],
83
+ });
84
+ }
85
+ return new Response('not found', { status: 404 });
86
+ },
87
+ websocket: {
88
+ // 4001 is the relay's "credential refused" code.
89
+ message(ws) {
90
+ ws.close(4001, 'auth failed');
91
+ },
92
+ },
93
+ });
94
+ return { server, deviceAuthCalls: () => deviceAuthCalls };
95
+ }
96
+
97
+ function runConnect(home: string, apiUrl: string, extraArgs: string[] = []) {
98
+ const child = spawn(
99
+ process.execPath,
100
+ ['run', CLI_PATH, 'connect', '--foreground', '--api-url', apiUrl, ...extraArgs],
101
+ {
102
+ env: { ...process.env, HOME: home, KORTIX_AGENT_TUNNEL_NO_BROWSER: '1' },
103
+ stdio: ['ignore', 'pipe', 'pipe'],
104
+ },
105
+ );
106
+ children.add(child);
107
+ let stdout = '';
108
+ let stderr = '';
109
+ child.stdout?.on('data', (chunk) => { stdout += String(chunk); });
110
+ child.stderr?.on('data', (chunk) => { stderr += String(chunk); });
111
+ return { child, out: () => stdout, err: () => stderr };
112
+ }
113
+
114
+ describe('agent tunnel connect re-authorization', () => {
115
+ test('re-pairs through device auth when the saved token is rejected', async () => {
116
+ const relay = startRejectingRelay({ serveDeviceAuth: true });
117
+ const apiUrl = `http://127.0.0.1:${relay.server.port}/v1/tunnel`;
118
+ const home = await seedStaleCredentials(apiUrl);
119
+ const configPath = join(home, '.agent-tunnel', 'config.json');
120
+ const run = runConnect(home, apiUrl);
121
+
122
+ try {
123
+ const config = await waitFor(async () => {
124
+ const parsed = JSON.parse(await readFile(configPath, 'utf8')) as { token?: string };
125
+ if (parsed.token !== FRESH_TOKEN) throw new Error('still holding the stale token');
126
+ return parsed as { token: string; tunnelId: string };
127
+ });
128
+
129
+ expect(config.tunnelId).toBe(FRESH_TUNNEL_ID);
130
+ expect(run.out()).toContain('Checking saved credentials');
131
+ expect(run.out()).toContain('Saved token rejected');
132
+ expect(relay.deviceAuthCalls()).toBeGreaterThan(0);
133
+ } finally {
134
+ run.child.kill('SIGTERM');
135
+ relay.server.stop(true);
136
+ }
137
+ }, 30_000);
138
+
139
+ test('keeps the saved credential when the relay is unreachable', async () => {
140
+ // Port 1 is reserved and refuses connections, so the probe cannot conclude
141
+ // anything about the credential itself.
142
+ const apiUrl = 'http://127.0.0.1:1/v1/tunnel';
143
+ const home = await seedStaleCredentials(apiUrl);
144
+ const configPath = join(home, '.agent-tunnel', 'config.json');
145
+ const run = runConnect(home, apiUrl);
146
+
147
+ const exitCode = await new Promise<number | null>((r) => run.child.once('exit', r));
148
+ expect(exitCode).toBe(1);
149
+ expect(run.err()).toContain('Cannot reach the relay');
150
+
151
+ const config = JSON.parse(await readFile(configPath, 'utf8')) as { token?: string };
152
+ expect(config.token).toBe(STALE_TOKEN);
153
+ }, 30_000);
154
+
155
+ test('--reauth discards a saved credential without probing it first', async () => {
156
+ const relay = startRejectingRelay({ serveDeviceAuth: true });
157
+ const apiUrl = `http://127.0.0.1:${relay.server.port}/v1/tunnel`;
158
+ const home = await seedStaleCredentials(apiUrl);
159
+ const configPath = join(home, '.agent-tunnel', 'config.json');
160
+ const run = runConnect(home, apiUrl, ['--reauth']);
161
+
162
+ try {
163
+ const config = await waitFor(async () => {
164
+ const parsed = JSON.parse(await readFile(configPath, 'utf8')) as { token?: string };
165
+ if (parsed.token !== FRESH_TOKEN) throw new Error('not re-paired yet');
166
+ return parsed;
167
+ });
168
+ expect(config.token).toBe(FRESH_TOKEN);
169
+ expect(run.out()).not.toContain('Checking saved credentials');
170
+ } finally {
171
+ run.child.kill('SIGTERM');
172
+ relay.server.stop(true);
173
+ }
174
+ }, 30_000);
175
+
176
+ test('logout clears the credential but keeps unrelated settings', async () => {
177
+ const home = await mkdtemp(join(tmpdir(), 'agent-tunnel-logout-home-'));
178
+ temporaryHomes.add(home);
179
+ const configDir = join(home, '.agent-tunnel');
180
+ await mkdir(configDir, { recursive: true, mode: 0o700 });
181
+ const configPath = join(configDir, 'config.json');
182
+ await writeFile(
183
+ configPath,
184
+ JSON.stringify({
185
+ tunnelId: STALE_TUNNEL_ID,
186
+ token: STALE_TOKEN,
187
+ apiUrl: 'https://api.kortix.com/v1/tunnel',
188
+ shellTimeout: 12_345,
189
+ }),
190
+ { mode: 0o600 },
191
+ );
192
+
193
+ const child = spawn(process.execPath, ['run', CLI_PATH, 'logout'], {
194
+ env: { ...process.env, HOME: home },
195
+ stdio: ['ignore', 'pipe', 'pipe'],
196
+ });
197
+ children.add(child);
198
+ await new Promise<number | null>((r) => child.once('exit', r));
199
+
200
+ const config = JSON.parse(await readFile(configPath, 'utf8')) as Record<string, unknown>;
201
+ expect(config.token).toBeUndefined();
202
+ expect(config.tunnelId).toBeUndefined();
203
+ expect(config.shellTimeout).toBe(12_345);
204
+ }, 30_000);
205
+ });