@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.
@@ -3,17 +3,25 @@ import { hostname, platform, arch, release } from 'os';
3
3
  import { buildTunnelWsUrl, trustedCredential, type TunnelConfig } from './config';
4
4
  import {
5
5
  ACCESS_HOLD_MS,
6
+ accessCommand,
7
+ accessView,
8
+ accessWidens,
9
+ applyAccess,
6
10
  clearAccessRequest,
7
11
  decideAccess,
8
12
  keepAwakeCommand,
13
+ nextAccess,
14
+ parseAccessChange,
9
15
  readAccess,
10
16
  readAccessRequest,
11
17
  wakeDesktopApp,
12
18
  writeAccessRequest,
19
+ type AccessChange,
20
+ type AccessView,
13
21
  } from './access';
14
22
  import { agentTunnelHome } from './service-paths';
15
23
  import { machineDisplayName, machineId } from './device-auth';
16
- import { capabilityForMethod } from '../shared/permissions';
24
+ import { capabilityForMethod, normalizeDesktopCall } from '../shared/permissions';
17
25
  import { TunnelErrorCode } from '../shared/types';
18
26
  import { agentTunnelVersion } from './version';
19
27
  import { c } from './terminal';
@@ -100,6 +108,8 @@ export interface TunnelAgentOptions {
100
108
  /** The relay pings every 30 s; 75 s of silence means the socket is dead (R1). */
101
109
  const LIVENESS_TIMEOUT_MS = 75_000;
102
110
  const WATCHDOG_INTERVAL_MS = 5_000;
111
+ /** The macOS permission check runs every 12 watchdog ticks (60 s). */
112
+ const DESKTOP_PROBE_EVERY_TICKS = 12;
103
113
  /** A tick that arrives this much later than scheduled means the machine slept. */
104
114
  const CLOCK_JUMP_MS = 30_000;
105
115
  const REJECTED_RETRY_MS = 5 * 60_000;
@@ -173,6 +183,9 @@ export class TunnelAgent {
173
183
  private standbyAttempts = 0;
174
184
  private lastRejectedLogAt = 0;
175
185
  private sentAccessKey: string | null = null;
186
+ private desktopPermissions: { accessibility: boolean; screenRecording: boolean } | null = null;
187
+ private probingDesktop = false;
188
+ private watchdogTicks = 0;
176
189
  private keepAwake: ChildProcess | null = null;
177
190
  private keepAwakeFailed = false;
178
191
  private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
@@ -282,6 +295,8 @@ export class TunnelAgent {
282
295
  capabilities: this.registry.getCapabilityNames(),
283
296
  agentVersion: AGENT_VERSION,
284
297
  reportsAccess: true,
298
+ // Serves `tunnel.access.set`: Kortix may change access through this agent.
299
+ controlsAccess: true,
285
300
  });
286
301
  });
287
302
 
@@ -395,6 +410,7 @@ export class TunnelAgent {
395
410
  this.standbyAttempts = 0;
396
411
  this.setStatus('online');
397
412
  this.reportAccess();
413
+ void this.probeDesktopPermissions();
398
414
  if (this.stableConnectionTimer) clearTimeout(this.stableConnectionTimer);
399
415
  this.stableConnectionTimer = setTimeout(() => {
400
416
  this.reconnectAttempts = 0;
@@ -456,6 +472,14 @@ export class TunnelAgent {
456
472
  return;
457
473
  }
458
474
 
475
+ // ── The owner's access change, relayed by Kortix ────────────────
476
+ // Before the permission guard and the access gate: it must work while
477
+ // access is off, and it is the owner's own action, not an agent call.
478
+ if ('id' in msg && msg.id && msg.method === 'tunnel.access.set') {
479
+ await this.handleAccessChange(msg as JsonRpcRequest);
480
+ return;
481
+ }
482
+
459
483
  // ── RPC request dispatch ────────────────────────────────────────
460
484
  if ('id' in msg && msg.id) {
461
485
  await this.handleRpcRequest(msg as JsonRpcRequest);
@@ -493,7 +517,9 @@ export class TunnelAgent {
493
517
  }
494
518
 
495
519
  private async handleRpcRequest(request: JsonRpcRequest): Promise<void> {
496
- const { id, method, params = {} } = request;
520
+ // An older API may still send `desktop.cua.<tool>`: serve it as `desktop.cua.call`.
521
+ const { method, params } = normalizeDesktopCall(request.method, request.params ?? {});
522
+ const { id } = request;
497
523
 
498
524
  const permissionId = params.permissionId as string | undefined;
499
525
  const permission = this.permissionGuard.getPermissionForMethod(permissionId, method);
@@ -522,7 +548,9 @@ export class TunnelAgent {
522
548
  this.sendSignedResult(id, result);
523
549
  } catch (err) {
524
550
  const message = err instanceof Error ? err.message : String(err);
525
- this.sendSignedError(id, -32003, message);
551
+ const code = (err as { code?: unknown } | null)?.code;
552
+ // Only a JSON-RPC server code is binding; an exit status or errno is not.
553
+ this.sendSignedError(id, typeof code === 'number' && code <= -32000 && code >= -32099 ? code : -32003, message);
526
554
  }
527
555
  }
528
556
 
@@ -621,10 +649,12 @@ export class TunnelAgent {
621
649
  // Concurrent calls share one pending request: one prompt, one app launch.
622
650
  const pending = readAccessRequest(this.home);
623
651
  if (pending && Date.now() - Date.parse(pending.requestedAt) < hold) {
624
- requestId = pending.id;
652
+ // A relayed change waiting for the owner stays: if it is allowed, this call runs too.
653
+ requestId = pending.change ? null : pending.id;
625
654
  } else {
626
655
  requestId = writeAccessRequest({ capability: capabilityForMethod(method) ?? method, method }, this.home).id;
627
656
  wakeDesktopApp(this.home);
657
+ this.reportAccess();
628
658
  }
629
659
  } catch {
630
660
  // Without a request file nobody can answer; the hold simply runs out.
@@ -641,16 +671,104 @@ export class TunnelAgent {
641
671
  return ACCESS_ERRORS.pending;
642
672
  }
643
673
 
644
- /** X2: optional signed notification; relays that do not know it ignore it. */
674
+ /**
675
+ * `tunnel.access.set`: Kortix relays the owner's change. A narrowing applies
676
+ * at once. A widening (`always`, a longer grant) needs the owner on this
677
+ * machine: it becomes a request the desktop app shows as a native prompt,
678
+ * and the call waits for the answer. Without the app, the owner runs the
679
+ * command in a terminal.
680
+ */
681
+ private async handleAccessChange(request: JsonRpcRequest): Promise<void> {
682
+ let change: AccessChange;
683
+ try {
684
+ change = parseAccessChange(request.params);
685
+ } catch (err) {
686
+ this.sendSignedError(request.id, TunnelErrorCode.LOCAL_ERROR, err instanceof Error ? err.message : String(err));
687
+ return;
688
+ }
689
+ try {
690
+ const view = await this.changeAccess(change);
691
+ this.reportAccess();
692
+ this.sendSignedResult(request.id, view);
693
+ } catch (err) {
694
+ this.reportAccess();
695
+ this.sendSignedError(request.id, TunnelErrorCode.ACCESS_UNCONFIRMED, err instanceof Error ? err.message : String(err));
696
+ }
697
+ }
698
+
699
+ private async changeAccess(change: AccessChange): Promise<AccessView> {
700
+ const current = readAccess(this.home);
701
+ const target = nextAccess(current, change, this.now());
702
+ if (!accessWidens(current, target, this.now())) return applyAccess(change, this.home, this.now());
703
+
704
+ const unconfirmed = (why: string) => new Error(`computer_access_unconfirmed: ${why}`);
705
+ const asked = writeAccessRequest({ capability: 'access', method: 'tunnel.access.set', change }, this.home);
706
+ this.reportAccess();
707
+ if (wakeDesktopApp(this.home) === 'no-app') {
708
+ clearAccessRequest(asked.id, this.home);
709
+ throw unconfirmed(`Only the owner can allow more access, on the computer itself. Run ${accessCommand(change)} there.`);
710
+ }
711
+ const hold = this.options.accessHoldMs ?? ACCESS_HOLD_MS;
712
+ const deadline = Date.now() + hold;
713
+ while (Date.now() < deadline) {
714
+ await new Promise((resolve) => setTimeout(resolve, Math.min(100, hold / 4)));
715
+ // Reached: the owner allowed it (the app ran the command).
716
+ if (!accessWidens(readAccess(this.home), target, this.now())) {
717
+ clearAccessRequest(asked.id, this.home);
718
+ return accessView(this.home, platform(), this.now());
719
+ }
720
+ if (readAccessRequest(this.home)?.id !== asked.id) throw unconfirmed('The owner declined on the computer.');
721
+ }
722
+ clearAccessRequest(asked.id, this.home);
723
+ throw unconfirmed(`Nobody confirmed on the computer within ${Math.round(hold / 1000)} seconds.`);
724
+ }
725
+
726
+ /**
727
+ * X2: the machine's own state, signed, whenever it changes: access, keep
728
+ * awake, the request waiting for the owner, and the macOS permissions as
729
+ * this process holds them. Kortix shows exactly this; relays that do not
730
+ * know a field ignore it.
731
+ */
645
732
  private reportAccess(): void {
646
- const access = readAccess(this.home);
647
- const { mode } = access;
648
- // A lapsed grant is no grant: the watchdog reports again when it expires.
649
- const grantedUntil = access.grantedUntil && Date.parse(access.grantedUntil) > this.now() ? access.grantedUntil : null;
650
- const key = `${mode}|${grantedUntil}`;
651
- if (key === this.sentAccessKey || this.status !== 'online') return;
733
+ if (this.status !== 'online') return;
734
+ const view = accessView(this.home, platform(), this.now());
735
+ const params = {
736
+ mode: view.mode,
737
+ grantedUntil: view.grantedUntil,
738
+ deniedUntil: view.deniedUntil,
739
+ keepAwake: view.keepAwake,
740
+ keepAwakeSupported: view.keepAwakeSupported,
741
+ pendingRequest: view.pendingRequest
742
+ ? { capability: view.pendingRequest.capability, requestedAt: view.pendingRequest.requestedAt, ...(view.pendingRequest.change ? { change: view.pendingRequest.change } : {}) }
743
+ : null,
744
+ desktopPermissions: this.desktopPermissions,
745
+ };
746
+ const key = JSON.stringify(params);
747
+ if (key === this.sentAccessKey) return;
652
748
  this.sentAccessKey = key;
653
- this.sendSigned({ jsonrpc: '2.0', method: 'tunnel.access.state', params: { mode, grantedUntil } });
749
+ this.sendSigned({ jsonrpc: '2.0', method: 'tunnel.access.state', params });
750
+ }
751
+
752
+ /**
753
+ * Asks the desktop driver whether macOS lets THIS process see and drive the
754
+ * screen. The answer for another process (the app window) can differ.
755
+ */
756
+ private async probeDesktopPermissions(): Promise<void> {
757
+ const call = this.registry.getHandler('desktop.cua.call');
758
+ if (!call || this.probingDesktop) return;
759
+ this.probingDesktop = true;
760
+ try {
761
+ const report = (await call({ tool: 'check_permissions', args: {}, __permission: { capability: 'desktop' } })) as Record<string, unknown> | null;
762
+ this.desktopPermissions = {
763
+ accessibility: report?.accessibility === true,
764
+ screenRecording: report?.screen_recording === true,
765
+ };
766
+ } catch {
767
+ // A failed probe says nothing about the grants; keep the last answer.
768
+ } finally {
769
+ this.probingDesktop = false;
770
+ }
771
+ this.reportAccess();
654
772
  }
655
773
 
656
774
  /** R6: hold a sleep blocker while access.json asks for it. */
@@ -709,6 +827,8 @@ export class TunnelAgent {
709
827
  this.syncKeepAwake(access.keepAwake);
710
828
  this.reportAccess();
711
829
  } catch {}
830
+ // Grants change in System Settings while the agent runs: re-check every minute.
831
+ if (++this.watchdogTicks % DESKTOP_PROBE_EVERY_TICKS === 0 && this.status === 'online') void this.probeDesktopPermissions();
712
832
  }, interval);
713
833
  this.watchdog.unref?.();
714
834
  }
@@ -26,7 +26,14 @@ function fakeDriver(dir: string): { binary: string; log: string } {
26
26
  echo "$* | embedded=$CUA_DRIVER_EMBEDDED telemetry=$CUA_DRIVER_RS_TELEMETRY_ENABLED host=$CUA_DRIVER_HOST_BUNDLE_ID" >> "${log}"
27
27
  case "$1" in
28
28
  serve)
29
- while [ "$#" -gt 0 ]; do [ "$1" = "--socket" ] && touch "$2"; shift; done
29
+ # Like the real driver (0.31.0): refuses to start over an existing endpoint.
30
+ while [ "$#" -gt 0 ]; do
31
+ if [ "$1" = "--socket" ]; then
32
+ if [ -e "$2" ]; then echo "embedded daemon endpoint already exists at $2" >&2; exit 1; fi
33
+ touch "$2"
34
+ fi
35
+ shift
36
+ done
30
37
  sleep 30 ;;
31
38
  status)
32
39
  # Like the real driver: running only while a daemon listens on the socket.
@@ -78,6 +85,41 @@ describe('embedded driver', () => {
78
85
  }
79
86
  });
80
87
 
88
+ test('a socket left by a previous agent is stopped and removed, and its own daemon starts', async () => {
89
+ const dir = tempDir();
90
+ const { binary, log } = fakeDriver(dir);
91
+ const socketPath = join(dir, 'cua.sock');
92
+ writeFileSync(socketPath, '');
93
+ const driver = new CuaDriver({ binary, embedded: true, socketPath });
94
+ try {
95
+ expect(await driver.call('health_report', {})).toEqual({ ok: true });
96
+ const commands = readFileSync(log, 'utf8').trim().split('\n').map((line) => line.split(' ')[0]);
97
+ expect(commands).toEqual(['stop', 'serve', 'call']);
98
+ } finally {
99
+ driver.stop();
100
+ }
101
+ });
102
+
103
+ test('a daemon that does not start fails the call, and the next call tries again', async () => {
104
+ const dir = tempDir();
105
+ const binary = join(dir, 'cua-driver');
106
+ const log = join(dir, 'calls.log');
107
+ writeFileSync(binary, `#!/bin/sh\necho "$1" >> "${log}"\n[ "$1" = serve ] && exit 1\necho '{"ok":true}'\n`);
108
+ chmodSync(binary, 0o755);
109
+ const driver = new CuaDriver({ binary, embedded: true, socketPath: join(dir, 'cua.sock') });
110
+ await expect(driver.call('get_screen_size', {})).rejects.toThrow('cua-driver did not start');
111
+ await expect(driver.call('get_screen_size', {})).rejects.toThrow('cua-driver did not start');
112
+ expect(readFileSync(log, 'utf8').trim().split('\n')).toEqual(['serve', 'serve']);
113
+ });
114
+
115
+ test('missingPermissions names the macOS grants the driver reports missing', async () => {
116
+ const dir = tempDir();
117
+ const binary = join(dir, 'cua-driver');
118
+ writeFileSync(binary, `#!/bin/sh\necho '{"accessibility":false,"screen_recording":true}'\n`);
119
+ chmodSync(binary, 0o755);
120
+ expect(await new CuaDriver({ binary }).missingPermissions()).toEqual(['Accessibility']);
121
+ });
122
+
81
123
  test('a separately installed driver keeps its standalone mode, without telemetry', async () => {
82
124
  const dir = tempDir();
83
125
  const { binary, log } = fakeDriver(dir);
@@ -1,7 +1,8 @@
1
1
  import { spawn, type ChildProcess } from 'child_process';
2
- import { existsSync, readFileSync, realpathSync, statSync } from 'fs';
2
+ import { existsSync, readFileSync, realpathSync, rmSync, statSync } from 'fs';
3
3
  import { homedir, platform, tmpdir } from 'os';
4
4
  import { basename, dirname, join } from 'path';
5
+ import { agentTunnelHome, serviceLabelFor, SERVICE_LABEL } from '../../service-paths';
5
6
 
6
7
  interface ExecResult {
7
8
  stdout: string;
@@ -150,7 +151,8 @@ function execFile(
150
151
  clearTimeout(timer);
151
152
  if (code !== 0) {
152
153
  const detail = stderr.trim() || stdout.trim();
153
- reject(new Error(`${cmd} failed (${code})${detail ? `: ${detail}` : ''}`));
154
+ // The driver's name, not its install path: agents read this message.
155
+ reject(new Error(`${basename(cmd)} failed (${code})${detail ? `: ${detail}` : ''}`));
154
156
  } else {
155
157
  resolve({ stdout, stderr });
156
158
  }
@@ -231,8 +233,10 @@ export class CuaDriver {
231
233
  this.binary = options.binary ?? null;
232
234
  this.embedded = options.embedded;
233
235
  const uid = typeof process.getuid === 'function' ? process.getuid() : 'user';
234
- // Under $TMPDIR: a unix socket path is capped at 104 bytes on macOS.
235
- this.socketPath = options.socketPath ?? join(tmpdir(), `kortix-cua-${uid}.sock`);
236
+ // One driver per agent home (the label's suffix), so two homes never share
237
+ // or steal a driver. Under $TMPDIR: a socket path is capped at 104 bytes on macOS.
238
+ const home = serviceLabelFor(agentTunnelHome()).slice(SERVICE_LABEL.length);
239
+ this.socketPath = options.socketPath ?? join(tmpdir(), `kortix-cua-${uid}${home}.sock`);
236
240
  this.hostBundleId = options.hostBundleId;
237
241
  }
238
242
 
@@ -326,6 +330,8 @@ export class CuaDriver {
326
330
 
327
331
  if (this.isEmbedded(bin)) {
328
332
  await this.startEmbeddedDaemon(bin);
333
+ this.daemonReady = true;
334
+ return { ok: true };
329
335
  } else if (platform() === 'darwin') {
330
336
  const child = spawn('open', ['-n', '-g', '-a', 'CuaDriver', '--args', 'serve'], {
331
337
  detached: true,
@@ -360,6 +366,14 @@ export class CuaDriver {
360
366
  private async startEmbeddedDaemon(bin: string): Promise<void> {
361
367
  if (this.daemon && this.daemon.exitCode === null) return;
362
368
  const { env, socket } = this.mode(bin);
369
+ // A socket this agent did not just start is stale: a previous agent left it
370
+ // when it exited, and the driver refuses to start over an existing endpoint.
371
+ // Stop whatever may still answer on it (it runs outside this process's
372
+ // macOS permissions), then remove it.
373
+ if (existsSync(this.socketPath)) {
374
+ await execFile(bin, ['stop', ...socket], 10_000, env).catch(() => undefined);
375
+ rmSync(this.socketPath, { force: true });
376
+ }
363
377
  const child = spawn(bin, ['serve', '--embedded', '--no-permissions-gate', ...socket], {
364
378
  stdio: 'ignore',
365
379
  env,
@@ -383,6 +397,24 @@ export class CuaDriver {
383
397
  }
384
398
  }
385
399
 
400
+ /**
401
+ * The macOS grants the driver reports missing for its permission owner:
402
+ * Kortix when embedded, CuaDriver otherwise. Empty when nothing is missing
403
+ * or the platform has no such grants.
404
+ */
405
+ /** The app macOS shows in Privacy & Security for this driver's grants. */
406
+ async permissionOwner(): Promise<string> {
407
+ return this.isEmbedded(await this.ensureInstalled()) ? 'Kortix' : 'CuaDriver';
408
+ }
409
+
410
+ async missingPermissions(): Promise<string[]> {
411
+ const report = (await this.call('check_permissions', {})) as Record<string, unknown> | null;
412
+ const missing: string[] = [];
413
+ if (report?.accessibility === false) missing.push('Accessibility');
414
+ if (report?.screen_recording === false) missing.push('Screen Recording');
415
+ return missing;
416
+ }
417
+
386
418
  /** Stops an embedded daemon this agent started. Restarting picks up new grants. */
387
419
  stop(): void {
388
420
  this.daemon?.kill();
@@ -391,6 +423,14 @@ export class CuaDriver {
391
423
  }
392
424
 
393
425
  private async ensureDaemonReady(): Promise<void> {
426
+ const bin = await this.ensureInstalled();
427
+ // Embedded: only a driver this agent started runs inside its permissions.
428
+ if (this.isEmbedded(bin)) {
429
+ if (this.daemon?.exitCode === null && existsSync(this.socketPath)) return;
430
+ this.stop();
431
+ await this.startDaemon();
432
+ return;
433
+ }
394
434
  if (this.daemonReady) return;
395
435
  try {
396
436
  const status = await this.status();
@@ -1,46 +1,7 @@
1
1
  import type { Capability, RpcHandler } from './index';
2
2
  import { CuaDriver } from './desktop/cua-driver';
3
- import { desktopFeatureForMethod } from '../../shared/permissions';
4
3
  import type { LocalPermission } from '../security/permission-guard';
5
-
6
- const CUA_TOOLS = [
7
- 'bring_to_front',
8
- 'check_for_update',
9
- 'check_permissions',
10
- 'click',
11
- 'double_click',
12
- 'drag',
13
- 'end_session',
14
- 'get_accessibility_tree',
15
- 'get_agent_cursor_state',
16
- 'get_config',
17
- 'get_cursor_position',
18
- 'get_recording_state',
19
- 'get_screen_size',
20
- 'get_window_state',
21
- 'hotkey',
22
- 'kill_app',
23
- 'launch_app',
24
- 'list_apps',
25
- 'list_windows',
26
- 'move_cursor',
27
- 'page',
28
- 'press_key',
29
- 'replay_trajectory',
30
- 'right_click',
31
- 'scroll',
32
- 'set_agent_cursor_enabled',
33
- 'set_agent_cursor_motion',
34
- 'set_agent_cursor_style',
35
- 'set_config',
36
- 'set_value',
37
- 'start_recording',
38
- 'install_ffmpeg',
39
- 'start_session',
40
- 'stop_recording',
41
- 'type_text',
42
- 'zoom',
43
- ];
4
+ import { TunnelErrorCode } from '../../shared/types';
44
5
 
45
6
  const LOCAL_ONLY_CUA_TOOLS = new Set(['check_for_update', 'install_ffmpeg']);
46
7
 
@@ -53,75 +14,87 @@ function assertRemotelyCallableTool(tool: unknown): asserts tool is string {
53
14
  }
54
15
  }
55
16
 
56
- export function createDesktopCapability(): Capability {
17
+ /** An RPC error with a binding code (the API maps it to a refusal kind). */
18
+ export class DesktopPermissionMissingError extends Error {
19
+ readonly code = TunnelErrorCode.DESKTOP_PERMISSION_MISSING;
20
+ }
21
+
22
+ /**
23
+ * A failed call is first asked "does macOS let this process see and drive the
24
+ * screen?". Without Accessibility or Screen Recording every tool fails with a
25
+ * driver-specific message; this turns all of them into one actionable error.
26
+ */
27
+ async function explainFailure(cua: CuaDriver, err: unknown, onMissing: () => void): Promise<never> {
28
+ const missing = await cua.missingPermissions().catch(() => []);
29
+ if (missing.length > 0) {
30
+ onMissing();
31
+ throw new DesktopPermissionMissingError(
32
+ `computer_desktop_permission_missing: macOS has not given ${await cua.permissionOwner()} ${missing.join(' and ')} on this computer.`,
33
+ );
34
+ }
35
+ throw err;
36
+ }
37
+
38
+ /**
39
+ * Desktop is one grant ("Screen & keyboard"): an approved desktop permission
40
+ * allows every driver tool. The tool list belongs to the installed driver.
41
+ */
42
+ export function createDesktopCapability(options: { onPermissionMissing?: () => void } = {}): Capability {
57
43
  const cua = new CuaDriver();
58
44
  const methods = new Map<string, RpcHandler>();
45
+ const onMissing = options.onPermissionMissing ?? (() => {});
59
46
 
60
- const assertDesktopPermission = (params: Record<string, unknown>, method: string): void => {
47
+ const assertDesktopPermission = (params: Record<string, unknown>): void => {
61
48
  const permission = params.__permission as LocalPermission | undefined;
62
49
  if (permission?.capability !== 'desktop') {
63
50
  throw new Error('Permission denied: desktop permission required');
64
51
  }
65
- const features = Array.isArray(permission.scope?.features)
66
- ? permission.scope.features.filter((value): value is string => typeof value === 'string')
67
- : [];
68
- if (features.length === 0) return;
69
- const feature = desktopFeatureForMethod(method, params);
70
- if (!feature || !features.includes(feature)) {
71
- throw new Error(
72
- `Permission denied: desktop feature "${feature ?? 'unknown'}" is not allowed`,
73
- );
74
- }
75
52
  };
76
53
 
77
54
  methods.set('desktop.cua.ensure', async (params) => {
78
- assertDesktopPermission(params, 'desktop.cua.ensure');
55
+ assertDesktopPermission(params);
79
56
  const binary = await cua.ensureInstalled();
80
57
  const version = await cua.version().catch(() => undefined);
81
58
  return { ok: true, binary, version };
82
59
  });
83
60
 
84
61
  methods.set('desktop.cua.start_daemon', async (params) => {
85
- assertDesktopPermission(params, 'desktop.cua.start_daemon');
62
+ assertDesktopPermission(params);
86
63
  return cua.startDaemon();
87
64
  });
88
65
 
89
66
  methods.set('desktop.cua.status', async (params) => {
90
- assertDesktopPermission(params, 'desktop.cua.status');
67
+ assertDesktopPermission(params);
91
68
  return { status: await cua.status() };
92
69
  });
93
70
 
94
71
  methods.set('desktop.cua.version', async (params) => {
95
- assertDesktopPermission(params, 'desktop.cua.version');
72
+ assertDesktopPermission(params);
96
73
  return { version: await cua.version() };
97
74
  });
98
75
 
99
76
  methods.set('desktop.cua.list_tools', async (params) => {
100
- assertDesktopPermission(params, 'desktop.cua.list_tools');
77
+ assertDesktopPermission(params);
101
78
  return { tools: await cua.listTools() };
102
79
  });
103
80
 
104
81
  methods.set('desktop.cua.describe', async (params) => {
105
- assertDesktopPermission(params, 'desktop.cua.describe');
82
+ assertDesktopPermission(params);
106
83
  return { description: await cua.describe(params.tool as string) };
107
84
  });
108
85
 
109
86
  methods.set('desktop.cua.call', async (params) => {
110
- assertDesktopPermission(params, 'desktop.cua.call');
87
+ assertDesktopPermission(params);
111
88
  const tool = params.tool;
112
89
  assertRemotelyCallableTool(tool);
113
90
  const args = (params.args || {}) as Record<string, unknown>;
114
- return cua.call(tool, args);
91
+ try {
92
+ return await cua.call(tool, args);
93
+ } catch (err) {
94
+ return explainFailure(cua, err, onMissing);
95
+ }
115
96
  });
116
97
 
117
- for (const tool of CUA_TOOLS) {
118
- if (LOCAL_ONLY_CUA_TOOLS.has(tool)) continue;
119
- methods.set(`desktop.cua.${tool}`, async (params) => {
120
- assertDesktopPermission(params, `desktop.cua.${tool}`);
121
- return cua.call(tool, params);
122
- });
123
- }
124
-
125
98
  return {
126
99
  name: 'desktop',
127
100
  methods,
@@ -9,6 +9,7 @@ import { createShellCapability } from './shell';
9
9
  export function createEnabledCapabilityRegistry(
10
10
  config: TunnelConfig,
11
11
  findDesktopDriver: () => string | null = findCuaDriverBinary,
12
+ desktop: { onPermissionMissing?: () => void } = {},
12
13
  ): CapabilityRegistry {
13
14
  const registry = new CapabilityRegistry();
14
15
  const enabled = new Set(config.enabledCapabilities ?? ['filesystem', 'shell', 'desktop']);
@@ -18,7 +19,7 @@ export function createEnabledCapabilityRegistry(
18
19
  // local driver. The server must see the real handler surface, not the user's
19
20
  // desired capability list.
20
21
  if (enabled.has('desktop') && findDesktopDriver()) {
21
- registry.register(createDesktopCapability());
22
+ registry.register(createDesktopCapability(desktop));
22
23
  }
23
24
  return registry;
24
25
  }
@@ -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' });