@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.
@@ -1,8 +1,9 @@
1
1
  import { chmodSync, existsSync, lstatSync, readFileSync } from 'fs';
2
- import { join } from 'path';
2
+ import { basename, dirname, join } from 'path';
3
3
  import { homedir } from 'os';
4
4
  import { isTunnelCapability } from '../shared/permissions';
5
5
  import type { TunnelCapability } from '../shared/types';
6
+ import { agentTunnelHome, defaultAgentTunnelHome } from './service-paths';
6
7
 
7
8
  export interface TunnelConfig {
8
9
  token: string;
@@ -23,7 +24,7 @@ export interface TunnelConfig {
23
24
  enabledCapabilities?: TunnelCapability[];
24
25
  }
25
26
 
26
- const CONFIG_DIR = join(homedir(), '.agent-tunnel');
27
+ const CONFIG_DIR = agentTunnelHome();
27
28
  const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
28
29
 
29
30
  const DEFAULTS: Partial<TunnelConfig> = {
@@ -213,6 +214,19 @@ export function buildTunnelWsUrl(
213
214
  return `${base}${wsPath}?${params.toString()}`;
214
215
  }
215
216
 
217
+ /**
218
+ * Directories no RPC may read or write, whatever config.json says. The agent
219
+ * home holds the credential, access.json (the owner's answer to the access
220
+ * prompt) and desktop-app.json (what the agent launches). A file grant must
221
+ * never rewrite them into a permanent grant or a command to run. A desktop
222
+ * home `<userData>/agent-tunnel/<sha8>` also protects its sibling homes.
223
+ */
224
+ export function protectedAgentPaths(home: string = CONFIG_DIR): string[] {
225
+ const paths = new Set([home, defaultAgentTunnelHome()]);
226
+ if (basename(dirname(home)) === 'agent-tunnel') paths.add(dirname(home));
227
+ return [...paths];
228
+ }
229
+
216
230
  export function loadConfig(overrides: Partial<TunnelConfig> = {}): TunnelConfig {
217
231
  let fileConfig: Partial<TunnelConfig> = {};
218
232
  if (existsSync(CONFIG_FILE)) {
@@ -250,6 +264,7 @@ export function loadConfig(overrides: Partial<TunnelConfig> = {}): TunnelConfig
250
264
  merged.apiUrl = normalizeApiUrl(merged.apiUrl);
251
265
  merged.wsPath = absoluteWsPath(merged.wsPath);
252
266
  validateConfigValues(merged);
267
+ merged.blockedPaths = [...merged.blockedPaths, ...protectedAgentPaths()];
253
268
 
254
269
  return merged;
255
270
  }
@@ -1,5 +1,5 @@
1
1
  import '../node-ws-polyfill';
2
- import { AGENT_REPLACED_CLOSE_CODE, AGENT_VERSION, AUTH_REJECTED_CLOSE_CODES } from './agent';
2
+ import { AGENT_REPLACED_CLOSE_CODE, AGENT_VERSION, isCredentialRejection } from './agent';
3
3
  import { buildTunnelWsUrl, trustedCredential, type TunnelConfig } from './config';
4
4
 
5
5
  /**
@@ -78,8 +78,8 @@ export function probeCredentials(
78
78
  });
79
79
 
80
80
  socket.addEventListener('close', (event) => {
81
- const { code } = event as CloseEvent;
82
- if (AUTH_REJECTED_CLOSE_CODES.includes(code)) return settle('rejected');
81
+ const { code, reason } = event as CloseEvent;
82
+ if (isCredentialRejection(code, reason)) return settle('rejected');
83
83
  // The relay only replaces a socket it registered, and it only registers
84
84
  // one that authenticated. Being replaced proves the credential is good.
85
85
  if (code === AGENT_REPLACED_CLOSE_CODE) return settle('valid');
@@ -1,8 +1,8 @@
1
1
  import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'fs';
2
- import { homedir } from 'os';
3
2
  import { join } from 'path';
3
+ import { agentTunnelHome } from './service-paths';
4
4
 
5
- export const CONFIG_DIR = join(homedir(), '.agent-tunnel');
5
+ export const CONFIG_DIR = agentTunnelHome();
6
6
  export const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
7
7
 
8
8
  /** Fields that identify a pairing. Everything else in the file is user settings. */
@@ -2,8 +2,10 @@ import { describe, expect, test } from 'bun:test';
2
2
  import {
3
3
  InvalidDeviceAuthResponseError,
4
4
  awaitDeviceAuthorization,
5
+ machineDisplayName,
5
6
  parseDeviceAuthChallenge,
6
7
  parseDeviceAuthStatus,
8
+ requestDeviceAuthorization,
7
9
  } from './device-auth';
8
10
 
9
11
  const VALID_TUNNEL_ID = '00000000-0000-4000-8000-000000000042';
@@ -130,3 +132,58 @@ describe('polling', () => {
130
132
  expect(outcome).toEqual({ status: 'expired' });
131
133
  });
132
134
  });
135
+
136
+ describe('machine display name', () => {
137
+ const fails = () => {
138
+ throw new Error('scutil: not found');
139
+ };
140
+
141
+ test('macOS uses the ComputerName the person set', () => {
142
+ const name = machineDisplayName({
143
+ os: 'darwin',
144
+ host: 'MacBook-Pro-9.local',
145
+ env: {},
146
+ run: (command, args) => {
147
+ expect([command, ...args]).toEqual(['scutil', '--get', 'ComputerName']);
148
+ return 'Ada’s MacBook Pro\n';
149
+ },
150
+ });
151
+ expect(name).toBe('Ada’s MacBook Pro');
152
+ });
153
+
154
+ test('macOS falls back to the hostname without .local when scutil fails or is empty', () => {
155
+ expect(machineDisplayName({ os: 'darwin', host: 'MacBook-Pro-9.local', env: {}, run: fails })).toBe(
156
+ 'MacBook-Pro-9',
157
+ );
158
+ expect(machineDisplayName({ os: 'darwin', host: 'MacBook-Pro-9.local', env: {}, run: () => ' ' })).toBe(
159
+ 'MacBook-Pro-9',
160
+ );
161
+ });
162
+
163
+ test('Windows uses COMPUTERNAME', () => {
164
+ expect(
165
+ machineDisplayName({ os: 'win32', host: 'desktop-1', env: { COMPUTERNAME: 'STUDIO-PC' }, run: fails }),
166
+ ).toBe('STUDIO-PC');
167
+ expect(machineDisplayName({ os: 'win32', host: 'desktop-1', env: {}, run: fails })).toBe('desktop-1');
168
+ });
169
+
170
+ test('Linux uses the hostname and never runs a command', () => {
171
+ expect(machineDisplayName({ os: 'linux', host: 'build-box.local', env: {}, run: fails })).toBe('build-box');
172
+ expect(machineDisplayName({ os: 'linux', host: 'build-box', env: {}, run: fails })).toBe('build-box');
173
+ });
174
+
175
+ test('pairing proposes the display name as the machine name', async () => {
176
+ const original = globalThis.fetch;
177
+ let body: Record<string, unknown> = {};
178
+ globalThis.fetch = (async (_url: string, init: RequestInit) => {
179
+ body = JSON.parse(String(init.body));
180
+ return new Response(JSON.stringify(challenge()), { status: 200 });
181
+ }) as unknown as typeof fetch;
182
+ try {
183
+ await requestDeviceAuthorization('https://api.kortix.com/v1/tunnel');
184
+ } finally {
185
+ globalThis.fetch = original;
186
+ }
187
+ expect(body.machineHostname).toBe(machineDisplayName());
188
+ });
189
+ });
@@ -1,4 +1,4 @@
1
- import { spawn } from 'child_process';
1
+ import { execFileSync, spawn } from 'child_process';
2
2
  import { hostname, platform } from 'os';
3
3
  import { isTunnelCapability } from '../shared/permissions';
4
4
  import type { TunnelCapability } from '../shared/types';
@@ -10,6 +10,8 @@ import type { TunnelCapability } from '../shared/types';
10
10
  * rules below can be read — and tested — without a CLI around them.
11
11
  */
12
12
 
13
+ export const UUID_PATTERN =
14
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
13
15
  const TUNNEL_ID_PATTERN =
14
16
  /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
15
17
  const SETUP_TOKEN_PATTERN = /^kortix_tnl_[A-Za-z0-9_-]{32,64}$/;
@@ -138,11 +140,51 @@ export function parseDeviceAuthStatus(value: unknown): DeviceAuthOutcome | null
138
140
  }
139
141
  }
140
142
 
141
- export async function requestDeviceAuthorization(apiUrl: string): Promise<DeviceAuthChallenge> {
143
+ /**
144
+ * The name a person gave this machine, e.g. "Ada's MacBook Pro" instead of
145
+ * `MacBook-Pro-9.local`. macOS: System Settings' Computer Name; Windows:
146
+ * COMPUTERNAME; elsewhere, and whenever those are unavailable, the hostname
147
+ * without a trailing `.local`.
148
+ */
149
+ export function machineDisplayName({
150
+ os = platform(),
151
+ host = hostname(),
152
+ env = process.env,
153
+ run = (command: string, args: string[]) =>
154
+ execFileSync(command, args, { encoding: 'utf8', timeout: 1_000, stdio: ['ignore', 'pipe', 'ignore'] }),
155
+ }: {
156
+ os?: string;
157
+ host?: string;
158
+ env?: Record<string, string | undefined>;
159
+ run?: (command: string, args: string[]) => string;
160
+ } = {}): string {
161
+ const fallback = host.replace(/\.local$/i, '') || host;
162
+ try {
163
+ const name =
164
+ os === 'darwin' ? run('scutil', ['--get', 'ComputerName']) : os === 'win32' ? env.COMPUTERNAME : '';
165
+ return name?.trim() || fallback;
166
+ } catch {
167
+ return fallback;
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Starts pairing. `projectId` names the project the machine is being connected
173
+ * to, so the approval page can skip its project picker. A relay that predates
174
+ * the field ignores it.
175
+ */
176
+ export async function requestDeviceAuthorization(
177
+ apiUrl: string,
178
+ options: { projectId?: string } = {},
179
+ ): Promise<DeviceAuthChallenge> {
142
180
  const response = await fetch(`${apiUrl}/device-auth`, {
143
181
  method: 'POST',
144
182
  headers: { 'Content-Type': 'application/json' },
145
- body: JSON.stringify({ machineHostname: hostname() }),
183
+ body: JSON.stringify({
184
+ // The default machine name on the approval page.
185
+ machineHostname: machineDisplayName(),
186
+ ...(options.projectId ? { project_id: options.projectId } : {}),
187
+ }),
146
188
  });
147
189
  if (!response.ok) {
148
190
  const body = await response.text().catch(() => '');
@@ -56,7 +56,9 @@ function assertAllowedResolvedPath(
56
56
  });
57
57
 
58
58
  if (!withinAllowed) {
59
- throw new Error(`Access denied: path "${originalPath}" is outside allowed directories`);
59
+ throw new Error(
60
+ `Access denied: path "${originalPath}" is outside allowed directories (${allowedPaths.join(', ')})`,
61
+ );
60
62
  }
61
63
  }
62
64
  }
@@ -2,7 +2,9 @@ import {
2
2
  type ServiceStatus,
3
3
  getServiceStatus,
4
4
  installService,
5
+ pauseService,
5
6
  restartService,
7
+ resumeService,
6
8
  startService,
7
9
  stopService,
8
10
  uninstallService,
@@ -39,8 +41,8 @@ export function acquireTunnelLease(): TunnelLease {
39
41
 
40
42
  /** Service verbs that map one-to-one onto a supervisor operation. */
41
43
  export const SERVICE_ACTIONS = {
42
- start: { run: startService, label: 'started' },
43
- stop: { run: stopService, label: 'stopped' },
44
+ start: { run: resumeService, label: 'started' },
45
+ stop: { run: pauseService, label: 'paused until `agent-tunnel start`' },
44
46
  restart: { run: restartService, label: 'restarted' },
45
47
  uninstall: { run: uninstallService, label: 'removed' },
46
48
  install: { run: installService, label: 'installed' },
@@ -50,6 +52,7 @@ export type ServiceAction = keyof typeof SERVICE_ACTIONS;
50
52
 
51
53
  export function describeService(status: ServiceStatus): string {
52
54
  if (!status.installed) return `${glyph.off} not installed`;
55
+ if (status.enabled === false) return `${c.yellow}○${c.reset} paused ${c.dim}· stays stopped until \`agent-tunnel start\`${c.reset}`;
53
56
  if (status.active) return `${glyph.on} running ${c.dim}· starts at login${c.reset}`;
54
57
  return `${c.yellow}○${c.reset} installed ${c.dim}· stopped${c.reset}`;
55
58
  }
@@ -0,0 +1,164 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test';
2
+
3
+ import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+
7
+ import { pause, renderWindowsTaskXml, serviceDriverFor, supervisor } from './service-drivers';
8
+ import { getServicePaths } from './service-paths';
9
+
10
+ const realRun = supervisor.run;
11
+ const realPause = pause.ms;
12
+ afterEach(() => {
13
+ supervisor.run = realRun;
14
+ pause.ms = realPause;
15
+ });
16
+
17
+ /** Records every supervisor command; `answers` fakes the ones a test cares about. */
18
+ function recordCommands(answers: Record<string, { ok: boolean; detail?: string }> = {}) {
19
+ const calls: string[] = [];
20
+ supervisor.run = (command, args) => {
21
+ const line = [command, ...args].join(' ');
22
+ calls.push(line);
23
+ const hit = Object.entries(answers).find(([prefix]) => line.startsWith(prefix));
24
+ return { ok: hit ? hit[1].ok : true, detail: hit?.[1].detail ?? '' };
25
+ };
26
+ return calls;
27
+ }
28
+
29
+ const paths = getServicePaths('/tmp/agent-tunnel-drivers-test');
30
+
31
+ describe('durable pause (R3)', () => {
32
+ test('launchd: pause disables the job before unloading it, so login does not start it again', () => {
33
+ const calls = recordCommands();
34
+ serviceDriverFor('darwin')!.pause(paths, true);
35
+ const uid = process.getuid!();
36
+ expect(calls).toEqual([
37
+ `launchctl disable gui/${uid}/${paths.label}`,
38
+ `launchctl bootout gui/${uid} ${paths.launchdPlist}`,
39
+ ]);
40
+ });
41
+
42
+ test('launchd: resume enables, loads and starts the job', () => {
43
+ const calls = recordCommands();
44
+ const outcome = serviceDriverFor('darwin')!.resume(paths, true);
45
+ const uid = process.getuid!();
46
+ expect(calls).toEqual([
47
+ `launchctl enable gui/${uid}/${paths.label}`,
48
+ `launchctl bootstrap gui/${uid} ${paths.launchdPlist}`,
49
+ `launchctl kickstart gui/${uid}/${paths.label}`,
50
+ ]);
51
+ expect(outcome.active).toBe(true);
52
+ });
53
+
54
+ test('launchd: status reports a disabled job as paused', () => {
55
+ const uid = process.getuid!();
56
+ recordCommands({
57
+ 'launchctl print-disabled': { ok: true, detail: `disabled services = {\n\t"${paths.label}" => disabled\n}` },
58
+ 'launchctl print ': { ok: false },
59
+ });
60
+ expect(serviceDriverFor('darwin')!.status(paths, false)).toMatchObject({ active: false, enabled: false });
61
+ recordCommands({
62
+ 'launchctl print-disabled': { ok: true, detail: `disabled services = {\n\t"${paths.label}" => enabled\n}` },
63
+ });
64
+ expect(serviceDriverFor('darwin')!.status(paths, false)).toMatchObject({ active: true, enabled: true });
65
+ expect(uid).toBeGreaterThanOrEqual(0);
66
+ });
67
+
68
+ test('systemd: pause is disable --now; resume is enable --now', () => {
69
+ const calls = recordCommands();
70
+ serviceDriverFor('linux')!.pause(paths, true);
71
+ serviceDriverFor('linux')!.resume(paths, true);
72
+ expect(calls).toEqual([
73
+ `systemctl --user disable --now ${paths.label}.service`,
74
+ `systemctl --user enable --now ${paths.label}.service`,
75
+ ]);
76
+ });
77
+
78
+ test('windows: pause disables and ends the task; resume enables and runs it', () => {
79
+ const calls = recordCommands();
80
+ serviceDriverFor('win32')!.pause(paths, true);
81
+ serviceDriverFor('win32')!.resume(paths, true);
82
+ expect(calls).toEqual([
83
+ `schtasks.exe /Change /TN ${paths.label} /DISABLE`,
84
+ `schtasks.exe /End /TN ${paths.label}`,
85
+ `schtasks.exe /Change /TN ${paths.label} /ENABLE`,
86
+ `schtasks.exe /Run /TN ${paths.label}`,
87
+ ]);
88
+ });
89
+ });
90
+
91
+ describe('install really starts the service', () => {
92
+ const runner = { command: '/usr/bin/node', args: ['/opt/kortix/agent-cli.js', 'run', '--service'] };
93
+ function tempPaths() {
94
+ const dir = mkdtempSync(join(tmpdir(), 'agent-tunnel-install-'));
95
+ const base = getServicePaths(dir);
96
+ return {
97
+ dir,
98
+ paths: {
99
+ ...base,
100
+ launchdPlist: join(dir, 'job.plist'),
101
+ systemdUnit: join(dir, 'job.service'),
102
+ windowsScript: join(dir, 'job.ps1'),
103
+ },
104
+ };
105
+ }
106
+
107
+ test('launchd: a bootstrap launchd did not load is retried, and never reported active', () => {
108
+ const { dir, paths } = tempPaths();
109
+ pause.ms = () => {};
110
+ try {
111
+ const calls = recordCommands({ 'launchctl print': { ok: false }, 'launchctl bootstrap': { ok: false, detail: 'Bootstrap failed: 5: Input/output error' } });
112
+ const outcome = serviceDriverFor('darwin')!.install(paths, runner);
113
+ expect(outcome.active).toBeNull();
114
+ expect(calls.filter((c) => c.startsWith('launchctl bootstrap'))).toHaveLength(3);
115
+
116
+ recordCommands();
117
+ expect(serviceDriverFor('darwin')!.install(paths, runner).active).toBe(true);
118
+ } finally {
119
+ rmSync(dir, { recursive: true, force: true });
120
+ }
121
+ });
122
+
123
+ test('systemd: install restarts a running unit so an update rolls the agent, and keeps it past logout', () => {
124
+ const { dir, paths } = tempPaths();
125
+ try {
126
+ const calls = recordCommands({ 'loginctl show-user': { ok: true, detail: 'Linger=no' } });
127
+ const outcome = serviceDriverFor('linux')!.install(paths, runner);
128
+ expect(calls).toContain(`systemctl --user restart ${paths.label}.service`);
129
+ expect(calls.some((c) => c.startsWith('loginctl enable-linger'))).toBe(true);
130
+ expect(outcome.active).toBe(true);
131
+
132
+ recordCommands({ 'loginctl': { ok: false, detail: 'Linger=no' } });
133
+ expect(serviceDriverFor('linux')!.install(paths, runner).detail).toContain('stops when you log out');
134
+ } finally {
135
+ rmSync(dir, { recursive: true, force: true });
136
+ }
137
+ });
138
+
139
+ test('windows: the task is registered from XML without battery or 72 h limits, hidden, and restarted on install', () => {
140
+ const { dir, paths } = tempPaths();
141
+ try {
142
+ const calls = recordCommands();
143
+ serviceDriverFor('win32')!.install(paths, runner);
144
+ expect(calls[0]).toBe(`schtasks.exe /Create /TN ${paths.label} /XML ${join(dir, 'job.xml')} /F`);
145
+ expect(calls.slice(1)).toEqual([`schtasks.exe /End /TN ${paths.label}`, `schtasks.exe /Run /TN ${paths.label}`]);
146
+ const xml = readFileSync(join(dir, 'job.xml')).toString('utf16le');
147
+ expect(xml.charCodeAt(0)).toBe(0xfeff);
148
+ expect(xml).toContain('<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>');
149
+ expect(xml).toContain('<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>');
150
+ expect(xml).toContain('<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>');
151
+ expect(xml).toContain('-WindowStyle Hidden');
152
+ expect(renderWindowsTaskXml(paths, 'DOMAIN\\a&b')).toContain('<UserId>DOMAIN\\a&amp;b</UserId>');
153
+ } finally {
154
+ rmSync(dir, { recursive: true, force: true });
155
+ }
156
+ });
157
+
158
+ test('windows: status reads the non-localized task state', () => {
159
+ recordCommands({ 'powershell.exe': { ok: true, detail: 'Running\r\n' } });
160
+ expect(serviceDriverFor('win32')!.status(paths, false)).toMatchObject({ active: true, enabled: true });
161
+ recordCommands({ 'powershell.exe': { ok: true, detail: 'Disabled' } });
162
+ expect(serviceDriverFor('win32')!.status(paths, false)).toMatchObject({ active: false, enabled: false });
163
+ });
164
+ });