@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.
- package/README.md +74 -14
- package/dist/agent-cli.js +464 -249
- 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 +143 -13
- 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/credential-probe.ts +4 -4
- package/src/agent/self-restart.test.ts +24 -0
- package/src/agent/self-restart.ts +47 -0
- package/src/agent/service-lifecycle.test.ts +46 -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';
|
|
@@ -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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
647
|
-
const
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|