@phnx-labs/agents-cli 1.22.103 → 1.22.104
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/CHANGELOG.md +27 -0
- package/README.md +1 -1
- package/dist/commands/accounts.js +2 -2
- package/dist/commands/computer.d.ts +100 -79
- package/dist/commands/computer.js +290 -830
- package/dist/commands/setup-computer.js +20 -1
- package/dist/commands/setup-secrets.d.ts +2 -2
- package/dist/commands/setup-secrets.js +1 -1
- package/dist/commands/view.js +4 -6
- package/dist/lib/account-catalog.d.ts +22 -1
- package/dist/lib/account-catalog.js +72 -38
- package/dist/lib/accounting/usage.js +18 -14
- package/dist/lib/agent-spec/agents.d.ts +1 -0
- package/dist/lib/agent-spec/agents.js +1 -1
- package/dist/lib/browser/drivers/ssh.js +1 -1
- package/dist/lib/computer/context.d.ts +83 -0
- package/dist/lib/computer/context.js +91 -0
- package/dist/lib/computer/policy.d.ts +46 -0
- package/dist/lib/computer/policy.js +160 -0
- package/dist/lib/computer/record.d.ts +38 -0
- package/dist/lib/computer/record.js +86 -0
- package/dist/lib/computer/sessions-list.js +6 -6
- package/dist/lib/computer-client.d.ts +150 -0
- package/dist/lib/computer-client.js +222 -0
- package/dist/lib/exec.js +35 -1
- package/dist/lib/harness/adapters/claude.d.ts +37 -0
- package/dist/lib/harness/adapters/claude.js +69 -0
- package/dist/lib/helper-download.d.ts +1 -1
- package/dist/lib/helper-download.js +1 -1
- package/dist/lib/helper-versions.d.ts +9 -4
- package/dist/lib/helper-versions.js +8 -8
- package/dist/lib/installations/shims.js +8 -4
- package/dist/lib/menubar/download-menubar.d.ts +2 -1
- package/dist/lib/menubar/download-menubar.js +2 -1
- package/dist/lib/secrets-client.d.ts +3 -3
- package/dist/lib/secrets-client.js +15 -38
- package/dist/lib/session/db.js +1 -1
- package/dist/lib/sha256-asset.d.ts +2 -1
- package/dist/lib/sha256-asset.js +2 -1
- package/dist/lib/ssh-tunnel.d.ts +61 -0
- package/dist/lib/ssh-tunnel.js +105 -0
- package/dist/lib/summarizer/summarize.d.ts +2 -2
- package/dist/lib/summarizer/summarize.js +10 -3
- package/package.json +2 -3
- package/dist/commands/computer-actions.d.ts +0 -55
- package/dist/commands/computer-actions.js +0 -594
- package/dist/computer.d.ts +0 -2
- package/dist/computer.js +0 -7
- package/dist/lib/computer/actions.d.ts +0 -36
- package/dist/lib/computer/actions.js +0 -162
- package/dist/lib/computer/computer-rpc.d.ts +0 -39
- package/dist/lib/computer/computer-rpc.js +0 -447
- package/dist/lib/computer/des.d.ts +0 -1
- package/dist/lib/computer/des.js +0 -114
- package/dist/lib/computer/dispatch.d.ts +0 -10
- package/dist/lib/computer/dispatch.js +0 -133
- package/dist/lib/computer/download.d.ts +0 -54
- package/dist/lib/computer/download.js +0 -83
- package/dist/lib/computer/loop.d.ts +0 -62
- package/dist/lib/computer/loop.js +0 -98
- package/dist/lib/computer/model.d.ts +0 -44
- package/dist/lib/computer/model.js +0 -157
- package/dist/lib/computer/rfb-client.d.ts +0 -53
- package/dist/lib/computer/rfb-client.js +0 -562
- package/dist/lib/computer/ssh-tunnel.d.ts +0 -189
- 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;
|