@kortix/agent-tunnel 0.13.53 → 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.
- package/README.md +74 -14
- package/dist/agent-cli.js +457 -246
- package/package.json +1 -1
- package/src/agent/access.test.ts +89 -0
- package/src/agent/access.ts +146 -4
- package/src/agent/agent.test.ts +136 -6
- package/src/agent/agent.ts +132 -12
- package/src/agent/capabilities/desktop/cua-driver.test.ts +43 -1
- package/src/agent/capabilities/desktop/cua-driver.ts +44 -4
- package/src/agent/capabilities/desktop.ts +41 -68
- package/src/agent/capabilities/enabled-registry.ts +2 -1
- package/src/agent/capabilities/security.test.ts +54 -16
- package/src/agent/cli-help.test.ts +53 -2
- package/src/agent/cli.ts +127 -19
- package/src/agent/self-restart.test.ts +24 -0
- package/src/agent/self-restart.ts +47 -0
- package/src/agent/service.test.ts +27 -1
- package/src/agent/service.ts +22 -1
- package/src/agent/state-file.test.ts +11 -1
- package/src/agent/state-file.ts +13 -1
- package/src/index.ts +1 -1
- package/src/shared/index.ts +1 -1
- package/src/shared/permissions.ts +26 -58
- package/src/shared/types.ts +8 -36
package/src/agent/agent.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
647
|
-
const
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
235
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
62
|
+
assertDesktopPermission(params);
|
|
86
63
|
return cua.startDaemon();
|
|
87
64
|
});
|
|
88
65
|
|
|
89
66
|
methods.set('desktop.cua.status', async (params) => {
|
|
90
|
-
assertDesktopPermission(params
|
|
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
|
|
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
|
|
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
|
|
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
|
|
87
|
+
assertDesktopPermission(params);
|
|
111
88
|
const tool = params.tool;
|
|
112
89
|
assertRemotelyCallableTool(tool);
|
|
113
90
|
const args = (params.args || {}) as Record<string, unknown>;
|
|
114
|
-
|
|
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.
|
|
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
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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' });
|