@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,12 +1,17 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
2
  import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
+ import { posixShellCommand } from './service-drivers';
4
5
  import { join } from 'node:path';
6
+ import { homedir } from 'node:os';
5
7
  import {
6
8
  DEFAULT_INSTALL_BACKGROUND_SERVICE,
7
9
  SERVICE_LABEL,
10
+ agentTunnelHome,
8
11
  buildServiceShellCommand,
9
12
  getServicePaths,
13
+ runnerPartsFor,
14
+ serviceLabelFor,
10
15
  isEphemeralRunnerPath,
11
16
  renderLaunchdPlist,
12
17
  renderSystemdUnit,
@@ -30,18 +35,22 @@ describe('agent tunnel service definitions', () => {
30
35
  const plist = renderLaunchdPlist('exec /bin/echo tunnel');
31
36
  expect(plist).toContain(`<string>${SERVICE_LABEL}</string>`);
32
37
  expect(plist).toContain('<key>RunAtLoad</key>');
33
- expect(plist).toContain('<key>KeepAlive</key>');
38
+ // R3: the service runs forever. launchd restarts it on ANY exit, at most every 10 s.
39
+ expect(plist).toContain('<key>KeepAlive</key>\n <true/>');
40
+ expect(plist).toContain('<key>ThrottleInterval</key>\n <integer>10</integer>');
41
+ expect(plist).not.toContain('SuccessfulExit');
34
42
  expect(plist).toContain('<key>Umask</key>');
35
43
  expect(plist).toContain('agent-tunnel.out.log');
36
44
  expect(plist).toContain('agent-tunnel.err.log');
37
45
  });
38
46
 
39
- test('systemd unit restarts on failure but not after a terminal exit', () => {
47
+ test('systemd unit restarts on any exit (R3)', () => {
40
48
  const unit = renderSystemdUnit('exec /bin/echo tunnel');
41
49
  expect(unit).toContain('Description=Kortix Agent Tunnel');
42
- // Restart=always respawned the agent forever when the credential was
43
- // missing or revoked, which no restart can fix.
44
- expect(unit).toContain('Restart=on-failure');
50
+ // The agent itself never exits for a bad credential any more (it waits in
51
+ // `rejected`), so any exit is a crash worth restarting.
52
+ expect(unit).toContain('Restart=always');
53
+ expect(unit).toContain('RestartSec=5');
45
54
  expect(unit).toContain('UMask=0077');
46
55
  expect(unit).toContain('WantedBy=default.target');
47
56
  expect(unit).toContain('agent-tunnel.out.log');
@@ -57,6 +66,7 @@ describe('agent tunnel service definitions', () => {
57
66
  expect(script).toContain('while ($true)');
58
67
  expect(script).toContain("& 'node' 'agent-tunnel.js' 'run' '--service'");
59
68
  expect(script).toContain('Start-Sleep -Seconds 5');
69
+ expect(script).not.toContain('break');
60
70
  });
61
71
 
62
72
  test('treats package-manager caches as ephemeral runner locations', () => {
@@ -92,6 +102,8 @@ describe('agent tunnel service definitions', () => {
92
102
  const copied = vendorRunner(cached, paths);
93
103
  expect(copied).toBe(paths.vendoredRunner);
94
104
  expect(readFileSync(copied, 'utf8')).toBe('// cached bundle\n');
105
+ // The copy reports the same version as the CLI that installed it (R5).
106
+ expect(JSON.parse(readFileSync(join(home, 'bin', 'package.json'), 'utf8'))).toMatchObject({ name: '@kortix/agent-tunnel' });
95
107
  } finally {
96
108
  rmSync(home, { recursive: true, force: true });
97
109
  }
@@ -111,3 +123,112 @@ describe('agent tunnel service definitions', () => {
111
123
  expect(paths.windowsScript).toContain('agent-tunnel-service.ps1');
112
124
  });
113
125
  });
126
+
127
+ describe('AGENT_TUNNEL_HOME isolation', () => {
128
+ const defaultHome = join(homedir(), '.agent-tunnel');
129
+
130
+ test('defaults to ~/.agent-tunnel and honours the override', () => {
131
+ expect(agentTunnelHome({})).toBe(defaultHome);
132
+ expect(agentTunnelHome({ AGENT_TUNNEL_HOME: ' ' })).toBe(defaultHome);
133
+ expect(agentTunnelHome({ AGENT_TUNNEL_HOME: '/tmp/kortix-dev/agent-tunnel' })).toBe(
134
+ '/tmp/kortix-dev/agent-tunnel',
135
+ );
136
+ });
137
+
138
+ test('the default home keeps the historical service label', () => {
139
+ expect(serviceLabelFor(defaultHome)).toBe(SERVICE_LABEL);
140
+ expect(serviceLabelFor(`${defaultHome}/`)).toBe(SERVICE_LABEL);
141
+ });
142
+
143
+ test('any other home gets a stable 8-hex suffix derived from its path', () => {
144
+ const label = serviceLabelFor('/tmp/kortix-dev/agent-tunnel');
145
+ expect(label).toMatch(/^ai\.kortix\.agent-tunnel\.[0-9a-f]{8}$/);
146
+ expect(serviceLabelFor('/tmp/kortix-dev/agent-tunnel')).toBe(label);
147
+ expect(serviceLabelFor('/tmp/kortix-dev/other')).not.toBe(label);
148
+ });
149
+
150
+ test('service files of a non-default home never touch the real service', () => {
151
+ const paths = getServicePaths('/tmp/kortix-dev/agent-tunnel');
152
+ expect(paths.label).not.toBe(SERVICE_LABEL);
153
+ expect(paths.configDir).toBe('/tmp/kortix-dev/agent-tunnel');
154
+ expect(paths.logDir).toBe('/tmp/kortix-dev/agent-tunnel/logs');
155
+ expect(paths.launchdPlist).toEndWith(`/LaunchAgents/${paths.label}.plist`);
156
+ expect(paths.systemdUnit).toEndWith(`/${paths.label}.service`);
157
+ expect(paths.windowsScript).toStartWith('/tmp/kortix-dev/agent-tunnel');
158
+ expect(renderLaunchdPlist('exec true', paths)).toContain(`<string>${paths.label}</string>`);
159
+ });
160
+ });
161
+
162
+ describe('service runner', () => {
163
+ const devPaths = getServicePaths('/tmp/kortix-dev/agent-tunnel');
164
+
165
+ test('a Node runner carries a non-default home into the service env', () => {
166
+ const runner = runnerPartsFor('/opt/kortix/agent-cli.js', { execPath: '/usr/local/bin/node' }, devPaths);
167
+ expect(runner).toEqual({
168
+ command: '/usr/local/bin/node',
169
+ args: ['/opt/kortix/agent-cli.js', 'run', '--service'],
170
+ env: { AGENT_TUNNEL_HOME: '/tmp/kortix-dev/agent-tunnel' },
171
+ });
172
+ expect(runnerPartsFor('/opt/kortix/agent-cli.js', { execPath: 'node' }, getServicePaths()).env).toEqual({});
173
+ });
174
+
175
+ test('an Electron runner sets ELECTRON_RUN_AS_NODE and is never vendored', () => {
176
+ // An npx-looking path would be vendored under Node; the app bundle never is.
177
+ const script = '/Applications/Kortix.app/Contents/Resources/agent-tunnel/agent-cli.js';
178
+ const runner = runnerPartsFor(
179
+ script,
180
+ { execPath: '/Applications/Kortix.app/Contents/MacOS/Kortix', electron: '39.8.1' },
181
+ devPaths,
182
+ );
183
+ expect(runner.command).toBe('/Applications/Kortix.app/Contents/MacOS/Kortix');
184
+ expect(runner.args).toEqual([script, 'run', '--service']);
185
+ expect(runner.env).toEqual({
186
+ AGENT_TUNNEL_HOME: '/tmp/kortix-dev/agent-tunnel',
187
+ ELECTRON_RUN_AS_NODE: '1',
188
+ });
189
+ });
190
+
191
+ test('a Linux AppImage runs the AppImage file with a vendored bundle', () => {
192
+ const home = mkdtempSync(join(tmpdir(), 'agent-tunnel-appimage-'));
193
+ try {
194
+ const mount = join(home, '.mount_KortixAbc', 'resources', 'agent-tunnel');
195
+ mkdirSync(mount, { recursive: true });
196
+ writeFileSync(join(mount, 'agent-cli.js'), '// bundle\n');
197
+ const paths = getServicePaths(join(home, 'agent-home'));
198
+ const runner = runnerPartsFor(
199
+ join(mount, 'agent-cli.js'),
200
+ { execPath: join(home, '.mount_KortixAbc', 'kortix'), electron: '39.8.1', appImage: '/home/u/Kortix.AppImage' },
201
+ paths,
202
+ );
203
+ expect(runner.command).toBe('/home/u/Kortix.AppImage');
204
+ expect(runner.args[0]).toBe(paths.vendoredRunner);
205
+ expect(readFileSync(paths.vendoredRunner, 'utf8')).toBe('// bundle\n');
206
+ } finally {
207
+ rmSync(home, { recursive: true, force: true });
208
+ }
209
+ });
210
+
211
+ test('launchd and systemd commands export the runner env before exec', () => {
212
+ const command = posixShellCommand({
213
+ command: '/Applications/Kortix.app/Contents/MacOS/Kortix',
214
+ args: ['agent-cli.js', 'run', '--service'],
215
+ env: { AGENT_TUNNEL_HOME: "/tmp/it's here", ELECTRON_RUN_AS_NODE: '1' },
216
+ });
217
+ expect(command).toStartWith(`export AGENT_TUNNEL_HOME='/tmp/it'\\''s here' ELECTRON_RUN_AS_NODE='1'; exec `);
218
+ expect(renderLaunchdPlist(command, devPaths)).toContain('ELECTRON_RUN_AS_NODE=&apos;1&apos;');
219
+ expect(renderSystemdUnit(command, devPaths)).toContain('ELECTRON_RUN_AS_NODE=');
220
+ // No env, no export: the default service definition is unchanged.
221
+ expect(posixShellCommand({ command: 'node', args: ['a.js'] })).toStartWith('exec ');
222
+ });
223
+
224
+ test('the Windows script sets the env and waits for a GUI-subsystem binary', () => {
225
+ const script = renderWindowsPowerShellScript({
226
+ command: 'C:\\Kortix\\Kortix.exe',
227
+ args: ['agent-cli.js', 'run', '--service'],
228
+ env: { AGENT_TUNNEL_HOME: 'C:\\dev\\agent-tunnel', ELECTRON_RUN_AS_NODE: '1' },
229
+ });
230
+ expect(script).toContain("$env:AGENT_TUNNEL_HOME = 'C:\\dev\\agent-tunnel'");
231
+ expect(script).toContain("$env:ELECTRON_RUN_AS_NODE = '1'");
232
+ expect(script).toContain("& 'C:\\Kortix\\Kortix.exe' 'agent-cli.js' 'run' '--service' | Out-Null");
233
+ });
234
+ });
@@ -1,9 +1,10 @@
1
- import { chmodSync, copyFileSync, existsSync, mkdirSync, realpathSync, rmSync, writeFileSync } from 'fs';
1
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'fs';
2
2
  import { platform } from 'os';
3
3
  import { join } from 'path';
4
4
 
5
5
  import {
6
6
  SUPPORTED_PLATFORMS_MESSAGE,
7
+ type Outcome,
7
8
  type RunnerParts,
8
9
  posixShellCommand,
9
10
  serviceDriver,
@@ -12,7 +13,9 @@ import {
12
13
  type ServicePaths,
13
14
  getServicePaths,
14
15
  rotateServiceLogs,
16
+ serviceHomeEnv,
15
17
  } from './service-paths';
18
+ import { agentTunnelVersion } from './version';
16
19
 
17
20
  export {
18
21
  DEFAULT_INSTALL_BACKGROUND_SERVICE,
@@ -20,7 +23,10 @@ export {
20
23
  SERVICE_LABEL,
21
24
  TERMINAL_SERVICE_EXIT_CODE,
22
25
  type ServicePaths,
26
+ agentTunnelHome,
23
27
  getServicePaths,
28
+ serviceHomeEnv,
29
+ serviceLabelFor,
24
30
  rotateServiceLogs,
25
31
  serviceLogFiles,
26
32
  } from './service-paths';
@@ -34,6 +40,13 @@ export interface ServiceStatus {
34
40
  platform: NodeJS.Platform;
35
41
  installed: boolean;
36
42
  active: boolean | null;
43
+ /** False while paused: disabled in the supervisor, so login does not start it. */
44
+ enabled?: boolean;
45
+ /**
46
+ * The installed unit is exactly what `install` would write now. False after
47
+ * an app update or move: the unit points at an old runner (R5).
48
+ */
49
+ upToDate?: boolean;
37
50
  path?: string;
38
51
  detail?: string;
39
52
  }
@@ -52,7 +65,9 @@ export function isEphemeralRunnerPath(path: string): boolean {
52
65
  normalized.includes('/_npx/') ||
53
66
  normalized.includes('/_cacache/') ||
54
67
  normalized.includes('/.pnpm-store/') ||
55
- normalized.includes('/.yarn/$$virtual/')
68
+ normalized.includes('/.yarn/$$virtual/') ||
69
+ // A Linux AppImage is mounted at a new /tmp/.mount_* path on every launch.
70
+ normalized.includes('/.mount_')
56
71
  );
57
72
  }
58
73
 
@@ -68,6 +83,13 @@ export function vendorRunner(scriptPath: string, paths: ServicePaths = getServic
68
83
  const source = realpathSync(scriptPath);
69
84
  copyFileSync(source, paths.vendoredRunner);
70
85
  try { chmodSync(paths.vendoredRunner, 0o700); } catch {}
86
+ // The copy has no package.json beside it, so it would report version
87
+ // "unknown" and the desktop's version check (R5) could never pass.
88
+ writeFileSync(
89
+ join(paths.binDir, 'package.json'),
90
+ JSON.stringify({ name: '@kortix/agent-tunnel', version: agentTunnelVersion(), private: true }),
91
+ { mode: 0o600 },
92
+ );
71
93
  writeFileSync(
72
94
  join(paths.binDir, 'agent-cli.source.json'),
73
95
  JSON.stringify({ source, vendoredFrom: scriptPath }, null, 2),
@@ -76,11 +98,45 @@ export function vendorRunner(scriptPath: string, paths: ServicePaths = getServic
76
98
  return paths.vendoredRunner;
77
99
  }
78
100
 
79
- function currentRunnerParts(): RunnerParts {
80
- const script = process.argv[1];
81
- if (script && existsSync(script)) {
82
- return { command: process.execPath, args: [vendorRunner(script), 'run', '--service'] };
101
+ /**
102
+ * The command the supervisor runs: this interpreter and this bundle.
103
+ *
104
+ * Under Electron (the desktop app runs the bundle with ELECTRON_RUN_AS_NODE)
105
+ * the interpreter is the app binary, which must be told to act as Node again,
106
+ * and the bundle sits inside the app, which the updater replaces in place, so
107
+ * it is not vendored. A Linux AppImage is the exception: its mount path changes
108
+ * per launch, so the service runs the AppImage file and a vendored bundle.
109
+ */
110
+ export function runnerPartsFor(
111
+ script: string,
112
+ runtime: { execPath: string; electron?: string; appImage?: string } = {
113
+ execPath: process.execPath,
114
+ electron: process.versions.electron,
115
+ appImage: process.env.APPIMAGE,
116
+ },
117
+ paths: ServicePaths = getServicePaths(),
118
+ vendor: (script: string, paths: ServicePaths) => string = vendorRunner,
119
+ ): RunnerParts {
120
+ const env = serviceHomeEnv(paths.configDir);
121
+ if (runtime.electron) {
122
+ return {
123
+ command: runtime.appImage || runtime.execPath,
124
+ args: [runtime.appImage ? vendor(script, paths) : script, 'run', '--service'],
125
+ env: { ...env, ELECTRON_RUN_AS_NODE: '1' },
126
+ };
83
127
  }
128
+ return { command: runtime.execPath, args: [vendor(script, paths), 'run', '--service'], env };
129
+ }
130
+
131
+ /** Where vendorRunner WOULD put the bundle, without copying anything. */
132
+ const plannedRunner = (script: string, paths: ServicePaths) =>
133
+ isEphemeralRunnerPath(script) ? paths.vendoredRunner : script;
134
+
135
+ function currentRunnerParts(
136
+ vendor: (script: string, paths: ServicePaths) => string = vendorRunner,
137
+ ): RunnerParts {
138
+ const script = process.argv[1];
139
+ if (script && existsSync(script)) return runnerPartsFor(script, undefined, getServicePaths(), vendor);
84
140
  throw new Error(
85
141
  'Cannot install the background service because the current Agent Tunnel executable was not found',
86
142
  );
@@ -98,11 +154,7 @@ export function buildServiceShellCommand(): string {
98
154
  * functions.
99
155
  */
100
156
  function withDriver(
101
- operate: (driver: NonNullable<ReturnType<typeof serviceDriver>>, paths: ServicePaths, installed: boolean) => {
102
- active?: boolean | null;
103
- installed?: boolean;
104
- detail?: string;
105
- },
157
+ operate: (driver: NonNullable<ReturnType<typeof serviceDriver>>, paths: ServicePaths, installed: boolean) => Outcome,
106
158
  fallback: { installed: boolean; active: boolean | null },
107
159
  ): ServiceStatus {
108
160
  const driver = serviceDriver();
@@ -121,6 +173,8 @@ function withDriver(
121
173
  platform: platform(),
122
174
  installed: outcome.installed ?? installed,
123
175
  active: outcome.active ?? fallback.active,
176
+ ...(outcome.enabled !== undefined ? { enabled: outcome.enabled } : {}),
177
+ ...(outcome.upToDate !== undefined ? { upToDate: outcome.upToDate } : {}),
124
178
  path,
125
179
  detail: outcome.detail,
126
180
  };
@@ -163,6 +217,33 @@ export function stopService(): ServiceStatus {
163
217
  );
164
218
  }
165
219
 
220
+ /** R3: stops the service and keeps it stopped across login and reboot. */
221
+ export function pauseService(): ServiceStatus {
222
+ return withDriver(
223
+ (driver, paths, installed) => ({ ...driver.pause(paths, installed), active: false }),
224
+ { installed: false, active: false },
225
+ );
226
+ }
227
+
228
+ /**
229
+ * Reverses pauseService. A unit that points at an old runner (the app moved or
230
+ * updated while paused) is rewritten first, so Resume never starts a unit whose
231
+ * executable is gone.
232
+ */
233
+ export function resumeService(): ServiceStatus {
234
+ const current = getServiceStatus();
235
+ // Install enables the job on every supervisor, so it also ends the pause.
236
+ if (current.installed && current.upToDate === false) return installService();
237
+ return resumeUnit();
238
+ }
239
+
240
+ function resumeUnit(): ServiceStatus {
241
+ return withDriver(
242
+ (driver, paths, installed) => driver.resume(paths, installed),
243
+ { installed: false, active: null },
244
+ );
245
+ }
246
+
166
247
  export function restartService(): ServiceStatus {
167
248
  stopService();
168
249
  return startService();
@@ -180,7 +261,13 @@ export function getServiceStatus(): ServiceStatus {
180
261
  };
181
262
  }
182
263
  return withDriver(
183
- (driver, paths, installed) => driver.status(paths, installed),
264
+ (driver, paths, installed) => {
265
+ let upToDate = false;
266
+ try {
267
+ upToDate = installed && readFileSync(driver.unitPath(paths), 'utf8') === driver.render(paths, currentRunnerParts(plannedRunner));
268
+ } catch {}
269
+ return { ...driver.status(paths, installed), upToDate };
270
+ },
184
271
  { installed: false, active: null },
185
272
  );
186
273
  }
@@ -0,0 +1,32 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { mkdtempSync, readdirSync, rmSync, statSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+
6
+ import { readAgentState, stateFilePath, writeAgentState } from './state-file';
7
+
8
+ describe('agent state file', () => {
9
+ test('writes the status atomically, privately, and reads it back', () => {
10
+ const home = join(mkdtempSync(join(tmpdir(), 'agent-tunnel-state-')), 'home');
11
+ try {
12
+ expect(readAgentState(home)).toBeNull();
13
+ const tunnel = { tunnelId: '00000000-0000-4000-8000-000000000001', apiUrl: 'http://127.0.0.1:8008/v1/tunnel' };
14
+
15
+ writeAgentState('connecting', tunnel, home);
16
+ writeAgentState('online', tunnel, home);
17
+
18
+ const state = readAgentState(home);
19
+ expect(state).toMatchObject({ ...tunnel, status: 'online', pid: process.pid });
20
+ expect(Date.parse(state!.since)).toBeGreaterThan(Date.now() - 5_000);
21
+ expect(typeof state!.agentVersion).toBe('string');
22
+ expect(statSync(stateFilePath(home)).mode & 0o077).toBe(0);
23
+ expect(statSync(home).mode & 0o077).toBe(0);
24
+ // No temp file is left behind.
25
+ expect(readdirSync(home)).toEqual(['state.json']);
26
+ // The state never carries the credential.
27
+ expect(JSON.stringify(state)).not.toContain('token');
28
+ } finally {
29
+ rmSync(join(home, '..'), { recursive: true, force: true });
30
+ }
31
+ });
32
+ });
@@ -0,0 +1,58 @@
1
+ import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs';
2
+ import { join } from 'path';
3
+
4
+ import type { TunnelAgentStatus } from './agent';
5
+ import { agentTunnelHome } from './service-paths';
6
+ import { agentTunnelVersion } from './version';
7
+
8
+ /**
9
+ * `<home>/state.json`: the running agent's connection status, for local
10
+ * observers such as the desktop tray. It holds no credential.
11
+ */
12
+ export interface AgentStateFile {
13
+ tunnelId: string;
14
+ apiUrl: string;
15
+ status: TunnelAgentStatus;
16
+ /** ISO time of the last status change. */
17
+ since: string;
18
+ agentVersion: string;
19
+ pid: number;
20
+ }
21
+
22
+ export function stateFilePath(home: string = agentTunnelHome()): string {
23
+ return join(home, 'state.json');
24
+ }
25
+
26
+ /** Atomic and private. A status write never stops the agent. */
27
+ export function writeAgentState(
28
+ status: TunnelAgentStatus,
29
+ tunnel: { tunnelId: string; apiUrl: string },
30
+ home: string = agentTunnelHome(),
31
+ ): void {
32
+ const state: AgentStateFile = {
33
+ tunnelId: tunnel.tunnelId,
34
+ apiUrl: tunnel.apiUrl,
35
+ status,
36
+ since: new Date().toISOString(),
37
+ agentVersion: agentTunnelVersion(),
38
+ pid: process.pid,
39
+ };
40
+ try {
41
+ mkdirSync(home, { recursive: true, mode: 0o700 });
42
+ const tmp = join(home, `state.${process.pid}.${Date.now()}.tmp`);
43
+ writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
44
+ try { chmodSync(tmp, 0o600); } catch {}
45
+ renameSync(tmp, stateFilePath(home));
46
+ } catch {
47
+ // Observers fall back to "unknown"; the tunnel itself is unaffected.
48
+ }
49
+ }
50
+
51
+ export function readAgentState(home: string = agentTunnelHome()): AgentStateFile | null {
52
+ try {
53
+ const parsed = JSON.parse(readFileSync(stateFilePath(home), 'utf8')) as AgentStateFile;
54
+ return parsed && typeof parsed === 'object' && typeof parsed.status === 'string' ? parsed : null;
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
@@ -91,6 +91,10 @@ export class TunnelClient {
91
91
  return data.result;
92
92
  }
93
93
 
94
+ /**
95
+ * Kept for published 0.1.x callers. The current API never returns a
96
+ * `requestId` (permission requests were removed), so this equals `rpc`.
97
+ */
94
98
  async rpcWithPermissionFlow(method: string, params: Record<string, unknown> = {}): Promise<unknown> {
95
99
  try {
96
100
  return await this.rpc(method, params);
@@ -147,9 +151,8 @@ export class TunnelClient {
147
151
  this.cachedTunnelId = null;
148
152
  throw new TunnelClientError(
149
153
  -1,
150
- 'No tunnel connection found. The user needs to set up Agent Tunnel first:\n' +
151
- '1. Create a tunnel connection\n' +
152
- '2. Connect the local machine from the Kortix desktop app or run the tunnel connect command',
154
+ 'No tunnel connection found. Connect this computer from the Kortix desktop app, ' +
155
+ 'or run `npx @kortix/agent-tunnel connect`.',
153
156
  );
154
157
  }
155
158
  }
@@ -140,7 +140,7 @@ describe('tunnel WebSocket identity binding', () => {
140
140
 
141
141
  expect(relay.isConnected('tunnel-1')).toBe(false);
142
142
  expect(socket.closes).toContainEqual({
143
- code: 4001,
143
+ code: 1011,
144
144
  reason: 'authentication response failed',
145
145
  });
146
146
  });
@@ -155,4 +155,34 @@ describe('tunnel WebSocket identity binding', () => {
155
155
 
156
156
  expect(socket.closes).toContainEqual({ code: 4002, reason: 'message too large' });
157
157
  });
158
+
159
+ // A relay that cannot finish auth (database down during a deploy, overload)
160
+ // must not tell the agent its credential is bad: agents stop for good on 4001.
161
+ test('closes with a retryable code when the authenticator throws', async () => {
162
+ const relay = new TunnelRelay();
163
+ const handlers = createWsHandlers(relay, {
164
+ onAuthenticate: async () => {
165
+ throw new Error('database unavailable');
166
+ },
167
+ });
168
+ const socket = fakeWs();
169
+ handlers.onOpen('tunnel-1', socket);
170
+ const originalError = console.error;
171
+ console.error = () => {};
172
+ try {
173
+ await handlers.onMessage('tunnel-1', socket, JSON.stringify({ type: 'auth', token: 't' }));
174
+ } finally {
175
+ console.error = originalError;
176
+ }
177
+ expect(socket.closes).toEqual([{ code: 1011, reason: 'authentication error' }]);
178
+ });
179
+
180
+ test('closes with a retryable code when auth does not complete in time', async () => {
181
+ const relay = new TunnelRelay();
182
+ const handlers = createWsHandlers(relay, { authTimeoutMs: 5 });
183
+ const socket = fakeWs();
184
+ handlers.onOpen('tunnel-1', socket);
185
+ await Bun.sleep(20);
186
+ expect(socket.closes).toEqual([{ code: 1013, reason: 'auth timeout' }]);
187
+ });
158
188
  });
@@ -39,7 +39,7 @@ export function createWsHandlers(relay: TunnelRelay, opts?: WsHandlerOptions): W
39
39
  const timer = setTimeout(() => {
40
40
  pendingConnections.delete(ws);
41
41
  try {
42
- ws.close(4001, 'auth timeout');
42
+ ws.close(1013, 'auth timeout');
43
43
  } catch {}
44
44
  }, authTimeoutMs);
45
45
 
@@ -135,7 +135,7 @@ export function createWsHandlers(relay: TunnelRelay, opts?: WsHandlerOptions): W
135
135
  );
136
136
  } catch {
137
137
  try {
138
- ws.close(4001, 'authentication response failed');
138
+ ws.close(1011, 'authentication response failed');
139
139
  } catch {}
140
140
  return;
141
141
  }
@@ -149,7 +149,7 @@ export function createWsHandlers(relay: TunnelRelay, opts?: WsHandlerOptions): W
149
149
  pendingConnections.delete(ws);
150
150
  console.error(`[tunnel-ws] Auth error for ${tunnelId}:`, err);
151
151
  try {
152
- ws.close(4001, 'authentication error');
152
+ ws.close(1011, 'authentication error');
153
153
  } catch {}
154
154
  }
155
155
 
@@ -42,6 +42,12 @@ export const TunnelErrorCode = {
42
42
  EXPIRED: -32005,
43
43
  RATE_LIMITED: -32006,
44
44
  AUTH_FAILED: -32007,
45
+ /** The owner has not answered the access prompt on the machine (contract v2 X1). */
46
+ ACCESS_PENDING: -32010,
47
+ /** The owner denied access on the machine; fails fast until the denial lapses. */
48
+ ACCESS_DENIED: -32011,
49
+ /** The owner turned computer access off on the machine. */
50
+ ACCESS_OFF: -32012,
45
51
  } as const;
46
52
 
47
53
  export type TunnelErrorCodeValue = (typeof TunnelErrorCode)[keyof typeof TunnelErrorCode];
@@ -183,6 +189,12 @@ export interface TunnelAuthMessage {
183
189
  /** RPC capability handlers registered in this exact agent process. */
184
190
  capabilities?: TunnelCapability[];
185
191
  agentVersion?: string;
192
+ /**
193
+ * True from agents that report their access mode (`tunnel.access.state`).
194
+ * Lets the API drop a mode stored by an earlier agent when an older one
195
+ * that enforces nothing connects with the same credential.
196
+ */
197
+ reportsAccess?: boolean;
186
198
  }
187
199
 
188
200
  /** Result returned by onAuthenticate hook on success. */