@phnx-labs/agents-cli 1.22.103 → 1.22.105

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.
Files changed (68) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +1 -1
  3. package/dist/commands/accounts.js +2 -2
  4. package/dist/commands/computer.d.ts +100 -79
  5. package/dist/commands/computer.js +290 -830
  6. package/dist/commands/run-device-picker.d.ts +58 -0
  7. package/dist/commands/run-device-picker.js +222 -0
  8. package/dist/commands/setup-computer.js +20 -1
  9. package/dist/commands/setup-secrets.d.ts +2 -2
  10. package/dist/commands/setup-secrets.js +1 -1
  11. package/dist/commands/view.js +4 -6
  12. package/dist/lib/account-catalog.d.ts +22 -1
  13. package/dist/lib/account-catalog.js +72 -38
  14. package/dist/lib/accounting/usage.js +18 -14
  15. package/dist/lib/agent-spec/agents.d.ts +1 -0
  16. package/dist/lib/agent-spec/agents.js +1 -1
  17. package/dist/lib/browser/drivers/ssh.js +1 -1
  18. package/dist/lib/computer/context.d.ts +83 -0
  19. package/dist/lib/computer/context.js +91 -0
  20. package/dist/lib/computer/policy.d.ts +46 -0
  21. package/dist/lib/computer/policy.js +160 -0
  22. package/dist/lib/computer/record.d.ts +38 -0
  23. package/dist/lib/computer/record.js +86 -0
  24. package/dist/lib/computer/sessions-list.js +6 -6
  25. package/dist/lib/computer-client.d.ts +150 -0
  26. package/dist/lib/computer-client.js +222 -0
  27. package/dist/lib/exec.js +35 -1
  28. package/dist/lib/harness/adapters/claude.d.ts +37 -0
  29. package/dist/lib/harness/adapters/claude.js +69 -0
  30. package/dist/lib/helper-download.d.ts +1 -1
  31. package/dist/lib/helper-download.js +1 -1
  32. package/dist/lib/helper-versions.d.ts +9 -4
  33. package/dist/lib/helper-versions.js +8 -8
  34. package/dist/lib/installations/shims.js +8 -4
  35. package/dist/lib/menubar/download-menubar.d.ts +2 -1
  36. package/dist/lib/menubar/download-menubar.js +2 -1
  37. package/dist/lib/secrets-client.d.ts +3 -3
  38. package/dist/lib/secrets-client.js +15 -38
  39. package/dist/lib/session/db.js +1 -1
  40. package/dist/lib/sha256-asset.d.ts +2 -1
  41. package/dist/lib/sha256-asset.js +2 -1
  42. package/dist/lib/ssh-tunnel.d.ts +61 -0
  43. package/dist/lib/ssh-tunnel.js +105 -0
  44. package/dist/lib/summarizer/summarize.d.ts +2 -2
  45. package/dist/lib/summarizer/summarize.js +10 -3
  46. package/package.json +2 -3
  47. package/dist/commands/computer-actions.d.ts +0 -55
  48. package/dist/commands/computer-actions.js +0 -594
  49. package/dist/computer.d.ts +0 -2
  50. package/dist/computer.js +0 -7
  51. package/dist/lib/computer/actions.d.ts +0 -36
  52. package/dist/lib/computer/actions.js +0 -162
  53. package/dist/lib/computer/computer-rpc.d.ts +0 -39
  54. package/dist/lib/computer/computer-rpc.js +0 -447
  55. package/dist/lib/computer/des.d.ts +0 -1
  56. package/dist/lib/computer/des.js +0 -114
  57. package/dist/lib/computer/dispatch.d.ts +0 -10
  58. package/dist/lib/computer/dispatch.js +0 -133
  59. package/dist/lib/computer/download.d.ts +0 -54
  60. package/dist/lib/computer/download.js +0 -83
  61. package/dist/lib/computer/loop.d.ts +0 -62
  62. package/dist/lib/computer/loop.js +0 -98
  63. package/dist/lib/computer/model.d.ts +0 -44
  64. package/dist/lib/computer/model.js +0 -157
  65. package/dist/lib/computer/rfb-client.d.ts +0 -53
  66. package/dist/lib/computer/rfb-client.js +0 -562
  67. package/dist/lib/computer/ssh-tunnel.d.ts +0 -189
  68. package/dist/lib/computer/ssh-tunnel.js +0 -584
@@ -1,162 +0,0 @@
1
- import * as fs from 'fs';
2
- import * as path from 'path';
3
- import { randomUUID } from 'node:crypto';
4
- import { resolvePolicyPath } from './computer-rpc.js';
5
- import { COMPUTER_INPUT_GATED_VERBS, formatComputerPermissionGrantHint } from '../permissions.js';
6
- import { emit as emitEvent } from '../feed/events.js';
7
- import { recordComputerSession } from '../session/db.js';
8
- import { resolveActor } from '../actor.js';
9
- function isComputerInputVerb(verb) {
10
- return typeof verb === 'string' && COMPUTER_INPUT_GATED_VERBS.includes(verb);
11
- }
12
- function computerSessionId(env = process.env) {
13
- return env.CODEX_THREAD_ID
14
- || env.CLAUDE_CODE_SESSION_ID
15
- || env.CLAUDE_SESSION_ID
16
- || env.AGENTS_SESSION_ID
17
- || env.AGENTS_RUN_ID
18
- || null;
19
- }
20
- function admissionCachePath(env = process.env) {
21
- if (env.AGENTS_COMPUTER_ADMISSION_CACHE)
22
- return env.AGENTS_COMPUTER_ADMISSION_CACHE;
23
- return path.join(path.dirname(resolvePolicyPath()), 'computer-target-admissions.json');
24
- }
25
- function targetSelector(opts) {
26
- return opts.bundle ? `bundle:${opts.bundle}` : 'frontmost';
27
- }
28
- function readAdmissionCache(filePath) {
29
- try {
30
- const parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
31
- return Array.isArray(parsed.admissions) ? parsed.admissions.filter(isAdmission) : [];
32
- }
33
- catch {
34
- return [];
35
- }
36
- }
37
- function isAdmission(v) {
38
- if (!v || typeof v !== 'object')
39
- return false;
40
- const r = v;
41
- return r.sessionId !== undefined
42
- && typeof r.sessionId === 'string'
43
- && typeof r.selector === 'string'
44
- && r.gateClass === 'input'
45
- && typeof r.pid === 'number'
46
- && typeof r.bundle_id === 'string'
47
- && typeof r.name === 'string'
48
- && typeof r.admittedAtMs === 'number'
49
- && isComputerInputVerb(r.admittedByVerb);
50
- }
51
- function rememberAdmission(app, opts) {
52
- const sessionId = computerSessionId(opts.env);
53
- if (!sessionId)
54
- return;
55
- const filePath = admissionCachePath(opts.env);
56
- const admissions = readAdmissionCache(filePath);
57
- const next = {
58
- sessionId,
59
- selector: opts.selector,
60
- gateClass: 'input',
61
- pid: app.pid,
62
- bundle_id: app.bundle_id,
63
- name: app.name,
64
- admittedAtMs: opts.nowMs ?? Date.now(),
65
- admittedByVerb: opts.verb,
66
- };
67
- const filtered = admissions.filter((a) => !(a.sessionId === sessionId && a.selector === opts.selector && a.gateClass === 'input'));
68
- filtered.push(next);
69
- try {
70
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
71
- fs.writeFileSync(filePath, JSON.stringify({ admissions: filtered }, null, 2), { mode: 0o600 });
72
- }
73
- catch {
74
- // Cache persistence is best-effort; the daemon remains the final gate.
75
- }
76
- }
77
- function findAdmission(opts) {
78
- const sessionId = computerSessionId(opts.env);
79
- if (!sessionId)
80
- return null;
81
- const admissions = readAdmissionCache(admissionCachePath(opts.env));
82
- const matches = admissions
83
- .filter((a) => a.sessionId === sessionId && a.selector === opts.selector && a.gateClass === 'input')
84
- .sort((a, b) => b.admittedAtMs - a.admittedAtMs);
85
- return matches[0] ?? null;
86
- }
87
- export function pickTarget(list, opts) {
88
- if (opts.pid != null) {
89
- const app = list.find((a) => a.pid === opts.pid);
90
- return { ok: true, app: app ?? { pid: opts.pid, name: '', bundle_id: '', active: false } };
91
- }
92
- if (opts.bundle) {
93
- const app = list.find((a) => a.bundle_id === opts.bundle);
94
- if (!app) {
95
- return {
96
- ok: false,
97
- error: `bundle not in allow list (or not running): ${opts.bundle}\n${formatComputerPermissionGrantHint(opts.bundle)}`,
98
- };
99
- }
100
- return { ok: true, app };
101
- }
102
- const active = list.find((a) => a.active);
103
- if (!active) {
104
- return {
105
- ok: false,
106
- error: `no active app found in allow list\n${formatComputerPermissionGrantHint()}`,
107
- };
108
- }
109
- return { ok: true, app: active };
110
- }
111
- function unwrap(r) {
112
- if (r.error) {
113
- console.error(`error: ${r.error.code}: ${r.error.message}`);
114
- process.exit(1);
115
- }
116
- return r.result ?? {};
117
- }
118
- export async function resolveTargetPidDecision(client, opts, gate) {
119
- if (opts.pid != null)
120
- return { ok: true, pid: opts.pid, source: 'pid' };
121
- const apps = unwrap(await client.call('list_apps'));
122
- const list = apps.apps || [];
123
- const picked = pickTarget(list, opts);
124
- const verb = gate?.verb;
125
- const selector = targetSelector(opts);
126
- if (picked.ok && isComputerInputVerb(verb)) {
127
- rememberAdmission(picked.app, { selector, verb, env: gate?.env, nowMs: gate?.nowMs });
128
- }
129
- if (!picked.ok) {
130
- if (isComputerInputVerb(verb)) {
131
- const admitted = findAdmission({ selector, env: gate?.env });
132
- if (admitted)
133
- return { ok: true, pid: admitted.pid, source: 'session_admission' };
134
- }
135
- return { ok: false, error: picked.error };
136
- }
137
- return { ok: true, pid: picked.app.pid, source: 'list_apps' };
138
- }
139
- const COMPUTER_INVOCATION_ID = randomUUID();
140
- export function emitComputerAction(verb, targetPid, opts, extra = {}) {
141
- emitEvent('computer.action', {
142
- command: verb,
143
- invocationId: COMPUTER_INVOCATION_ID,
144
- targetPid,
145
- bundle: opts.bundle,
146
- device: opts.device,
147
- ...extra,
148
- });
149
- try {
150
- recordComputerSession({
151
- invocationId: COMPUTER_INVOCATION_ID,
152
- sessionId: process.env.AGENT_SESSION_ID || process.env.AGENTS_SESSION_ID,
153
- launchId: process.env.AGENT_LAUNCH_ID,
154
- actor: resolveActor().id,
155
- actionCount: 1,
156
- taskPreview: typeof extra.task === 'string' ? extra.task : undefined,
157
- });
158
- }
159
- catch {
160
- // Recording is best-effort; the action and its event are already done.
161
- }
162
- }
@@ -1,39 +0,0 @@
1
- export interface RPCResponse {
2
- id: number | null;
3
- result?: Record<string, unknown>;
4
- error?: {
5
- code: string;
6
- message: string;
7
- };
8
- }
9
- export interface ComputerClient {
10
- call(method: string, params?: Record<string, unknown>): Promise<RPCResponse>;
11
- close(): Promise<void>;
12
- }
13
- export declare function resolveSocketPath(): string;
14
- export declare function resolveLogPath(): string;
15
- export declare function resolvePolicyPath(): string;
16
- export declare function loadComputerAllowList(): string[];
17
- export declare function writeComputerPolicy(allowedBundleIds: string[]): void;
18
- export declare function resolvePeersPath(): string;
19
- export declare function loadDefaultPeers(): string[];
20
- export declare function writeComputerPeers(allowedExecPaths: string[]): void;
21
- export declare function resolveHelperExec(): string | null;
22
- export declare function resolveHelperApp(): string | null;
23
- export declare function resolveTcpEndpoint(): {
24
- host: string;
25
- port: number;
26
- token: string | null;
27
- } | null;
28
- export declare function resolveVncEndpoint(): {
29
- host: string;
30
- port: number;
31
- password: string;
32
- } | null;
33
- export declare function openComputerClient(): ComputerClient;
34
- export declare const RPC_TIMEOUT_MS = 30000;
35
- export declare function resolveRpcTimeoutMs(env: string | undefined): number;
36
- export declare function describeTransport(): {
37
- kind: 'socket' | 'stdio' | 'tcp' | 'vnc' | 'none';
38
- path: string | null;
39
- };
@@ -1,447 +0,0 @@
1
- // JSON-RPC client for the computer-helper.
2
- //
3
- // Two transports, picked at runtime:
4
- // - socket: ~/.agents/.cache/helpers/computer.sock (or COMPUTER_HELPER_SOCKET
5
- // env) when the launchd daemon is installed and listening. Sub-50ms per
6
- // call. Path is internal scratch — sibling of browser.sock.
7
- // - stdio: spawn the helper binary as a child process per call. Used in
8
- // dev (no install-helper) and as a fallback. The legacy probe.py and
9
- // drive-capcut.py scripts in rush/agents still use this shape.
10
- //
11
- // Both transports share the same line-delimited JSON-RPC wire format:
12
- // in: {"id":N,"method":"...","params":{...}}
13
- // out: {"id":N,"result":{...}} or {"id":N,"error":{"code":"...","message":"..."}}
14
- import { spawn } from 'child_process';
15
- import { createConnection } from 'net';
16
- import * as fs from 'fs';
17
- import * as path from 'path';
18
- import { fileURLToPath } from 'url';
19
- import { getHelpersDir, getLogsDir, getUserPermissionsDir, getPermissionsDir } from '../state.js';
20
- import { RfbClient, parseVncEndpoint } from './rfb-client.js';
21
- // Resolve the socket path used by the launchd-managed daemon. Internal
22
- // scratch — lives under getHelpersDir() (~/.agents/.cache/helpers/),
23
- // matching browser.sock.
24
- export function resolveSocketPath() {
25
- const envPath = process.env.COMPUTER_HELPER_SOCKET;
26
- if (envPath && envPath.length > 0)
27
- return envPath;
28
- return path.join(getHelpersDir(), 'computer.sock');
29
- }
30
- // Default log path for the launchd-managed daemon. Lives in the cache/logs/
31
- // bucket (matches the scheduler daemon's logs.jsonl convention).
32
- export function resolveLogPath() {
33
- return path.join(getLogsDir(), 'computer-helper.log');
34
- }
35
- // Policy file the helper reads at startup and on SIGHUP. Sibling of
36
- // computer.sock under ~/.agents/.cache/helpers/. Allow-list of bare bundle
37
- // ids (e.g. "com.apple.mail"), derived from Computer(...) patterns in
38
- // ~/.agents/permissions/groups/.
39
- export function resolvePolicyPath() {
40
- return path.join(getHelpersDir(), 'computer-policy.json');
41
- }
42
- // Walk all permission group YAMLs (user dir wins on name collision) and
43
- // collect Computer(<bundle-id>) patterns from each group's `allow:` list.
44
- // Returns distinct bundle ids. Line-by-line regex extraction matches
45
- // buildPermissionsFromGroups: YAML parsers stumble on the nested quotes in
46
- // some rule values, but the strict pattern below catches our shape cleanly.
47
- export function loadComputerAllowList() {
48
- const seenFiles = new Set();
49
- const allowed = new Set();
50
- for (const baseDir of [getUserPermissionsDir(), getPermissionsDir()]) {
51
- const groupsDir = path.join(baseDir, 'groups');
52
- if (!fs.existsSync(groupsDir))
53
- continue;
54
- let entries;
55
- try {
56
- entries = fs.readdirSync(groupsDir, { withFileTypes: true });
57
- }
58
- catch {
59
- continue;
60
- }
61
- for (const entry of entries) {
62
- if (!entry.isFile())
63
- continue;
64
- if (!entry.name.endsWith('.yml') && !entry.name.endsWith('.yaml'))
65
- continue;
66
- // User dir wins on filename collision.
67
- const stem = entry.name.replace(/\.(yaml|yml)$/, '');
68
- if (seenFiles.has(stem))
69
- continue;
70
- seenFiles.add(stem);
71
- const filePath = path.join(groupsDir, entry.name);
72
- let content;
73
- try {
74
- content = fs.readFileSync(filePath, 'utf-8');
75
- }
76
- catch {
77
- continue;
78
- }
79
- // Strict regex: optional whitespace, dash, quoted Computer(<id>).
80
- // Only honors `allow:` lines — `deny:` Computer patterns would be a
81
- // contradiction (everything is deny-by-default already).
82
- let inAllow = false;
83
- for (const rawLine of content.split('\n')) {
84
- const line = rawLine.replace(/\r$/, '');
85
- const sectionMatch = line.match(/^(\w+)\s*:\s*$/);
86
- if (sectionMatch) {
87
- inAllow = sectionMatch[1] === 'allow';
88
- continue;
89
- }
90
- if (!inAllow)
91
- continue;
92
- const ruleMatch = line.match(/^\s*-\s*"Computer\(([^)]+)\)"\s*$/);
93
- if (ruleMatch) {
94
- const bundleId = ruleMatch[1].trim();
95
- if (bundleId.length > 0)
96
- allowed.add(bundleId);
97
- }
98
- }
99
- }
100
- }
101
- return [...allowed].sort();
102
- }
103
- // Write the policy file the helper reads at startup and on SIGHUP.
104
- // Mode 0600 — same lockdown as the socket (lives in the user-owned cache
105
- // dir, but be explicit).
106
- export function writeComputerPolicy(allowedBundleIds) {
107
- const dir = getHelpersDir();
108
- if (!fs.existsSync(dir)) {
109
- fs.mkdirSync(dir, { recursive: true });
110
- }
111
- const policy = { allow: allowedBundleIds };
112
- fs.writeFileSync(resolvePolicyPath(), JSON.stringify(policy, null, 2), { mode: 0o600 });
113
- }
114
- // Peer-auth (F5): the helper reads a list of executable paths it will
115
- // accept connections from. Anything else — `nc`, `/usr/bin/python3`, a
116
- // random electron app — gets the socket closed before its first RPC.
117
- // File mirrors computer-policy.json: JSON, mode 0600, missing/unparseable
118
- // means deny-everything.
119
- export function resolvePeersPath() {
120
- return path.join(getHelpersDir(), 'computer-peers.json');
121
- }
122
- // Default peer set: this exact `agents` CLI binary plus Rush.app if it's
123
- // installed. realpath() the symlink chain so we record the on-disk path
124
- // the helper will see via proc_pidpath, not the shim path.
125
- //
126
- // Why path-based instead of codesign-team-id? The agents CLI is unsigned
127
- // today (npm distribution), and even if we sign Rush.app the team-id
128
- // check would need a separate roundtrip. Path is concrete and fast; the
129
- // daemon already runs as the user so anyone who can swap a binary at
130
- // these paths can do worse via other means.
131
- export function loadDefaultPeers() {
132
- const out = new Set();
133
- const add = (p) => {
134
- try {
135
- out.add(fs.realpathSync(p));
136
- }
137
- catch {
138
- out.add(p);
139
- }
140
- };
141
- // The Node executable currently running the CLI. This is what
142
- // proc_pidpath() will report when the CLI calls into the daemon.
143
- if (process.execPath)
144
- add(process.execPath);
145
- // Rush.app — the consumer Electron client. Both the helper-binary and
146
- // the main app binary are possible callers depending on how Rush wires
147
- // the RPC client.
148
- const rushCandidates = [
149
- '/Applications/Rush.app/Contents/MacOS/Rush',
150
- '/Applications/Rush.app/Contents/MacOS/Electron',
151
- ];
152
- for (const p of rushCandidates) {
153
- if (fs.existsSync(p))
154
- add(p);
155
- }
156
- return [...out].sort();
157
- }
158
- // Write the peer-auth allow list. Same mode 0600 + atomic-ish semantics
159
- // as the policy file. The daemon picks it up at startup and on SIGHUP.
160
- export function writeComputerPeers(allowedExecPaths) {
161
- const dir = getHelpersDir();
162
- if (!fs.existsSync(dir)) {
163
- fs.mkdirSync(dir, { recursive: true });
164
- }
165
- fs.writeFileSync(resolvePeersPath(), JSON.stringify({ allow: allowedExecPaths }, null, 2), { mode: 0o600 });
166
- }
167
- // Resolve the helper executable inside the dist .app bundle. Used by the stdio
168
- // fallback and by install-helper to find the source bundle. Only TRUSTED sources
169
- // are listed here: a local checkout build and the bundled tarball copy. The
170
- // download cache is deliberately NOT a candidate — a cached bundle is
171
- // user-writable and must only ever be read through downloadMacHelperApp(), which
172
- // re-verifies its signature + Team ID + notarization on every cache hit
173
- // (lib/computer/download.ts). Listing the cache here would let a same-user
174
- // process plant a differently-signed .app that install-helper trusts.
175
- export function resolveHelperExec() {
176
- const here = path.dirname(fileURLToPath(import.meta.url));
177
- const candidates = [
178
- // Local build (running from the agents-cli checkout). cli/dist/lib/computer -> repo root (4 up) -> native/computer-mac.
179
- path.resolve(here, '..', '..', '..', '..', 'native', 'computer-mac', 'dist', 'ComputerHelper.app', 'Contents', 'MacOS', 'ComputerHelper'),
180
- // Bundled with the npm package (dist/lib/computer -> dist/).
181
- path.resolve(here, '..', '..', 'computer-helper', 'ComputerHelper.app', 'Contents', 'MacOS', 'ComputerHelper'),
182
- ];
183
- for (const c of candidates) {
184
- if (fs.existsSync(c))
185
- return c;
186
- }
187
- return null;
188
- }
189
- // Resolve the dist .app bundle directory (not the inner exec).
190
- export function resolveHelperApp() {
191
- const exec = resolveHelperExec();
192
- if (!exec)
193
- return null;
194
- // exec = <bundle>/Contents/MacOS/ComputerHelper
195
- return path.resolve(exec, '..', '..', '..');
196
- }
197
- // Resolve the TCP endpoint for the Windows daemon (computer-helper-win), if
198
- // configured. The Windows helper binds loopback TCP (Program.cs) and the CLI
199
- // reaches it over an `ssh -L` tunnel, so the endpoint is a local forwarded
200
- // port. COMPUTER_HELPER_TCP is "host:port" (host defaults to 127.0.0.1);
201
- // COMPUTER_HELPER_TOKEN is the shared secret sent in the first `auth` frame.
202
- export function resolveTcpEndpoint() {
203
- const raw = process.env.COMPUTER_HELPER_TCP;
204
- if (!raw || raw.length === 0)
205
- return null;
206
- const [hostPart, portPart] = raw.includes(':') ? raw.split(':') : ['127.0.0.1', raw];
207
- const port = Number(portPart);
208
- if (!Number.isInteger(port) || port <= 0)
209
- return null;
210
- const token = process.env.COMPUTER_HELPER_TOKEN;
211
- return { host: hostPart || '127.0.0.1', port, token: token && token.length > 0 ? token : null };
212
- }
213
- // Resolve the VNC/RFB endpoint for driving a remote GUI desktop over the RFB
214
- // protocol (an x11vnc/Xvnc server — e.g. a headless Linux desktop or an LXD
215
- // container exposing x11vnc on the host's Tailscale IP). COMPUTER_HELPER_VNC is
216
- // "host:port" (port defaults to 5901); COMPUTER_HELPER_VNC_PASSWORD is the VNC
217
- // password. Unlike the native helpers this transport carries a framebuffer, not
218
- // an accessibility tree, so it is coordinate-based (see rfb-client.ts).
219
- export function resolveVncEndpoint() {
220
- const parsed = parseVncEndpoint(process.env.COMPUTER_HELPER_VNC);
221
- if (!parsed)
222
- return null;
223
- return { ...parsed, password: process.env.COMPUTER_HELPER_VNC_PASSWORD ?? '' };
224
- }
225
- // Pick the best transport. Precedence:
226
- // 1. COMPUTER_HELPER_VNC -> an RFB/VNC desktop (Linux GUI over the wire).
227
- // 2. COMPUTER_HELPER_TCP -> the Windows daemon over a (tunneled) TCP port.
228
- // 3. the macOS launchd socket if it exists.
229
- // 4. spawning the helper as a subprocess (legacy/dev fallback).
230
- export function openComputerClient() {
231
- const vnc = resolveVncEndpoint();
232
- if (vnc) {
233
- return new RfbClient(vnc.host, vnc.port, vnc.password);
234
- }
235
- const tcp = resolveTcpEndpoint();
236
- if (tcp) {
237
- return new TcpClient(tcp.host, tcp.port, tcp.token);
238
- }
239
- const sockPath = resolveSocketPath();
240
- if (fs.existsSync(sockPath)) {
241
- return new SocketClient(sockPath);
242
- }
243
- const helperExec = resolveHelperExec();
244
- if (!helperExec) {
245
- throw new Error('helper not built. Run: ./native/computer-mac/scripts/build.sh debug');
246
- }
247
- return new StdioClient(helperExec);
248
- }
249
- // Per-call RPC timeout. Without it a hung daemon (deadlocked connection
250
- // queue, stopped process) hangs the CLI forever — the waiter map never
251
- // settles. 30s clears every daemon-side ceiling (wait caps at 30s,
252
- // launch_app at 10s, screenshot at 5s). Overridable for slower flows.
253
- export const RPC_TIMEOUT_MS = 30_000;
254
- export function resolveRpcTimeoutMs(env) {
255
- const n = Number(env);
256
- return Number.isFinite(n) && n > 0 ? n : RPC_TIMEOUT_MS;
257
- }
258
- // Shared waiter map + line parser. Both transports plug their reader into
259
- // `handleChunk` and their writer into `send`.
260
- class BaseClient {
261
- buf = '';
262
- waiters = new Map();
263
- nextId = 1;
264
- closed = false;
265
- handleChunk(chunk) {
266
- this.buf += chunk;
267
- let nl;
268
- while ((nl = this.buf.indexOf('\n')) !== -1) {
269
- const line = this.buf.slice(0, nl);
270
- this.buf = this.buf.slice(nl + 1);
271
- if (!line)
272
- continue;
273
- try {
274
- const obj = JSON.parse(line);
275
- const id = typeof obj.id === 'number' ? obj.id : null;
276
- if (id !== null && this.waiters.has(id)) {
277
- const resolve = this.waiters.get(id);
278
- this.waiters.delete(id);
279
- resolve(obj);
280
- }
281
- }
282
- catch {
283
- // Drop garbage; helper diagnostics go to stderr / log file.
284
- }
285
- }
286
- }
287
- failPending(code, message) {
288
- for (const [id, resolve] of this.waiters) {
289
- resolve({ id, error: { code, message } });
290
- }
291
- this.waiters.clear();
292
- }
293
- async call(method, params) {
294
- if (this.closed) {
295
- return { id: null, error: { code: 'helper_exited', message: 'helper not running' } };
296
- }
297
- const id = this.nextId++;
298
- const payload = JSON.stringify({ id, method, params: params ?? {} }) + '\n';
299
- const timeoutMs = resolveRpcTimeoutMs(process.env.COMPUTER_HELPER_RPC_TIMEOUT_MS);
300
- return new Promise((resolve) => {
301
- const timer = setTimeout(() => {
302
- if (this.waiters.delete(id)) {
303
- resolve({ id, error: { code: 'rpc_timeout', message: `helper did not respond within ${timeoutMs}ms` } });
304
- }
305
- }, timeoutMs);
306
- // Resolve as an error (never reject) so callers flow through unwrap()
307
- // uniformly, matching failPending's contract.
308
- this.waiters.set(id, (r) => {
309
- clearTimeout(timer);
310
- resolve(r);
311
- });
312
- this.send(payload);
313
- });
314
- }
315
- }
316
- class SocketClient extends BaseClient {
317
- sock;
318
- constructor(socketPath) {
319
- super();
320
- this.sock = createConnection({ path: socketPath });
321
- this.sock.setEncoding('utf8');
322
- this.sock.on('data', (chunk) => this.handleChunk(chunk));
323
- this.sock.on('error', (err) => {
324
- this.closed = true;
325
- this.failPending('socket_error', err.message);
326
- });
327
- this.sock.on('close', () => {
328
- this.closed = true;
329
- this.failPending('helper_exited', 'socket closed before reply');
330
- });
331
- }
332
- send(payload) {
333
- this.sock.write(payload);
334
- }
335
- async close() {
336
- if (this.closed)
337
- return;
338
- this.sock.end();
339
- await new Promise((resolve) => {
340
- if (this.closed)
341
- return resolve();
342
- this.sock.on('close', () => resolve());
343
- });
344
- }
345
- }
346
- // TCP transport for the Windows daemon (computer-helper-win). The daemon
347
- // binds loopback only (Program.cs); the CLI reaches it over an `ssh -L`
348
- // tunnel, so `host` is typically 127.0.0.1 + a forwarded port. When a token
349
- // is configured the daemon accepts only an `auth` frame until authenticated,
350
- // so we send that first and gate every other call on it.
351
- class TcpClient extends BaseClient {
352
- sock;
353
- authReady;
354
- constructor(host, port, token) {
355
- super();
356
- this.sock = createConnection({ host, port });
357
- this.sock.setEncoding('utf8');
358
- this.sock.on('data', (chunk) => this.handleChunk(chunk));
359
- this.sock.on('error', (err) => {
360
- this.closed = true;
361
- this.failPending('socket_error', err.message);
362
- });
363
- this.sock.on('close', () => {
364
- this.closed = true;
365
- this.failPending('helper_exited', 'tcp connection closed before reply');
366
- });
367
- // Kick off the auth handshake synchronously so its frame (id 1) is the
368
- // first thing written. The Windows daemon now REQUIRES a token (it refuses to
369
- // start without one); a null token here means no auth frame is sent, the
370
- // daemon drops the connection, and the caller is told to re-run `setup`.
371
- this.authReady = token ? this.authenticate(token) : Promise.resolve();
372
- }
373
- async authenticate(token) {
374
- const res = await super.call('auth', { token });
375
- if (res.error)
376
- throw new Error(`computer-helper auth failed: ${res.error.code}`);
377
- }
378
- async call(method, params) {
379
- if (method !== 'auth') {
380
- try {
381
- await this.authReady;
382
- }
383
- catch (e) {
384
- return { id: null, error: { code: 'auth_failed', message: e.message } };
385
- }
386
- }
387
- return super.call(method, params);
388
- }
389
- send(payload) {
390
- this.sock.write(payload);
391
- }
392
- async close() {
393
- if (this.closed)
394
- return;
395
- this.sock.end();
396
- await new Promise((resolve) => {
397
- if (this.closed)
398
- return resolve();
399
- this.sock.on('close', () => resolve());
400
- });
401
- }
402
- }
403
- class StdioClient extends BaseClient {
404
- proc;
405
- constructor(helperPath) {
406
- super();
407
- this.proc = spawn(helperPath, [], { stdio: ['pipe', 'pipe', 'pipe'] });
408
- this.proc.stdout.setEncoding('utf8');
409
- this.proc.stdout.on('data', (chunk) => this.handleChunk(chunk));
410
- this.proc.on('exit', () => {
411
- this.closed = true;
412
- this.failPending('helper_exited', 'helper exited before reply');
413
- });
414
- }
415
- send(payload) {
416
- this.proc.stdin.write(payload);
417
- }
418
- async close() {
419
- if (this.closed)
420
- return;
421
- this.proc.stdin.end();
422
- await new Promise((resolve) => {
423
- if (this.closed)
424
- return resolve();
425
- this.proc.on('exit', () => resolve());
426
- });
427
- }
428
- }
429
- // Describe which transport is currently in use. Useful for diagnostics
430
- // like `agents computer status`. TCP takes precedence to match
431
- // openComputerClient() — when COMPUTER_HELPER_TCP is set we drive a remote
432
- // (Windows) daemon over a tunnel, so callers off macOS (no socket, no local
433
- // .app) must not be told "no transport". `path` is null: the endpoint is an
434
- // env-configured host:port, not an on-disk path.
435
- export function describeTransport() {
436
- if (resolveVncEndpoint())
437
- return { kind: 'vnc', path: null };
438
- if (resolveTcpEndpoint())
439
- return { kind: 'tcp', path: null };
440
- const sockPath = resolveSocketPath();
441
- if (fs.existsSync(sockPath))
442
- return { kind: 'socket', path: sockPath };
443
- const exec = resolveHelperExec();
444
- if (exec)
445
- return { kind: 'stdio', path: exec };
446
- return { kind: 'none', path: null };
447
- }
@@ -1 +0,0 @@
1
- export declare function desEncryptBlock(key8: Buffer, block8: Buffer): Buffer;