@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.
@@ -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';
@@ -24,6 +32,16 @@ import { signMessage, verifyMessageSignature } from '../shared/crypto';
24
32
 
25
33
  export const AGENT_VERSION = agentTunnelVersion();
26
34
 
35
+ /**
36
+ * Opens a socket to the relay. It names the agent: Bun sends no User-Agent,
37
+ * and Cloudflare answers an upgrade without one with 403. Bun, Node 22 and
38
+ * `ws` all take `{ headers }` as the second argument.
39
+ */
40
+ export function openRelaySocket(url: string): WebSocket {
41
+ const RelaySocket = WebSocket as unknown as new (url: string, init: { headers: Record<string, string> }) => WebSocket;
42
+ return new RelaySocket(url, { headers: { 'User-Agent': `kortix-agent-tunnel/${AGENT_VERSION}` } });
43
+ }
44
+
27
45
  /**
28
46
  * Relay close codes that mean the credential itself is bad. They are terminal:
29
47
  * reconnecting with the same token can never succeed.
@@ -100,6 +118,8 @@ export interface TunnelAgentOptions {
100
118
  /** The relay pings every 30 s; 75 s of silence means the socket is dead (R1). */
101
119
  const LIVENESS_TIMEOUT_MS = 75_000;
102
120
  const WATCHDOG_INTERVAL_MS = 5_000;
121
+ /** The macOS permission check runs every 12 watchdog ticks (60 s). */
122
+ const DESKTOP_PROBE_EVERY_TICKS = 12;
103
123
  /** A tick that arrives this much later than scheduled means the machine slept. */
104
124
  const CLOCK_JUMP_MS = 30_000;
105
125
  const REJECTED_RETRY_MS = 5 * 60_000;
@@ -173,6 +193,9 @@ export class TunnelAgent {
173
193
  private standbyAttempts = 0;
174
194
  private lastRejectedLogAt = 0;
175
195
  private sentAccessKey: string | null = null;
196
+ private desktopPermissions: { accessibility: boolean; screenRecording: boolean } | null = null;
197
+ private probingDesktop = false;
198
+ private watchdogTicks = 0;
176
199
  private keepAwake: ChildProcess | null = null;
177
200
  private keepAwakeFailed = false;
178
201
  private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
@@ -219,7 +242,7 @@ export class TunnelAgent {
219
242
 
220
243
  try {
221
244
  // lgtm[js/file-access-to-http] Tunnel endpoint is intentionally loaded from trusted local config.
222
- this.ws = new WebSocket(new URL(wsUrl));
245
+ this.ws = openRelaySocket(new URL(wsUrl).href);
223
246
  this.setupWsHandlers();
224
247
  } catch (err) {
225
248
  log(`${c.red}✗${c.reset}`, `Connection failed`);
@@ -282,6 +305,8 @@ export class TunnelAgent {
282
305
  capabilities: this.registry.getCapabilityNames(),
283
306
  agentVersion: AGENT_VERSION,
284
307
  reportsAccess: true,
308
+ // Serves `tunnel.access.set`: Kortix may change access through this agent.
309
+ controlsAccess: true,
285
310
  });
286
311
  });
287
312
 
@@ -395,6 +420,7 @@ export class TunnelAgent {
395
420
  this.standbyAttempts = 0;
396
421
  this.setStatus('online');
397
422
  this.reportAccess();
423
+ void this.probeDesktopPermissions();
398
424
  if (this.stableConnectionTimer) clearTimeout(this.stableConnectionTimer);
399
425
  this.stableConnectionTimer = setTimeout(() => {
400
426
  this.reconnectAttempts = 0;
@@ -456,6 +482,14 @@ export class TunnelAgent {
456
482
  return;
457
483
  }
458
484
 
485
+ // ── The owner's access change, relayed by Kortix ────────────────
486
+ // Before the permission guard and the access gate: it must work while
487
+ // access is off, and it is the owner's own action, not an agent call.
488
+ if ('id' in msg && msg.id && msg.method === 'tunnel.access.set') {
489
+ await this.handleAccessChange(msg as JsonRpcRequest);
490
+ return;
491
+ }
492
+
459
493
  // ── RPC request dispatch ────────────────────────────────────────
460
494
  if ('id' in msg && msg.id) {
461
495
  await this.handleRpcRequest(msg as JsonRpcRequest);
@@ -493,7 +527,9 @@ export class TunnelAgent {
493
527
  }
494
528
 
495
529
  private async handleRpcRequest(request: JsonRpcRequest): Promise<void> {
496
- const { id, method, params = {} } = request;
530
+ // An older API may still send `desktop.cua.<tool>`: serve it as `desktop.cua.call`.
531
+ const { method, params } = normalizeDesktopCall(request.method, request.params ?? {});
532
+ const { id } = request;
497
533
 
498
534
  const permissionId = params.permissionId as string | undefined;
499
535
  const permission = this.permissionGuard.getPermissionForMethod(permissionId, method);
@@ -522,7 +558,9 @@ export class TunnelAgent {
522
558
  this.sendSignedResult(id, result);
523
559
  } catch (err) {
524
560
  const message = err instanceof Error ? err.message : String(err);
525
- this.sendSignedError(id, -32003, message);
561
+ const code = (err as { code?: unknown } | null)?.code;
562
+ // Only a JSON-RPC server code is binding; an exit status or errno is not.
563
+ this.sendSignedError(id, typeof code === 'number' && code <= -32000 && code >= -32099 ? code : -32003, message);
526
564
  }
527
565
  }
528
566
 
@@ -621,10 +659,12 @@ export class TunnelAgent {
621
659
  // Concurrent calls share one pending request: one prompt, one app launch.
622
660
  const pending = readAccessRequest(this.home);
623
661
  if (pending && Date.now() - Date.parse(pending.requestedAt) < hold) {
624
- requestId = pending.id;
662
+ // A relayed change waiting for the owner stays: if it is allowed, this call runs too.
663
+ requestId = pending.change ? null : pending.id;
625
664
  } else {
626
665
  requestId = writeAccessRequest({ capability: capabilityForMethod(method) ?? method, method }, this.home).id;
627
666
  wakeDesktopApp(this.home);
667
+ this.reportAccess();
628
668
  }
629
669
  } catch {
630
670
  // Without a request file nobody can answer; the hold simply runs out.
@@ -641,16 +681,104 @@ export class TunnelAgent {
641
681
  return ACCESS_ERRORS.pending;
642
682
  }
643
683
 
644
- /** X2: optional signed notification; relays that do not know it ignore it. */
684
+ /**
685
+ * `tunnel.access.set`: Kortix relays the owner's change. A narrowing applies
686
+ * at once. A widening (`always`, a longer grant) needs the owner on this
687
+ * machine: it becomes a request the desktop app shows as a native prompt,
688
+ * and the call waits for the answer. Without the app, the owner runs the
689
+ * command in a terminal.
690
+ */
691
+ private async handleAccessChange(request: JsonRpcRequest): Promise<void> {
692
+ let change: AccessChange;
693
+ try {
694
+ change = parseAccessChange(request.params);
695
+ } catch (err) {
696
+ this.sendSignedError(request.id, TunnelErrorCode.LOCAL_ERROR, err instanceof Error ? err.message : String(err));
697
+ return;
698
+ }
699
+ try {
700
+ const view = await this.changeAccess(change);
701
+ this.reportAccess();
702
+ this.sendSignedResult(request.id, view);
703
+ } catch (err) {
704
+ this.reportAccess();
705
+ this.sendSignedError(request.id, TunnelErrorCode.ACCESS_UNCONFIRMED, err instanceof Error ? err.message : String(err));
706
+ }
707
+ }
708
+
709
+ private async changeAccess(change: AccessChange): Promise<AccessView> {
710
+ const current = readAccess(this.home);
711
+ const target = nextAccess(current, change, this.now());
712
+ if (!accessWidens(current, target, this.now())) return applyAccess(change, this.home, this.now());
713
+
714
+ const unconfirmed = (why: string) => new Error(`computer_access_unconfirmed: ${why}`);
715
+ const asked = writeAccessRequest({ capability: 'access', method: 'tunnel.access.set', change }, this.home);
716
+ this.reportAccess();
717
+ if (wakeDesktopApp(this.home) === 'no-app') {
718
+ clearAccessRequest(asked.id, this.home);
719
+ throw unconfirmed(`Only the owner can allow more access, on the computer itself. Run ${accessCommand(change)} there.`);
720
+ }
721
+ const hold = this.options.accessHoldMs ?? ACCESS_HOLD_MS;
722
+ const deadline = Date.now() + hold;
723
+ while (Date.now() < deadline) {
724
+ await new Promise((resolve) => setTimeout(resolve, Math.min(100, hold / 4)));
725
+ // Reached: the owner allowed it (the app ran the command).
726
+ if (!accessWidens(readAccess(this.home), target, this.now())) {
727
+ clearAccessRequest(asked.id, this.home);
728
+ return accessView(this.home, platform(), this.now());
729
+ }
730
+ if (readAccessRequest(this.home)?.id !== asked.id) throw unconfirmed('The owner declined on the computer.');
731
+ }
732
+ clearAccessRequest(asked.id, this.home);
733
+ throw unconfirmed(`Nobody confirmed on the computer within ${Math.round(hold / 1000)} seconds.`);
734
+ }
735
+
736
+ /**
737
+ * X2: the machine's own state, signed, whenever it changes: access, keep
738
+ * awake, the request waiting for the owner, and the macOS permissions as
739
+ * this process holds them. Kortix shows exactly this; relays that do not
740
+ * know a field ignore it.
741
+ */
645
742
  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;
743
+ if (this.status !== 'online') return;
744
+ const view = accessView(this.home, platform(), this.now());
745
+ const params = {
746
+ mode: view.mode,
747
+ grantedUntil: view.grantedUntil,
748
+ deniedUntil: view.deniedUntil,
749
+ keepAwake: view.keepAwake,
750
+ keepAwakeSupported: view.keepAwakeSupported,
751
+ pendingRequest: view.pendingRequest
752
+ ? { capability: view.pendingRequest.capability, requestedAt: view.pendingRequest.requestedAt, ...(view.pendingRequest.change ? { change: view.pendingRequest.change } : {}) }
753
+ : null,
754
+ desktopPermissions: this.desktopPermissions,
755
+ };
756
+ const key = JSON.stringify(params);
757
+ if (key === this.sentAccessKey) return;
652
758
  this.sentAccessKey = key;
653
- this.sendSigned({ jsonrpc: '2.0', method: 'tunnel.access.state', params: { mode, grantedUntil } });
759
+ this.sendSigned({ jsonrpc: '2.0', method: 'tunnel.access.state', params });
760
+ }
761
+
762
+ /**
763
+ * Asks the desktop driver whether macOS lets THIS process see and drive the
764
+ * screen. The answer for another process (the app window) can differ.
765
+ */
766
+ private async probeDesktopPermissions(): Promise<void> {
767
+ const call = this.registry.getHandler('desktop.cua.call');
768
+ if (!call || this.probingDesktop) return;
769
+ this.probingDesktop = true;
770
+ try {
771
+ const report = (await call({ tool: 'check_permissions', args: {}, __permission: { capability: 'desktop' } })) as Record<string, unknown> | null;
772
+ this.desktopPermissions = {
773
+ accessibility: report?.accessibility === true,
774
+ screenRecording: report?.screen_recording === true,
775
+ };
776
+ } catch {
777
+ // A failed probe says nothing about the grants; keep the last answer.
778
+ } finally {
779
+ this.probingDesktop = false;
780
+ }
781
+ this.reportAccess();
654
782
  }
655
783
 
656
784
  /** R6: hold a sleep blocker while access.json asks for it. */
@@ -709,6 +837,8 @@ export class TunnelAgent {
709
837
  this.syncKeepAwake(access.keepAwake);
710
838
  this.reportAccess();
711
839
  } catch {}
840
+ // Grants change in System Settings while the agent runs: re-check every minute.
841
+ if (++this.watchdogTicks % DESKTOP_PROBE_EVERY_TICKS === 0 && this.status === 'online') void this.probeDesktopPermissions();
712
842
  }, interval);
713
843
  this.watchdog.unref?.();
714
844
  }
@@ -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
  }