@kortix/agent-tunnel 0.13.52 → 0.13.54

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,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
  }
@@ -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
+ }
@@ -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', () => {
@@ -1,5 +1,5 @@
1
1
  import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'fs';
2
- import { platform } from 'os';
2
+ import { homedir, platform } from 'os';
3
3
  import { join } from 'path';
4
4
 
5
5
  import {
@@ -128,6 +128,25 @@ export function runnerPartsFor(
128
128
  return { command: runtime.execPath, args: [vendor(script, paths), 'run', '--service'], env };
129
129
  }
130
130
 
131
+ /**
132
+ * The Kortix desktop app's own agent runtime, when the app is installed on
133
+ * this Mac. A CLI install runs on it, so the connection is the same agent
134
+ * build with the same bundled driver, and macOS grants Accessibility and
135
+ * Screen Recording to Kortix once for both, never to node or a terminal.
136
+ */
137
+ export function installedAppRunner(
138
+ apps: readonly string[] = ['/Applications/Kortix.app', join(homedir(), 'Applications', 'Kortix.app')],
139
+ os: string = process.platform,
140
+ ): { execPath: string; script: string } | null {
141
+ if (os !== 'darwin') return null;
142
+ for (const app of apps) {
143
+ const execPath = join(app, 'Contents', 'MacOS', 'Kortix');
144
+ const script = join(app, 'Contents', 'Resources', 'agent-tunnel', 'agent-cli.js');
145
+ if (existsSync(execPath) && existsSync(script)) return { execPath, script };
146
+ }
147
+ return null;
148
+ }
149
+
131
150
  /** Where vendorRunner WOULD put the bundle, without copying anything. */
132
151
  const plannedRunner = (script: string, paths: ServicePaths) =>
133
152
  isEphemeralRunnerPath(script) ? paths.vendoredRunner : script;
@@ -135,6 +154,8 @@ const plannedRunner = (script: string, paths: ServicePaths) =>
135
154
  function currentRunnerParts(
136
155
  vendor: (script: string, paths: ServicePaths) => string = vendorRunner,
137
156
  ): RunnerParts {
157
+ const app = process.versions.electron ? null : installedAppRunner();
158
+ if (app) return runnerPartsFor(app.script, { execPath: app.execPath, electron: 'installed-app' }, getServicePaths(), vendor);
138
159
  const script = process.argv[1];
139
160
  if (script && existsSync(script)) return runnerPartsFor(script, undefined, getServicePaths(), vendor);
140
161
  throw new Error(
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
- import { mkdtempSync, readdirSync, rmSync, statSync } from 'node:fs';
2
+ import { mkdtempSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
 
@@ -29,4 +29,14 @@ describe('agent state file', () => {
29
29
  rmSync(join(home, '..'), { recursive: true, force: true });
30
30
  }
31
31
  });
32
+
33
+ test('a writer that died without writing offline reads as offline', () => {
34
+ const home = mkdtempSync(join(tmpdir(), 'agent-tunnel-state-'));
35
+ try {
36
+ writeFileSync(stateFilePath(home), JSON.stringify({ tunnelId: 't', apiUrl: 'u', status: 'online', since: '', agentVersion: '0', pid: 2 ** 22 + 7 }));
37
+ expect(readAgentState(home)?.status).toBe('offline');
38
+ } finally {
39
+ rmSync(home, { recursive: true, force: true });
40
+ }
41
+ });
32
42
  });
@@ -48,10 +48,22 @@ export function writeAgentState(
48
48
  }
49
49
  }
50
50
 
51
+ function isAlive(pid: unknown): boolean {
52
+ if (typeof pid !== 'number' || !Number.isSafeInteger(pid) || pid <= 0) return false;
53
+ try {
54
+ process.kill(pid, 0);
55
+ return true;
56
+ } catch (error) {
57
+ return (error as NodeJS.ErrnoException).code === 'EPERM';
58
+ }
59
+ }
60
+
61
+ /** The last status the agent wrote. A writer that died without writing `offline` reads as offline. */
51
62
  export function readAgentState(home: string = agentTunnelHome()): AgentStateFile | null {
52
63
  try {
53
64
  const parsed = JSON.parse(readFileSync(stateFilePath(home), 'utf8')) as AgentStateFile;
54
- return parsed && typeof parsed === 'object' && typeof parsed.status === 'string' ? parsed : null;
65
+ if (!parsed || typeof parsed !== 'object' || typeof parsed.status !== 'string') return null;
66
+ return parsed.status !== 'offline' && !isAlive(parsed.pid) ? { ...parsed, status: 'offline' } : parsed;
55
67
  } catch {
56
68
  return null;
57
69
  }
package/src/index.ts CHANGED
@@ -9,7 +9,7 @@ export {
9
9
  TunnelErrorCode,
10
10
  TunnelMethods,
11
11
  capabilityForMethod,
12
- desktopFeatureForMethod,
12
+ normalizeDesktopCall,
13
13
  isTunnelCapability,
14
14
  operationForMethod,
15
15
  validateTunnelPermissionScope,
@@ -35,7 +35,7 @@ export type {
35
35
  export { TunnelErrorCode, TunnelMethods } from './types';
36
36
  export {
37
37
  capabilityForMethod,
38
- desktopFeatureForMethod,
38
+ normalizeDesktopCall,
39
39
  isTunnelCapability,
40
40
  operationForMethod,
41
41
  validateTunnelPermissionScope,
@@ -19,54 +19,35 @@ export interface PermissionScopeValidationResult {
19
19
  sanitized?: Record<string, unknown>;
20
20
  }
21
21
 
22
- const DESKTOP_METHOD_FEATURES: Readonly<Record<string, string>> = {
23
- 'desktop.cua.ensure': 'computer_use',
24
- 'desktop.cua.start_daemon': 'computer_use',
25
- 'desktop.cua.status': 'computer_use',
26
- 'desktop.cua.version': 'computer_use',
27
- 'desktop.cua.list_tools': 'computer_use',
28
- 'desktop.cua.describe': 'computer_use',
29
- 'desktop.cua.bring_to_front': 'windows',
30
- 'desktop.cua.check_for_update': 'computer_use',
31
- 'desktop.cua.check_permissions': 'computer_use',
32
- 'desktop.cua.click': 'mouse',
33
- 'desktop.cua.double_click': 'mouse',
34
- 'desktop.cua.drag': 'mouse',
35
- 'desktop.cua.end_session': 'computer_use',
36
- 'desktop.cua.get_accessibility_tree': 'accessibility',
37
- 'desktop.cua.get_agent_cursor_state': 'mouse',
38
- 'desktop.cua.get_config': 'computer_use',
39
- 'desktop.cua.get_cursor_position': 'mouse',
40
- 'desktop.cua.get_recording_state': 'computer_use',
41
- 'desktop.cua.get_screen_size': 'screenshot',
42
- 'desktop.cua.get_window_state': 'accessibility',
43
- 'desktop.cua.hotkey': 'keyboard',
44
- 'desktop.cua.kill_app': 'apps',
45
- 'desktop.cua.launch_app': 'apps',
46
- 'desktop.cua.list_apps': 'apps',
47
- 'desktop.cua.list_windows': 'windows',
48
- 'desktop.cua.move_cursor': 'mouse',
49
- 'desktop.cua.page': 'accessibility',
50
- 'desktop.cua.press_key': 'keyboard',
51
- 'desktop.cua.replay_trajectory': 'computer_use',
52
- 'desktop.cua.right_click': 'mouse',
53
- 'desktop.cua.scroll': 'keyboard',
54
- 'desktop.cua.set_agent_cursor_enabled': 'mouse',
55
- 'desktop.cua.set_agent_cursor_motion': 'mouse',
56
- 'desktop.cua.set_agent_cursor_style': 'mouse',
57
- 'desktop.cua.set_config': 'computer_use',
58
- 'desktop.cua.set_value': 'accessibility',
59
- 'desktop.cua.start_recording': 'screenshot',
60
- 'desktop.cua.install_ffmpeg': 'computer_use',
61
- 'desktop.cua.start_session': 'computer_use',
62
- 'desktop.cua.stop_recording': 'screenshot',
63
- 'desktop.cua.type_text': 'keyboard',
64
- 'desktop.cua.zoom': 'screenshot',
65
- };
22
+ const CUA_PREFIX = 'desktop.cua.';
66
23
 
24
+ /** Every `desktop.cua.<tool>` is the desktop capability: the driver owns the tool list. */
67
25
  export function capabilityForMethod(method: string): TunnelCapability | null {
68
26
  const capability = (TunnelMethods as Readonly<Record<string, TunnelCapability | null>>)[method];
69
- return capability ?? null;
27
+ if (capability !== undefined) return capability;
28
+ return method.startsWith(CUA_PREFIX) && method.length > CUA_PREFIX.length ? 'desktop' : null;
29
+ }
30
+
31
+ /**
32
+ * `desktop.cua.<tool>` → `desktop.cua.call { tool, args }`. Every agent since
33
+ * 0.1.0 serves `desktop.cua.call` for any driver tool, so one wire method
34
+ * reaches every tool the installed driver has, with no per-tool list to keep
35
+ * in sync. Management methods (`ensure`, `status`, `describe`, …) pass through.
36
+ */
37
+ export function normalizeDesktopCall(
38
+ method: string,
39
+ params: Record<string, unknown>,
40
+ ): { method: string; params: Record<string, unknown> } {
41
+ if (!method.startsWith(CUA_PREFIX) || method in TunnelMethods) return { method, params };
42
+ const { permissionId, ...args } = params;
43
+ return {
44
+ method: 'desktop.cua.call',
45
+ params: {
46
+ tool: method.slice(CUA_PREFIX.length),
47
+ args,
48
+ ...(permissionId === undefined ? {} : { permissionId }),
49
+ },
50
+ };
70
51
  }
71
52
 
72
53
  export function isTunnelCapability(value: string): value is TunnelCapability {
@@ -277,16 +258,3 @@ export function operationForMethod(method: string): string {
277
258
  const separator = method.indexOf('.');
278
259
  return separator === -1 ? method : method.slice(separator + 1);
279
260
  }
280
-
281
- export function desktopFeatureForMethod(
282
- method: string,
283
- args: Record<string, unknown> = {},
284
- ): string | undefined {
285
- if (method === 'desktop.cua.call') {
286
- const tool = args.tool;
287
- if (typeof tool !== 'string' || tool.length === 0) return undefined;
288
- const toolMethod = tool.startsWith('desktop.cua.') ? tool : `desktop.cua.${tool}`;
289
- return DESKTOP_METHOD_FEATURES[toolMethod];
290
- }
291
- return DESKTOP_METHOD_FEATURES[method];
292
- }