@kortix/agent-tunnel 0.13.53 → 0.13.55

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.
@@ -72,7 +72,7 @@ describe('local capability permission enforcement', () => {
72
72
  () => '/trusted/cua-driver',
73
73
  );
74
74
  expect(registry.getCapabilityNames()).toEqual(['desktop']);
75
- expect(registry.getHandler('desktop.cua.get_screen_size')).not.toBeNull();
75
+ expect(registry.getHandler('desktop.cua.call')).not.toBeNull();
76
76
  });
77
77
  test('a permission scope cannot widen the local filesystem ceiling', async () => {
78
78
  const handler = createFilesystemCapability(config()).methods.get('fs.read')!;
@@ -154,19 +154,59 @@ describe('local capability permission enforcement', () => {
154
154
  ).rejects.toThrow('not in the allowed commands list');
155
155
  });
156
156
 
157
- test('desktop feature scopes deny before invoking cua-driver', async () => {
158
- const handler = createDesktopCapability().methods.get('desktop.cua.click')!;
159
- await expect(
160
- handler({
161
- x: 10,
162
- y: 10,
163
- __permission: {
164
- permissionId: 'permission-1',
165
- capability: 'desktop',
166
- scope: { features: ['screenshot'] },
167
- },
168
- }),
169
- ).rejects.toThrow('desktop feature "mouse" is not allowed');
157
+ test('a desktop grant allows every driver tool, and a legacy feature scope no longer narrows it', async () => {
158
+ const binary = join(root, 'fake-call-driver');
159
+ const log = join(root, 'call-args.jsonl');
160
+ await writeFile(binary, [
161
+ '#!/usr/bin/env node',
162
+ `require('node:fs').appendFileSync(${JSON.stringify(log)}, JSON.stringify(process.argv.slice(2, 4)) + '\\n');`,
163
+ `process.stdout.write('{"ok":true}');`,
164
+ ].join('\n'));
165
+ await chmod(binary, 0o700);
166
+ const previousBinary = process.env.CUA_DRIVER_BIN;
167
+ process.env.CUA_DRIVER_BIN = binary;
168
+ try {
169
+ const call = createDesktopCapability().methods.get('desktop.cua.call')!;
170
+ const __permission = { permissionId: 'p', capability: 'desktop', scope: { features: ['screenshot'] } };
171
+ // health_report was never in Kortix's tool list; the driver owns that list.
172
+ expect(await call({ tool: 'health_report', args: {}, __permission })).toEqual({ ok: true });
173
+ await expect(call({ tool: 'install_ffmpeg', __permission })).rejects.toThrow('local-only');
174
+ await expect(call({ tool: 'click', __permission: { ...__permission, capability: 'shell' } }))
175
+ .rejects.toThrow('desktop permission required');
176
+ const calls = (await readFile(log, 'utf8')).trim().split('\n').map((line) => JSON.parse(line));
177
+ expect(calls.at(-1)).toEqual(['call', 'health_report']);
178
+ } finally {
179
+ if (previousBinary === undefined) delete process.env.CUA_DRIVER_BIN;
180
+ else process.env.CUA_DRIVER_BIN = previousBinary;
181
+ }
182
+ });
183
+
184
+ test('a failed call reports missing macOS grants as computer_desktop_permission_missing (-32013)', async () => {
185
+ const binary = join(root, 'fake-untrusted-driver');
186
+ await writeFile(binary, [
187
+ '#!/usr/bin/env node',
188
+ 'const [command, tool] = process.argv.slice(2);',
189
+ `if (command === 'call' && tool === 'check_permissions') process.stdout.write('{"accessibility":false,"screen_recording":false}');`,
190
+ `else if (command === 'call') { process.stderr.write('AX is not trusted'); process.exitCode = 1; }`,
191
+ `else process.stdout.write('running');`,
192
+ ].join('\n'));
193
+ await chmod(binary, 0o700);
194
+ const previousBinary = process.env.CUA_DRIVER_BIN;
195
+ process.env.CUA_DRIVER_BIN = binary;
196
+ let restarts = 0;
197
+ try {
198
+ const call = createDesktopCapability({ onPermissionMissing: () => restarts++ }).methods.get('desktop.cua.call')!;
199
+ const error = await call({ tool: 'click', args: { pid: 1 }, __permission: { permissionId: 'p', capability: 'desktop', scope: {} } })
200
+ .catch((err: unknown) => err as Error & { code?: number });
201
+ expect((error as Error).message).toBe(
202
+ 'computer_desktop_permission_missing: macOS has not given CuaDriver Accessibility and Screen Recording on this computer.',
203
+ );
204
+ expect((error as { code?: number }).code).toBe(-32013);
205
+ expect(restarts).toBe(1);
206
+ } finally {
207
+ if (previousBinary === undefined) delete process.env.CUA_DRIVER_BIN;
208
+ else process.env.CUA_DRIVER_BIN = previousBinary;
209
+ }
170
210
  });
171
211
 
172
212
  test('desktop discovery dispatches to driver metadata commands and preserves unsupported output', async () => {
@@ -195,8 +235,6 @@ describe('local capability permission enforcement', () => {
195
235
  scope: { features: ['computer_use'] },
196
236
  };
197
237
  await expect(list({})).rejects.toThrow('desktop permission required');
198
- await expect(list({ __permission: { ...__permission, scope: { features: ['screenshot'] } } }))
199
- .rejects.toThrow('not allowed');
200
238
  expect(await list({ __permission })).toEqual({ tools: 'double_click' });
201
239
  expect(await describeTool({ tool: 'double_click', __permission }))
202
240
  .toEqual({ description: 'Double click coordinates' });
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { spawnSync } from "child_process";
3
- import { mkdtempSync, rmSync } from "fs";
3
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from "fs";
4
4
  import { tmpdir } from "os";
5
5
  import { join, resolve } from "path";
6
6
 
@@ -20,6 +20,8 @@ const DOCUMENTED_SURFACE = [
20
20
  "status",
21
21
  "logs",
22
22
  "logout",
23
+ "access",
24
+ "keep-awake",
23
25
  "--daemon",
24
26
  "--foreground",
25
27
  ] as const;
@@ -84,6 +86,55 @@ describe("agent tunnel service UX", () => {
84
86
  );
85
87
 
86
88
  expect(result.status).toBe(2);
87
- expect(result.stderr).toContain("--keep-awake is not supported");
89
+ expect(result.stderr).toContain("--keep-awake is not a flag. Run: agent-tunnel keep-awake on");
90
+ });
91
+ });
92
+
93
+ describe("access and keep-awake: the one way to change them on this computer", () => {
94
+ const run = (home: string, ...args: string[]) =>
95
+ spawnSync("bun", ["run", CLI_PATH, ...args], {
96
+ encoding: "utf8",
97
+ env: { ...process.env, AGENT_TUNNEL_HOME: home },
98
+ });
99
+
100
+ test("changes access and keep awake, prints the state, and writes access.json", () => {
101
+ const home = mkdtempSync(join(tmpdir(), "agent-tunnel-cli-access-"));
102
+ try {
103
+ const shown = JSON.parse(run(home, "access", "--json").stdout);
104
+ expect(shown).toMatchObject({ mode: "always", keepAwake: false, pendingRequest: null });
105
+
106
+ const ask = run(home, "access", "ask", "--json");
107
+ expect(ask.status).toBe(0);
108
+ expect(JSON.parse(ask.stdout).mode).toBe("ask");
109
+
110
+ const granted = JSON.parse(run(home, "access", "--allow-for", "60", "--json").stdout);
111
+ expect(Date.parse(granted.grantedUntil) - Date.now()).toBeGreaterThan(59 * 60_000);
112
+
113
+ expect(JSON.parse(run(home, "access", "--revoke", "--json").stdout).grantedUntil).toBeNull();
114
+ expect(JSON.parse(run(home, "keep-awake", "on", "--json").stdout).keepAwake).toBe(true);
115
+ expect(JSON.parse(readFileSync(join(home, "access.json"), "utf8"))).toMatchObject({ mode: "ask", keepAwake: true });
116
+
117
+ const status = JSON.parse(run(home, "status", "--json").stdout);
118
+ expect(status.access).toMatchObject({ mode: "ask", keepAwake: true, keepAwakeSupported: process.platform !== "win32" });
119
+ } finally {
120
+ rmSync(home, { recursive: true, force: true });
121
+ }
122
+ });
123
+
124
+ test("refuses a wrong mode or duration with exit 1 and changes nothing", () => {
125
+ const home = mkdtempSync(join(tmpdir(), "agent-tunnel-cli-access-"));
126
+ try {
127
+ const mode = run(home, "access", "sometimes");
128
+ expect(mode.status).toBe(1);
129
+ expect(mode.stderr).toContain("Access is one of: ask, always, off");
130
+ const minutes = run(home, "access", "--allow-for", "soon");
131
+ expect(minutes.status).toBe(1);
132
+ expect(minutes.stderr).toContain("--allow-for takes minutes");
133
+ const awake = run(home, "keep-awake", "maybe");
134
+ expect(awake.status).toBe(1);
135
+ expect(existsSync(join(home, "access.json"))).toBe(false);
136
+ } finally {
137
+ rmSync(home, { recursive: true, force: true });
138
+ }
88
139
  });
89
140
  });
package/src/agent/cli.ts CHANGED
@@ -4,7 +4,17 @@ import { hostname } from 'os';
4
4
  import { join } from 'path';
5
5
 
6
6
  import { PAIR_AGAIN_COMMAND, TunnelAgent } from './agent';
7
- import { accessFilePath, accessRequestPath, desktopAppPath, readAccess, writeAccess } from './access';
7
+ import {
8
+ accessFilePath,
9
+ accessRequestPath,
10
+ accessView,
11
+ applyAccess,
12
+ desktopAppPath,
13
+ readAccess,
14
+ writeAccess,
15
+ type AccessChange,
16
+ type AccessView,
17
+ } from './access';
8
18
  import { printStartupBanner } from './banner';
9
19
  import { createEnabledCapabilityRegistry } from './capabilities/enabled-registry';
10
20
  import { loadConfig, type TunnelConfig } from './config';
@@ -18,12 +28,12 @@ import {
18
28
  requestDeviceAuthorization,
19
29
  } from './device-auth';
20
30
  import { collapseRepeatedLines, isShellStartupNoise } from './log-format';
21
- import { anyFlag, isInteractiveTerminal, isTruthyFlag, promptYesNo } from './prompts';
31
+ import { anyFlag, isInteractiveTerminal, isTruthyFlag } from './prompts';
22
32
  import {
23
- DEFAULT_INSTALL_BACKGROUND_SERVICE,
24
33
  agentTunnelHome,
25
34
  getServicePaths,
26
35
  getServiceStatus,
36
+ installedAppRunner,
27
37
  rotateServiceLogs,
28
38
  serviceLogFiles,
29
39
  } from './service';
@@ -34,6 +44,7 @@ import {
34
44
  describeService,
35
45
  renderServiceAction,
36
46
  } from './service-control';
47
+ import { watchForRestart } from './self-restart';
37
48
  import { readAgentState, writeAgentState } from './state-file';
38
49
  import { blankLine, c, clearScreen, field, glyph, stripAnsi } from './terminal';
39
50
  import { agentTunnelVersion } from './version';
@@ -43,16 +54,22 @@ const BACKGROUND_FLAGS = ['daemon', 'service', 'background', 'always-online'] as
43
54
  const FOREGROUND_FLAGS = ['foreground', 'no-daemon', 'no-service', 'no-background'] as const;
44
55
  const DEFAULT_LOG_LINES = 60;
45
56
 
57
+ /** `_` holds the words after the command (`access always` → `_: 'always'`). */
46
58
  type Flags = Record<string, string>;
47
59
 
48
60
  function parseArgs(argv: string[]): { command: string; flags: Flags } {
49
61
  const flags: Flags = {};
62
+ const words: string[] = [];
50
63
  for (let i = 3; i < argv.length; i++) {
51
64
  const arg = argv[i];
52
- if (!arg.startsWith('--')) continue;
65
+ if (!arg.startsWith('--')) {
66
+ words.push(arg);
67
+ continue;
68
+ }
53
69
  const next = argv[i + 1];
54
70
  flags[arg.slice(2)] = next && !next.startsWith('--') ? argv[++i] : 'true';
55
71
  }
72
+ if (words.length > 0) flags._ = words.join(' ');
56
73
  return { command: argv[2] || 'help', flags };
57
74
  }
58
75
 
@@ -90,7 +107,17 @@ function startAgent(config: TunnelConfig, options: { service?: boolean } = {}):
90
107
  process.stdout.write = process.stderr.write.bind(process.stderr) as typeof process.stdout.write;
91
108
  options = { ...options, service: true };
92
109
  }
93
- const registry = createEnabledCapabilityRegistry(config);
110
+ let agent: TunnelAgent | null = null;
111
+ const restart = options.service
112
+ ? watchForRestart((reason) => {
113
+ process.stdout.write(`[agent-tunnel] restarting: ${reason}\n`);
114
+ agent?.disconnect();
115
+ process.exit(0);
116
+ })
117
+ : null;
118
+ const registry = createEnabledCapabilityRegistry(config, undefined, {
119
+ onPermissionMissing: restart?.permissionMissing,
120
+ });
94
121
  if (config.enabledCapabilities?.includes('desktop') && !registry.has('desktop')) {
95
122
  console.error(
96
123
  '[agent-tunnel] Computer Use is approved but unavailable: install the trusted cua-driver locally, then restart Agent Tunnel.',
@@ -111,7 +138,7 @@ function startAgent(config: TunnelConfig, options: { service?: boolean } = {}):
111
138
 
112
139
  // A service never stops on its own (R2): a refused credential waits in
113
140
  // `rejected` and re-reads config.json, so pairing again heals it.
114
- const agent = new TunnelAgent(
141
+ agent = new TunnelAgent(
115
142
  config,
116
143
  registry,
117
144
  // The agent passes the credential it uses NOW: a re-pair picked up by a
@@ -126,7 +153,7 @@ function startAgent(config: TunnelConfig, options: { service?: boolean } = {}):
126
153
 
127
154
  const shutdown = () => {
128
155
  if (!options.service) console.log(`\n${c.dim} Shutting down…${c.reset}`);
129
- agent.disconnect();
156
+ agent?.disconnect();
130
157
  process.exit(0);
131
158
  };
132
159
  process.on('SIGTERM', shutdown);
@@ -135,26 +162,38 @@ function startAgent(config: TunnelConfig, options: { service?: boolean } = {}):
135
162
 
136
163
  // ── pairing ──────────────────────────────────────────────────────────────────
137
164
 
138
- async function chooseBackgroundMode(flags: Flags): Promise<boolean> {
165
+ /**
166
+ * A person at a terminal gets what the desktop app gives: a background
167
+ * connection, no question asked, and the exact commands that pause, resume,
168
+ * and remove it. `--foreground` keeps it in this terminal only. A script
169
+ * (no TTY) or `--json` keeps the foreground default it always had.
170
+ */
171
+ function chooseBackgroundMode(flags: Flags): boolean {
139
172
  if (anyFlag(flags, BACKGROUND_FLAGS)) return true;
140
173
  if (anyFlag(flags, FOREGROUND_FLAGS)) return false;
141
- if (jsonMode || !isInteractiveTerminal()) return false;
174
+ return !jsonMode && isInteractiveTerminal();
175
+ }
142
176
 
177
+ function printServiceControls(): void {
178
+ const npx = 'npx @kortix/agent-tunnel@latest';
179
+ console.log(` ${c.dim}Runs in the background: starts at login, restarts after failures, keeps running after this terminal closes.${c.reset}`);
180
+ if (installedAppRunner()) {
181
+ console.log(` ${c.dim}It runs on the Kortix app on this Mac: same connection, same macOS permissions. Control it from Your computer in the app.${c.reset}`);
182
+ }
143
183
  blankLine();
144
- console.log(` ${glyph.warn} ${c.bold}Security note${c.reset}`);
145
- console.log(` ${c.dim}Background mode starts at login, continues after this terminal closes, and restarts after failures.${c.reset}`);
146
- console.log(` ${c.dim}The computer must remain powered on, awake, and connected to the internet.${c.reset}`);
184
+ field('Pause', `${c.white}${npx} stop${c.reset}`);
185
+ field('Resume', `${c.white}${npx} start${c.reset}`);
186
+ field('Disconnect', `${c.white}${npx} logout${c.reset}`);
147
187
  blankLine();
148
-
149
- return promptYesNo(' Install the background service now?', DEFAULT_INSTALL_BACKGROUND_SERVICE);
150
188
  }
151
189
 
152
190
  /** Starts the agent the way the caller asked for, and returns. */
153
191
  async function launch(config: TunnelConfig, flags: Flags, lease?: { serviceWasActive: boolean }): Promise<void> {
154
- if (await chooseBackgroundMode(flags)) {
192
+ if (chooseBackgroundMode(flags)) {
155
193
  saveCredentials(config.tunnelId, config.token, config.apiUrl);
156
194
  if (!jsonMode) {
157
195
  renderServiceAction('install', SERVICE_ACTIONS.install.run());
196
+ printServiceControls();
158
197
  return;
159
198
  }
160
199
  // Return instead of process.exit(): an exit can cut off a piped stdout.
@@ -408,7 +447,7 @@ function commandStatus(flags: Flags): void {
408
447
  serviceLabel: getServicePaths().label,
409
448
  service,
410
449
  state: readAgentState(),
411
- access: readAccess(),
450
+ access: accessView(),
412
451
  lastActivity: lastServiceActivity(),
413
452
  }, null, 2));
414
453
  return;
@@ -566,6 +605,69 @@ function commandServiceAction(action: ServiceAction, flags: Flags): void {
566
605
  renderServiceAction(action, SERVICE_ACTIONS[action].run());
567
606
  }
568
607
 
608
+ /** "Ask each time · allowed until 14:05", for a person at a terminal. */
609
+ function describeAccess(view: AccessView): string {
610
+ const clock = (iso: string) => new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
611
+ if (view.mode === 'always') return `${c.white}Always allowed${c.reset}`;
612
+ if (view.mode === 'off') return `${c.white}Off${c.reset} ${c.dim}(every agent call is refused)${c.reset}`;
613
+ const now = view.grantedUntil
614
+ ? `allowed until ${clock(view.grantedUntil)}`
615
+ : view.deniedUntil
616
+ ? `denied until ${clock(view.deniedUntil)}`
617
+ : 'asks before the next call';
618
+ return `${c.white}Ask each time${c.reset} ${c.dim}· ${now}${c.reset}`;
619
+ }
620
+
621
+ function printAccess(view: AccessView, flags: Flags): void {
622
+ if (isTruthyFlag(flags.json)) {
623
+ console.log(JSON.stringify(view, null, 2));
624
+ return;
625
+ }
626
+ const npx = 'npx @kortix/agent-tunnel@latest';
627
+ blankLine();
628
+ field('access', describeAccess(view));
629
+ field('keep awake', view.keepAwakeSupported ? (view.keepAwake ? `${c.white}on${c.reset}` : `${c.white}off${c.reset}`) : `${c.dim}not available on this system${c.reset}`);
630
+ if (view.pendingRequest) {
631
+ field('waiting', `${c.white}${view.pendingRequest.change ? 'a change asked for through Kortix' : `an agent call (${view.pendingRequest.capability})`}${c.reset}`);
632
+ field('', `${c.dim}${npx} access --allow-for 60 or --deny${c.reset}`);
633
+ }
634
+ blankLine();
635
+ console.log(` ${c.dim}Change it:${c.reset} ${c.white}${npx} access ask|always|off${c.reset}${c.dim}, --allow-for <minutes>, --revoke, --deny${c.reset}`);
636
+ blankLine();
637
+ }
638
+
639
+ /**
640
+ * `access [ask|always|off] [--allow-for <minutes>] [--revoke] [--deny]`: when
641
+ * Kortix may use this computer. The person at this machine is its owner, so
642
+ * this widens access too; the desktop app runs this command after its own
643
+ * confirmation. No change prints the current state.
644
+ */
645
+ function commandAccess(flags: Flags): void {
646
+ const change: AccessChange = {};
647
+ if (flags._) change.mode = flags._ as AccessChange['mode'];
648
+ if (flags['allow-for'] !== undefined) {
649
+ change.grantMinutes = Number(flags['allow-for']);
650
+ if (!(change.grantMinutes > 0)) fail('--allow-for takes minutes, at most 1440. Example: agent-tunnel access --allow-for 60');
651
+ }
652
+ if (isTruthyFlag(flags.revoke)) change.revoke = true;
653
+ if (isTruthyFlag(flags.deny)) change.deny = true;
654
+ if (Object.keys(change).length === 0) return printAccess(accessView(), flags);
655
+ if (change.mode !== undefined && !['ask', 'always', 'off'].includes(change.mode)) {
656
+ fail('Access is one of: ask, always, off. Example: agent-tunnel access ask');
657
+ }
658
+ // A grant applies in "Ask each time"; from another mode it means "ask, allowed for now".
659
+ if (change.grantMinutes !== undefined && change.mode === undefined && readAccess().mode !== 'ask') change.mode = 'ask';
660
+ printAccess(applyAccess(change), flags);
661
+ }
662
+
663
+ /** `keep-awake [on|off]`: hold off system sleep while the agent runs (macOS, Linux). */
664
+ function commandKeepAwake(flags: Flags): void {
665
+ if (flags._ === undefined) return printAccess(accessView(), flags);
666
+ if (flags._ !== 'on' && flags._ !== 'off') fail('Keep awake is on or off. Example: agent-tunnel keep-awake on');
667
+ if (!accessView().keepAwakeSupported) fail('Keep awake is not available on this system yet.');
668
+ printAccess(applyAccess({ keepAwake: flags._ === 'on' }), flags);
669
+ }
670
+
569
671
  // ── dispatch ─────────────────────────────────────────────────────────────────
570
672
 
571
673
  interface Command {
@@ -581,6 +683,11 @@ const COMMANDS: Record<string, Command> = {
581
683
  run: commandConnect,
582
684
  },
583
685
  status: { summary: 'Show pairing, capabilities, and service state (--json)', run: commandStatus },
686
+ access: {
687
+ summary: 'When Kortix may use this computer: ask, always, off (--allow-for <min>, --revoke, --deny, --json)',
688
+ run: commandAccess,
689
+ },
690
+ 'keep-awake': { summary: 'Keep this computer awake while connected: on, off (--json)', run: commandKeepAwake },
584
691
  logs: { summary: 'Show recent service logs (--lines <n>, --all, --clear)', run: commandLogs },
585
692
  start: {
586
693
  summary: 'Start the background service (resumes a paused one)',
@@ -620,7 +727,7 @@ const OPTIONS: ReadonlyArray<readonly [string, string]> = [
620
727
  ['--reauth', 'With connect: discard the saved credential and pair again'],
621
728
  ['--daemon / --foreground', 'With connect: skip the prompt and choose the mode'],
622
729
  ['--project-id <uuid>', 'With connect: offer "Also share with <project>" on the approval page; the computer is yours in every project either way'],
623
- ['--json', 'With connect: NDJSON events, no prompts. With status and logout: machine-readable output'],
730
+ ['--json', 'With connect: NDJSON events, no prompts. With status, access, keep-awake, and logout: machine-readable output'],
624
731
  ['--keep-service', 'With logout: keep the background service installed'],
625
732
  ];
626
733
 
@@ -658,7 +765,7 @@ const { command, flags } = parseArgs(process.argv);
658
765
  jsonMode = command === 'connect' && isTruthyFlag(flags.json);
659
766
 
660
767
  if (Object.prototype.hasOwnProperty.call(flags, 'keep-awake')) {
661
- console.error(` ${glyph.bad} --keep-awake is not supported. Configure sleep behavior in the operating system.`);
768
+ console.error(` ${glyph.bad} --keep-awake is not a flag. Run: agent-tunnel keep-awake on`);
662
769
  process.exit(2);
663
770
  }
664
771
 
@@ -669,7 +776,8 @@ const resolved =
669
776
  if (!resolved) {
670
777
  showHelp();
671
778
  } else {
672
- void Promise.resolve(resolved.run(flags)).catch((error: unknown) => {
779
+ // `.then`: a command that throws synchronously reports like one that rejects.
780
+ void Promise.resolve().then(() => resolved.run(flags)).catch((error: unknown) => {
673
781
  fail(error instanceof Error ? error.message : String(error));
674
782
  });
675
783
  }
@@ -1,5 +1,5 @@
1
1
  import '../node-ws-polyfill';
2
- import { AGENT_REPLACED_CLOSE_CODE, AGENT_VERSION, isCredentialRejection } from './agent';
2
+ import { AGENT_REPLACED_CLOSE_CODE, AGENT_VERSION, isCredentialRejection, openRelaySocket } from './agent';
3
3
  import { buildTunnelWsUrl, trustedCredential, type TunnelConfig } from './config';
4
4
 
5
5
  /**
@@ -36,18 +36,18 @@ export function probeCredentials(
36
36
  return new Promise<CredentialProbeResult>((resolve) => {
37
37
  let socket: WebSocket;
38
38
  try {
39
- socket = new WebSocket(new URL(buildTunnelWsUrl(config)));
39
+ socket = openRelaySocket(new URL(buildTunnelWsUrl(config)).href);
40
40
  } catch {
41
41
  resolve('unreachable');
42
42
  return;
43
43
  }
44
44
 
45
45
  // resolve() is idempotent, so the first verdict wins and later events are
46
- // harmless no-ops. That removes any need to track settled state by hand.
46
+ // harmless no-ops. Resolve before close(): Bun fires 'close' inside it.
47
47
  const settle = (result: CredentialProbeResult): void => {
48
48
  clearTimeout(timer);
49
- try { socket.close(1000, 'probe complete'); } catch {}
50
49
  resolve(result);
50
+ try { socket.close(1000, 'probe complete'); } catch {}
51
51
  };
52
52
 
53
53
  const timer = setTimeout(() => settle('unreachable'), options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS);
@@ -0,0 +1,24 @@
1
+ import { expect, test } from 'bun:test';
2
+ import { mkdtempSync, renameSync, rmSync, writeFileSync } from 'fs';
3
+ import { tmpdir } from 'os';
4
+ import { join } from 'path';
5
+
6
+ import { runtimeFingerprint } from './self-restart';
7
+
8
+ test('an update that swaps a runtime file in place changes the fingerprint', () => {
9
+ const dir = mkdtempSync(join(tmpdir(), 'self-restart-'));
10
+ try {
11
+ const binary = join(dir, 'Kortix');
12
+ writeFileSync(binary, 'v1');
13
+ const started = runtimeFingerprint([binary]);
14
+ expect(runtimeFingerprint([binary])).toBe(started);
15
+ // An app updater writes the new bundle beside the old one and renames it over.
16
+ writeFileSync(join(dir, 'next'), 'v1');
17
+ renameSync(join(dir, 'next'), binary);
18
+ expect(runtimeFingerprint([binary])).not.toBe(started);
19
+ rmSync(binary);
20
+ expect(runtimeFingerprint([binary])).toBe('missing');
21
+ } finally {
22
+ rmSync(dir, { recursive: true, force: true });
23
+ }
24
+ });
@@ -0,0 +1,47 @@
1
+ import { statSync } from 'fs';
2
+
3
+ /** Identity of the files this process runs; a replaced file changes it. */
4
+ export function runtimeFingerprint(files: readonly string[]): string {
5
+ return files
6
+ .map((file) => {
7
+ try {
8
+ const stat = statSync(file);
9
+ return `${stat.ino}:${stat.mtimeMs}:${stat.size}`;
10
+ } catch {
11
+ return 'missing';
12
+ }
13
+ })
14
+ .join('|');
15
+ }
16
+
17
+ /** A macOS grant refused this long after start is not a grant this process missed. */
18
+ const PERMISSION_RESTART_MIN_UPTIME_S = 5 * 60;
19
+
20
+ /**
21
+ * A background service must not outlive its own code or its macOS grants:
22
+ *
23
+ * - An app update replaces the binary under the running agent. macOS then
24
+ * cannot validate the old process's signature and refuses it Accessibility
25
+ * and Screen Recording, while the updated app itself shows them allowed.
26
+ * - A grant made in System Settings applies to processes started after it.
27
+ *
28
+ * Both are healed by exiting: the supervisor (launchd KeepAlive, systemd
29
+ * Restart=always, the Windows loop) starts the service again on the current
30
+ * code with the current grants.
31
+ */
32
+ export function watchForRestart(exit: (reason: string) => void, intervalMs = 60_000): {
33
+ permissionMissing: () => void;
34
+ } {
35
+ const files = [process.execPath, process.argv[1]].filter((file): file is string => Boolean(file));
36
+ const started = runtimeFingerprint(files);
37
+ setInterval(() => {
38
+ if (runtimeFingerprint(files) !== started) exit('its code was updated');
39
+ }, intervalMs).unref();
40
+ return {
41
+ permissionMissing: () => {
42
+ if (process.uptime() < PERMISSION_RESTART_MIN_UPTIME_S) return;
43
+ // After the refusal is sent: the caller gets the actionable error first.
44
+ setTimeout(() => exit('macOS refused a desktop permission'), 1_000).unref();
45
+ },
46
+ };
47
+ }
@@ -13,6 +13,7 @@ import {
13
13
  rotateServiceLogs,
14
14
  } from './service';
15
15
  import { collapseRepeatedLines } from './log-format';
16
+ import { AGENT_VERSION } from './agent';
16
17
  import { probeCredentials } from './credential-probe';
17
18
 
18
19
  const CLI_PATH = resolve(import.meta.dir, 'cli.ts');
@@ -104,6 +105,51 @@ describe('service log hygiene', () => {
104
105
  });
105
106
 
106
107
  describe('credential probe and the single-agent rule', () => {
108
+ test('every relay socket opens through openRelaySocket', () => {
109
+ const src = resolve(import.meta.dir, '..');
110
+ const raw: string[] = [];
111
+ for (const file of new Bun.Glob('**/*.ts').scanSync(src)) {
112
+ if (file.endsWith('.test.ts')) continue;
113
+ readFileSync(join(src, file), 'utf8').split('\n').forEach((line, i) => {
114
+ if (/\bnew WebSocket\(/.test(line) && !/^\s*(\/\/|\*)/.test(line)) raw.push(`${file}:${i + 1}`);
115
+ });
116
+ }
117
+ expect(raw).toEqual([]);
118
+ });
119
+
120
+ test('auth_ok is valid, though Bun fires close inside socket.close()', async () => {
121
+ let userAgent: string | null = null as string | null;
122
+ const server = Bun.serve({
123
+ port: 0,
124
+ fetch(request, server) {
125
+ // Cloudflare refuses an upgrade without a User-Agent, and Bun sends none by default.
126
+ userAgent = request.headers.get('user-agent');
127
+ return server.upgrade(request) ? undefined : new Response('no upgrade', { status: 400 });
128
+ },
129
+ websocket: {
130
+ message(ws) {
131
+ ws.send(JSON.stringify({ type: 'auth_ok' }));
132
+ },
133
+ },
134
+ });
135
+
136
+ try {
137
+ const result = await probeCredentials(
138
+ {
139
+ apiUrl: `http://127.0.0.1:${server.port}/v1/tunnel`,
140
+ tunnelId: '00000000-0000-4000-8000-000000000001',
141
+ token: 'kortix_tnl_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
142
+ wsPath: '/ws',
143
+ } as never,
144
+ { timeoutMs: 5_000 },
145
+ );
146
+ expect(result).toBe('valid');
147
+ expect(userAgent).toBe(`kortix-agent-tunnel/${AGENT_VERSION}`);
148
+ } finally {
149
+ server.stop(true);
150
+ }
151
+ }, 30_000);
152
+
107
153
  test('treats being replaced as proof the credential is valid', async () => {
108
154
  // The relay only replaces a socket it already registered, and registration
109
155
  // happens after a successful handshake.
@@ -10,6 +10,7 @@ import {
10
10
  agentTunnelHome,
11
11
  buildServiceShellCommand,
12
12
  getServicePaths,
13
+ installedAppRunner,
13
14
  runnerPartsFor,
14
15
  serviceLabelFor,
15
16
  isEphemeralRunnerPath,
@@ -28,7 +29,32 @@ describe('agent tunnel service definitions', () => {
28
29
  const command = buildServiceShellCommand();
29
30
  expect(command).toContain("'run'");
30
31
  expect(command).toContain("'--service'");
31
- expect(command).toStartWith('exec ');
32
+ // `export …;` first when it runs on the installed Kortix app (ELECTRON_RUN_AS_NODE).
33
+ expect(command).toMatch(/^(export [^;]+; )?exec /);
34
+ });
35
+
36
+ test('a CLI install on a Mac with the Kortix app runs on the app runtime', () => {
37
+ const root = mkdtempSync(join(tmpdir(), 'kortix-app-'));
38
+ try {
39
+ const app = join(root, 'Kortix.app');
40
+ expect(installedAppRunner([app], 'darwin')).toBeNull();
41
+ mkdirSync(join(app, 'Contents', 'MacOS'), { recursive: true });
42
+ mkdirSync(join(app, 'Contents', 'Resources', 'agent-tunnel'), { recursive: true });
43
+ writeFileSync(join(app, 'Contents', 'MacOS', 'Kortix'), '');
44
+ writeFileSync(join(app, 'Contents', 'Resources', 'agent-tunnel', 'agent-cli.js'), '');
45
+ const runner = installedAppRunner([join(root, 'Missing.app'), app], 'darwin')!;
46
+ expect(runner).toEqual({
47
+ execPath: join(app, 'Contents', 'MacOS', 'Kortix'),
48
+ script: join(app, 'Contents', 'Resources', 'agent-tunnel', 'agent-cli.js'),
49
+ });
50
+ expect(installedAppRunner([app], 'linux')).toBeNull();
51
+ const parts = runnerPartsFor(runner.script, { execPath: runner.execPath, electron: 'installed-app' });
52
+ expect(parts.command).toBe(runner.execPath);
53
+ expect(parts.args).toEqual([runner.script, 'run', '--service']);
54
+ expect(parts.env?.ELECTRON_RUN_AS_NODE).toBe('1');
55
+ } finally {
56
+ rmSync(root, { recursive: true, force: true });
57
+ }
32
58
  });
33
59
 
34
60
  test('launchd plist restarts and runs at login', () => {