@phnx-labs/agents-cli 1.22.102 → 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.
Files changed (76) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +1 -1
  3. package/dist/bootstrap.js +15 -0
  4. package/dist/commands/accounts.js +2 -2
  5. package/dist/commands/computer.d.ts +100 -79
  6. package/dist/commands/computer.js +290 -830
  7. package/dist/commands/setup-computer.js +20 -1
  8. package/dist/commands/setup-secrets.d.ts +2 -2
  9. package/dist/commands/setup-secrets.js +1 -1
  10. package/dist/commands/view.js +4 -6
  11. package/dist/lib/account-catalog.d.ts +22 -1
  12. package/dist/lib/account-catalog.js +72 -38
  13. package/dist/lib/accounting/usage.js +18 -14
  14. package/dist/lib/agent-spec/agents.d.ts +1 -0
  15. package/dist/lib/agent-spec/agents.js +1 -1
  16. package/dist/lib/browser/drivers/ssh.js +1 -1
  17. package/dist/lib/computer/context.d.ts +83 -0
  18. package/dist/lib/computer/context.js +91 -0
  19. package/dist/lib/computer/policy.d.ts +46 -0
  20. package/dist/lib/computer/policy.js +160 -0
  21. package/dist/lib/computer/record.d.ts +38 -0
  22. package/dist/lib/computer/record.js +86 -0
  23. package/dist/lib/computer/sessions-list.js +6 -6
  24. package/dist/lib/computer-client.d.ts +150 -0
  25. package/dist/lib/computer-client.js +222 -0
  26. package/dist/lib/exec.js +35 -1
  27. package/dist/lib/harness/adapters/claude.d.ts +37 -0
  28. package/dist/lib/harness/adapters/claude.js +69 -0
  29. package/dist/lib/helper-download.d.ts +1 -1
  30. package/dist/lib/helper-download.js +1 -1
  31. package/dist/lib/helper-versions.d.ts +9 -4
  32. package/dist/lib/helper-versions.js +9 -9
  33. package/dist/lib/installations/shims.js +8 -4
  34. package/dist/lib/menubar/download-menubar.d.ts +2 -1
  35. package/dist/lib/menubar/download-menubar.js +2 -1
  36. package/dist/lib/menubar/install-menubar.d.ts +56 -0
  37. package/dist/lib/menubar/install-menubar.js +133 -17
  38. package/dist/lib/menubar/resolve-version.d.ts +82 -0
  39. package/dist/lib/menubar/resolve-version.js +133 -0
  40. package/dist/lib/profiles.js +13 -2
  41. package/dist/lib/secrets-client.d.ts +23 -3
  42. package/dist/lib/secrets-client.js +57 -57
  43. package/dist/lib/self-heal/checks/menubar-helper.d.ts +2 -0
  44. package/dist/lib/self-heal/checks/menubar-helper.js +21 -0
  45. package/dist/lib/self-heal/registry.js +3 -0
  46. package/dist/lib/self-heal/types.d.ts +1 -1
  47. package/dist/lib/session/db.js +1 -1
  48. package/dist/lib/sha256-asset.d.ts +2 -1
  49. package/dist/lib/sha256-asset.js +2 -1
  50. package/dist/lib/ssh-tunnel.d.ts +61 -0
  51. package/dist/lib/ssh-tunnel.js +105 -0
  52. package/dist/lib/summarizer/summarize.d.ts +2 -2
  53. package/dist/lib/summarizer/summarize.js +10 -3
  54. package/package.json +2 -3
  55. package/dist/commands/computer-actions.d.ts +0 -55
  56. package/dist/commands/computer-actions.js +0 -594
  57. package/dist/computer.d.ts +0 -2
  58. package/dist/computer.js +0 -7
  59. package/dist/lib/computer/actions.d.ts +0 -36
  60. package/dist/lib/computer/actions.js +0 -162
  61. package/dist/lib/computer/computer-rpc.d.ts +0 -39
  62. package/dist/lib/computer/computer-rpc.js +0 -447
  63. package/dist/lib/computer/des.d.ts +0 -1
  64. package/dist/lib/computer/des.js +0 -114
  65. package/dist/lib/computer/dispatch.d.ts +0 -10
  66. package/dist/lib/computer/dispatch.js +0 -133
  67. package/dist/lib/computer/download.d.ts +0 -54
  68. package/dist/lib/computer/download.js +0 -83
  69. package/dist/lib/computer/loop.d.ts +0 -62
  70. package/dist/lib/computer/loop.js +0 -98
  71. package/dist/lib/computer/model.d.ts +0 -44
  72. package/dist/lib/computer/model.js +0 -157
  73. package/dist/lib/computer/rfb-client.d.ts +0 -53
  74. package/dist/lib/computer/rfb-client.js +0 -562
  75. package/dist/lib/computer/ssh-tunnel.d.ts +0 -189
  76. package/dist/lib/computer/ssh-tunnel.js +0 -584
@@ -1,20 +1,49 @@
1
- import { execFileSync } from 'child_process';
2
- import * as fs from 'fs';
3
- import * as os from 'os';
4
- import * as path from 'path';
5
- import { registerCommandGroups } from '../lib/help.js';
6
- import { openComputerClient, resolveHelperApp, resolveHelperExec, resolveSocketPath, resolveLogPath, resolvePolicyPath, resolvePeersPath, resolveTcpEndpoint, resolveVncEndpoint, loadComputerAllowList, loadDefaultPeers, writeComputerPolicy, writeComputerPeers, } from '../lib/computer/computer-rpc.js';
7
- import { setupRemoteHelper, startRemoteTunnel, stopRemoteHelper, hydrateRemoteEnvFromState, readRemoteState, resolveRemoteDevice, REMOTE_TASK_NAME, WIN_HELPER_EXE, } from '../lib/computer/ssh-tunnel.js';
8
- import { sshExec } from '../lib/ssh-exec.js';
9
- import { encodePowershell } from '../lib/hosts/remote-cmd.js';
10
- import { registerActionCommands, withClient, unwrap, pickTarget, emitComputerAction } from './computer-actions.js';
11
- import { runComputerLoop } from '../lib/computer/loop.js';
12
- import { makeVerbDispatcher } from '../lib/computer/dispatch.js';
13
- import { makeClaudeResponder, resolveApiKey, DEFAULT_CLAUDE_MODEL, DEFAULT_CLAUDE_BASE_URL } from '../lib/computer/model.js';
14
- import { TASK_PREVIEW_MAX_CHARS } from '../lib/computer/sessions-list.js';
1
+ /**
2
+ * `agents computer` — the consumer surface over the standalone `computer` CLI
3
+ * (PHNX-4075).
4
+ *
5
+ * WHAT THIS FILE IS NOW. Every verb below forwards its arguments verbatim to the
6
+ * standalone engine and propagates its exit code. agents-cli contributes four
7
+ * things the engine cannot know:
8
+ *
9
+ * 1. the permissions allow list, rendered from `Computer(<bundle-id>)` rules
10
+ * in the agents resource layer (`lib/computer/policy.ts`);
11
+ * 2. the peer allow list, and `--device <name>` resolved against the fleet —
12
+ * both carried in the fd-3 context (`lib/computer/context.ts`);
13
+ * 3. the acting actor and agent session, likewise on fd 3;
14
+ * 4. a recorder for the action events the engine streams back on fd 4, so
15
+ * `agents computer sessions` and `agents sessions --computer` keep their
16
+ * history (`lib/computer/record.ts`).
17
+ *
18
+ * The remote path is the ENGINE's. `--device <name>` is resolved against the
19
+ * fleet here — that is what the registry, ssh identity and platform check are
20
+ * for — and then forwarded to the engine verbatim, which matches the alias
21
+ * against `context.target` and owns everything downstream of it: pushing the
22
+ * Windows helper, minting and storing its auth token, opening the `ssh -L`
23
+ * tunnel, and hydrating its own transport from the state it wrote. agents-cli
24
+ * keeps no tunnel state and publishes no endpoint: a `COMPUTER_HELPER_TCP` from
25
+ * here would carry a port without the token the daemon demands, so the verb
26
+ * would fail `auth_failed` against a tunnel that was up the whole time.
27
+ *
28
+ * WHY VERB FLAGS ARE NOT REDECLARED HERE. Each passthrough verb declares only
29
+ * `--device` — the one flag the consumer must intercept — and takes everything
30
+ * else as opaque operands via `allowUnknownOption`. Mirroring the engine's flags
31
+ * would create a second, silently drifting copy of its surface: a flag added
32
+ * upstream would be rejected here as unknown until someone noticed. The engine
33
+ * also owns per-verb `--help` for the same reason. What agents-cli keeps is the
34
+ * verb CATALOG — names, one-line descriptions, help groups — because that is
35
+ * what makes the surface discoverable from `agents computer --help`, and a
36
+ * verb the engine drops should fail loud here rather than silently vanish.
37
+ *
38
+ * `sessions` is the one verb that never reaches the engine: it reads agents-cli's
39
+ * own event ledger.
40
+ */
41
+ import { registerCommandGroups, setHelpSections } from '../lib/help.js';
42
+ import { loadComputerAllowList, loadDefaultPeers, resolveTcpEndpoint, resolveVncEndpoint, } from '../lib/computer/policy.js';
43
+ import { buildComputerContext } from '../lib/computer/context.js';
44
+ import { recordComputerAction } from '../lib/computer/record.js';
45
+ import { isComputerClientError, resolveComputerBin, runComputer, } from '../lib/computer-client.js';
15
46
  import { runComputerSessionsCommand } from './computer-sessions-picker.js';
16
- import { truncate } from '../lib/feed/events.js';
17
- import { namespacedServiceLabel, serviceManifestHomeEnv, serviceManagerRegistrationAllowed } from '../lib/service-manifest.js';
18
47
  // Help groups — mirror `agents browser` so the mental model carries over.
19
48
  const COMPUTER_HELP_GROUPS = [
20
49
  { title: 'Installation', names: ['setup'] },
@@ -24,11 +53,33 @@ const COMPUTER_HELP_GROUPS = [
24
53
  { title: 'Interact', names: ['launch', 'raise', 'click', 'right-click', 'type', 'type-text', 'key', 'drag', 'scroll', 'ax-action', 'focus', 'wait'] },
25
54
  { title: 'History and discovery', names: ['sessions'] },
26
55
  ];
27
- // Subcommands that manage the `--device` remote path themselves (provisioning /
28
- // tunnel lifecycle, or daemon-state reporting that must degrade gracefully
29
- // when no tunnel is recorded). Every other `--device`-bearing subcommand is a
30
- // plain verb that just needs the TCP endpoint hydrated before it runs.
31
- const REMOTE_LIFECYCLE = new Set(['setup', 'start', 'stop', 'status', 'reload']);
56
+ /**
57
+ * The verb catalog. Descriptions are the consumer's (they appear in
58
+ * `agents computer --help`); flags are the engine's.
59
+ *
60
+ * This list is the contract with the engine: `computer --verbs` must report the
61
+ * same names. `computer.test.ts` pins it so a drift shows up as a failing test
62
+ * rather than a verb that quietly stops existing.
63
+ */
64
+ export const COMPUTER_PASSTHROUGH_VERBS = [
65
+ { name: 'run', description: 'Autonomously drive an app from a natural-language task (model loop over the computer verbs)' },
66
+ { name: 'apps', description: 'List running apps the policy allows, with pid and bundle id' },
67
+ { name: 'describe', description: 'Dump an app\'s accessibility tree — the element ids the interact verbs target' },
68
+ { name: 'screenshot', description: 'Capture a window (default: largest), enumerate windows (--list), or the whole display (--display)' },
69
+ { name: 'get-text', description: 'Read the text content of an element or a whole window' },
70
+ { name: 'launch', description: 'Launch an allow-listed app by bundle id and wait for it to be ready' },
71
+ { name: 'raise', description: 'Bring an app to the front' },
72
+ { name: 'click', description: 'Click an element by id, or a coordinate pair' },
73
+ { name: 'right-click', description: 'Right-click an element by id, or a coordinate pair' },
74
+ { name: 'type', description: 'Type into a focused element by id' },
75
+ { name: 'type-text', description: 'Type a literal string at the current focus' },
76
+ { name: 'key', description: 'Send a key or chord (e.g. cmd+s, escape)' },
77
+ { name: 'drag', description: 'Drag from one point or element to another' },
78
+ { name: 'scroll', description: 'Scroll an element or the window under a coordinate' },
79
+ { name: 'ax-action', description: 'Perform a raw accessibility action on an element' },
80
+ { name: 'focus', description: 'Move keyboard focus to an element' },
81
+ { name: 'wait', description: 'Wait for an element or condition to appear before continuing' },
82
+ ];
32
83
  /**
33
84
  * Pure platform gate. The computer subsystem is macOS-only for LOCAL driving
34
85
  * (Accessibility / launchctl). It is NOT blocked off macOS when a remote daemon
@@ -48,40 +99,59 @@ export function shouldBlockOffPlatform(opts) {
48
99
  return true;
49
100
  }
50
101
  /**
51
- * Sniff the image format from the leading magic bytes: PNG starts with the
52
- * 8-byte signature `89 50 4E 47` ("\x89PNG"), JPEG with `FF D8 FF`. Returns the
53
- * canonical file extension, or null for anything else.
102
+ * Put `--device <name>` back on the argv handed to the engine.
103
+ *
104
+ * commander CONSUMES the `--device` it declares, so a verb that only read
105
+ * `opts.device` forwarded an argv with no remote selector in it and the engine
106
+ * — which selects the remote path from its own argv — ran the invocation
107
+ * LOCALLY. That is how `setup --device win-mini` installed the macOS helper on
108
+ * the laptop. The flag is re-inserted immediately after the verb rather than
109
+ * appended, so a verb whose operands are variadic cannot swallow it.
110
+ *
111
+ * Pure, so the re-insertion is testable without spawning the engine.
54
112
  */
55
- export function detectImageFormat(buf) {
56
- if (buf.length >= 4 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47)
57
- return '.png';
58
- if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff)
59
- return '.jpg';
60
- return null;
113
+ export function withDeviceFlag(argv, device) {
114
+ if (!device)
115
+ return argv;
116
+ const [verb, ...rest] = argv;
117
+ return [verb, '--device', device, ...rest];
61
118
  }
62
119
  /**
63
- * Make the screenshot filename honest about its bytes. The two helper backends
64
- * encode DIFFERENT formats and neither re-encodes to match the requested name:
65
- * the macOS helper (ScreenCaptureKit) returns JPEG
66
- * (native/computer-mac/Sources/ComputerHelper/Screenshot.swift:207,212),
67
- * the Windows helper returns PNG
68
- * (native/computer-win/Screenshot.cs:33). So a fixed default extension
69
- * cannot be correct for both — the only honest path is to sniff the real format
70
- * and swap the extension to match. Pure so it's unit-testable.
120
+ * Forward one invocation to the engine and propagate its exit code.
71
121
  *
72
- * Returns the path to write to (caller's path with its extension corrected) and
73
- * whether a correction was made. Unknown formats pass through unchanged.
122
+ * A missing standalone is the one failure agents-cli reports itself, because it
123
+ * is the one the engine cannot: it prints the install line and exits 1. There is
124
+ * no fallback engine to reach for — that is the point of the extraction.
74
125
  */
75
- export function reconcileScreenshotExt(outPath, buf) {
76
- const actual = detectImageFormat(buf);
77
- if (!actual)
78
- return { path: outPath, corrected: false };
79
- const cur = path.extname(outPath).toLowerCase();
80
- const alreadyMatches = cur === actual || (actual === '.jpg' && (cur === '.jpg' || cur === '.jpeg'));
81
- if (alreadyMatches)
82
- return { path: outPath, corrected: false };
83
- const base = outPath.slice(0, outPath.length - path.extname(outPath).length);
84
- return { path: base + actual, corrected: true };
126
+ export async function forwardToComputer(opts) {
127
+ let bin;
128
+ try {
129
+ bin = resolveComputerBin();
130
+ }
131
+ catch (err) {
132
+ if (isComputerClientError(err)) {
133
+ console.error(err.message);
134
+ return { exitCode: 1, stdout: '' };
135
+ }
136
+ throw err;
137
+ }
138
+ const hostFlag = opts.argv.findIndex(arg => arg === '--host' || arg.startsWith('--host='));
139
+ const host = hostFlag < 0 ? undefined : (opts.argv[hostFlag].includes('=') ? opts.argv[hostFlag].slice(7) : opts.argv[hostFlag + 1]);
140
+ const context = await buildComputerContext({ device: opts.device, host, computerBin: bin });
141
+ return runComputer({
142
+ argv: withDeviceFlag(opts.argv, opts.device),
143
+ context,
144
+ capture: opts.capture,
145
+ onEvent: opts.record === false
146
+ ? undefined
147
+ : (event) => recordComputerAction(event, { device: opts.device }),
148
+ });
149
+ }
150
+ /** Forward, then exit with the engine's status so shells and agents see the truth. */
151
+ async function forwardAndExit(opts) {
152
+ const { exitCode } = await forwardToComputer(opts);
153
+ if (exitCode !== 0)
154
+ process.exit(exitCode);
85
155
  }
86
156
  export function registerComputerCommand(program) {
87
157
  const computer = program
@@ -104,16 +174,11 @@ export function registerComputerCommand(program) {
104
174
  process.env.COMPUTER_HELPER_VNC_PASSWORD = globals.vncPassword;
105
175
  }
106
176
  const device = globals.device;
107
- // Verbs with --device reconnect to the tunnel `start --device` recorded;
108
- // this sets COMPUTER_HELPER_TCP so the shared client picks the TCP transport.
109
- if (device && !REMOTE_LIFECYCLE.has(actionCommand.name())) {
110
- hydrateRemoteEnvFromState(device);
111
- }
112
177
  if (shouldBlockOffPlatform({
113
178
  platform: process.platform,
114
179
  tcpConfigured: resolveTcpEndpoint() != null,
115
180
  vncConfigured: resolveVncEndpoint() != null,
116
- device,
181
+ device: device || (actionCommand.args.some(arg => arg === '--host' || arg.startsWith('--host=')) ? 'direct-host' : undefined),
117
182
  })) {
118
183
  console.error('agents computer: macOS only for local driving — it uses the macOS Accessibility API.');
119
184
  console.error('For a Linux GUI desktop over VNC: `agents computer --vnc <host:port> screenshot`.');
@@ -123,6 +188,41 @@ export function registerComputerCommand(program) {
123
188
  });
124
189
  registerComputerSubcommands(computer);
125
190
  registerCommandGroups(computer, COMPUTER_HELP_GROUPS);
191
+ setHelpSections(computer, {
192
+ examples: `
193
+ # One-time: install the engine, the helper, and the TCC grants
194
+ npm i -g @phnx-labs/computer-cli
195
+ agents setup computer
196
+
197
+ # Allow an app, then reload so the daemon picks it up
198
+ # ~/.agents/permissions/groups/computer.yaml: allow: ["Computer(com.apple.notes)"]
199
+ agents computer reload
200
+
201
+ # Observe, then act
202
+ agents computer apps --json
203
+ agents computer describe --bundle com.apple.notes
204
+ agents computer click --bundle com.apple.notes --id <element-id>
205
+
206
+ # A remote Windows device over the fleet
207
+ agents computer setup --device win-mini
208
+ agents computer start --device win-mini
209
+ agents computer screenshot --device win-mini -o /tmp/win.png
210
+ agents computer stop --device win-mini
211
+ `,
212
+ notes: `
213
+ The engine is the standalone \`computer\` CLI (npm i -g @phnx-labs/computer-cli);
214
+ agents-cli supplies the permission allow list, --device fleet resolution, and
215
+ the session/feed history. Per-verb flags are the engine's — \`agents computer
216
+ click --help\` asks it directly.
217
+
218
+ Apps are deny-by-default: a verb only reaches an app named by a
219
+ Computer(<bundle-id>) rule in ~/.agents/permissions/groups/. Edit a group,
220
+ then \`agents computer reload\`.
221
+
222
+ \`agents computer sessions\` (and \`agents sessions --computer\`) reads the
223
+ action history agents-cli records — it never leaves this CLI.
224
+ `,
225
+ });
126
226
  }
127
227
  export function registerComputerSubcommands(program) {
128
228
  registerSetupCommand(program);
@@ -130,229 +230,114 @@ export function registerComputerSubcommands(program) {
130
230
  registerStopCommand(program);
131
231
  registerReloadCommand(program);
132
232
  registerStatusCommand(program);
133
- registerRunCommand(program);
134
- registerScreenshotCommand(program);
135
- registerActionCommands(program);
233
+ registerPassthroughVerbs(program);
136
234
  registerSessionsCommand(program);
137
235
  registerCommandGroups(program, COMPUTER_HELP_GROUPS);
138
236
  }
139
- function registerStatusCommand(program) {
237
+ /**
238
+ * Register every plain verb as an opaque forwarder.
239
+ *
240
+ * `allowUnknownOption` is what makes this thin: commander stops trying to parse
241
+ * flags it does not own and hands them through in `cmd.args`, so the engine's
242
+ * flag surface can grow without a matching edit here.
243
+ */
244
+ function registerPassthroughVerbs(program) {
245
+ for (const verb of COMPUTER_PASSTHROUGH_VERBS) {
246
+ program
247
+ .command(verb.name)
248
+ .description(verb.description)
249
+ .option('--device <name>', 'Drive a remote Windows device registered with `agents devices` (the engine connects or provisions it on demand)')
250
+ .allowUnknownOption(true)
251
+ .allowExcessArguments(true)
252
+ .helpOption(false)
253
+ .action(async (opts, cmd) => {
254
+ await forwardAndExit({ argv: [verb.name, ...cmd.args], device: opts.device });
255
+ });
256
+ }
257
+ }
258
+ function registerSetupCommand(program) {
140
259
  program
141
- .command('status')
142
- .description('Report install state, daemon state, and Accessibility trust — or a remote Windows daemon with --device')
143
- .option('--device <name>', 'Report the remote Windows daemon (tunnel + liveness) instead of the local helper')
144
- .action(async (opts) => {
145
- if (opts.device) {
146
- await reportRemoteStatus(opts.device);
147
- return;
148
- }
149
- const socketPath = resolveSocketPath();
150
- const installed = fs.existsSync(HELPER_APP_DEST);
151
- const socketUp = fs.existsSync(socketPath);
152
- console.log(`installed: ${installed ? 'yes' : 'no'} (${HELPER_APP_DEST})`);
153
- console.log(`daemon: ${socketUp ? 'running' : 'stopped'}`);
154
- // Show the current allow list — what the user has actually authorized
155
- // via Computer(...) patterns in their permission groups.
156
- const allowed = loadComputerAllowList();
157
- const previewParts = allowed.slice(0, 5);
158
- const previewSuffix = allowed.length > 5 ? ` (+${allowed.length - 5} more)` : '';
159
- console.log(`policy: ${allowed.length} app${allowed.length === 1 ? '' : 's'} allowed${allowed.length > 0 ? `: ${previewParts.join(', ')}${previewSuffix}` : ''}`);
160
- const callers = loadDefaultPeers();
161
- console.log(`peers: ${callers.length} caller${callers.length === 1 ? '' : 's'} (peer-auth on socket)`);
162
- if (!installed) {
163
- console.log('');
164
- console.log('Run: agents computer setup');
165
- return;
166
- }
167
- if (!socketUp) {
168
- console.log('');
169
- console.log('Run: agents computer start');
170
- return;
171
- }
172
- // Daemon is up — probe trust state.
173
- const client = openComputerClient();
174
- try {
175
- const r = await client.call('trust_status');
176
- if (r.error) {
177
- console.error(`error: ${r.error.code}: ${r.error.message}`);
178
- process.exit(1);
179
- }
180
- const trusted = Boolean(r.result?.trusted);
181
- const helperPid = r.result?.pid;
182
- console.log(`trust: ${trusted ? 'granted' : 'denied'}`);
183
- if (typeof helperPid === 'number')
184
- console.log(`pid: ${helperPid}`);
185
- if (!trusted) {
186
- console.log('');
187
- console.log('Grant Accessibility + Screen Recording in System Settings, then `agents computer start` again.');
188
- }
189
- }
190
- finally {
191
- await client.close();
192
- }
260
+ .command('setup')
261
+ .alias('install-helper')
262
+ .description('Install the helper — locally to /Applications/ (macOS), or to a remote Windows device with --device')
263
+ .option('--device <name>', 'Provision a remote Windows device (push the exe + register a LOGON task) instead of installing locally')
264
+ .allowUnknownOption(true)
265
+ .allowExcessArguments(true)
266
+ .helpOption(false)
267
+ .action(async (opts, cmd) => {
268
+ await forwardAndExit({ argv: ['setup', ...cmd.args], device: opts.device, record: false });
193
269
  });
194
270
  }
195
- // status --device: the local checks (app install, launchd socket, policy files)
196
- // are macOS concepts — a remote Windows daemon is reported from what actually
197
- // exists for it: the recorded tunnel and a live trust_status probe through it.
198
- async function reportRemoteStatus(device) {
199
- console.log(`device: ${device}`);
200
- const state = readRemoteState(device);
201
- if (!state) {
202
- console.log('tunnel: none');
203
- console.log('daemon: unknown (no tunnel to probe through)');
204
- console.log('');
205
- console.log(`Run: agents computer start --device ${device}`);
206
- process.exit(1);
207
- }
208
- console.log(`tunnel: 127.0.0.1:${state.localPort} -> ${state.target} (127.0.0.1:${state.remotePort})`);
209
- hydrateRemoteEnvFromState(device);
210
- try {
211
- const client = openComputerClient();
212
- try {
213
- const r = await client.call('trust_status');
214
- if (r.error) {
215
- console.error(`error: ${r.error.code}: ${r.error.message}`);
216
- process.exit(1);
217
- }
218
- console.log('daemon: running');
219
- console.log(`trust: ${r.result?.trusted ? 'granted' : 'denied'} (Windows UIAutomation needs no per-app grant)`);
220
- if (typeof r.result?.pid === 'number')
221
- console.log(`pid: ${r.result.pid}`);
222
- if (typeof r.result?.path === 'string' && r.result.path)
223
- console.log(`exe: ${r.result.path}`);
224
- }
225
- finally {
226
- await client.close();
227
- }
228
- }
229
- catch (err) {
230
- console.log('daemon: unreachable');
231
- console.log(` ${err.message}`);
232
- console.log('');
233
- console.log(`Run: agents computer start --device ${device}`);
234
- process.exit(1);
235
- }
271
+ function registerStartCommand(program) {
272
+ program
273
+ .command('start')
274
+ .description('Activate the helper daemon — local launchd (macOS) or a remote Windows tunnel with --device')
275
+ .option('--device <name>', 'Start the remote Windows daemon and its tunnel instead of the local launchd service')
276
+ .allowUnknownOption(true)
277
+ .allowExcessArguments(true)
278
+ .helpOption(false)
279
+ .action(async (opts, cmd) => {
280
+ await forwardAndExit({ argv: ['start', ...cmd.args], device: opts.device, record: false });
281
+ });
236
282
  }
237
- // PowerShell to bounce the remote daemon: kill the running exe (tolerating
238
- // "not running"), then start the LOGON scheduled task that owns its
239
- // lifecycle. Pure so tests can assert the exact script (mirrors the
240
- // ssh-tunnel script builders).
241
- export function buildRestartTaskScript(taskName, exeName) {
242
- const procName = exeName.replace(/\.exe$/i, '');
243
- return [
244
- `$ErrorActionPreference = 'Stop'`,
245
- `Stop-Process -Name '${procName}' -Force -ErrorAction SilentlyContinue`,
246
- `Start-ScheduledTask -TaskName '${taskName}'`,
247
- `Write-Output 'restarted'`,
248
- ].join('; ');
283
+ function registerStopCommand(program) {
284
+ program
285
+ .command('stop')
286
+ .description('Deactivate the helper daemon — local launchd (macOS) or a remote Windows tunnel with --device')
287
+ .option('--device <name>', 'Tear down the remote tunnel and unregister the scheduled task')
288
+ .allowUnknownOption(true)
289
+ .allowExcessArguments(true)
290
+ .helpOption(false)
291
+ .action(async (opts, cmd) => {
292
+ // The engine owns both halves of a remote stop — the tunnel it opened and
293
+ // the scheduled task it registered — and reports each one itself.
294
+ await forwardAndExit({ argv: ['stop', ...cmd.args], device: opts.device, record: false });
295
+ });
249
296
  }
250
- // reload --device: the Windows daemon has no policy file to re-read (it
251
- // enforces no allow-list — see TrustStatus in native/computer-win/Rpc.cs), so
252
- // reload means bounce the daemon via its scheduled task — the way to pick up
253
- // a freshly pushed exe — then prove it answers through the recorded tunnel.
254
- async function reloadRemoteHelper(device) {
255
- const { target } = await resolveRemoteDevice(device);
256
- const script = buildRestartTaskScript(REMOTE_TASK_NAME, WIN_HELPER_EXE);
257
- const res = sshExec(target, `powershell -NoProfile -NonInteractive -EncodedCommand ${encodePowershell(script)}`, { timeoutMs: 60_000 });
258
- if (res.code !== 0) {
259
- const msg = (res.stderr || res.stdout || '').trim();
260
- console.error(`restart failed on ${target}${res.timedOut ? ' (timed out)' : ''}${msg ? `: ${msg}` : ''}`);
261
- process.exit(1);
262
- }
263
- console.log(`task: restarted "${REMOTE_TASK_NAME}" on ${target}`);
264
- const state = readRemoteState(device);
265
- if (!state) {
266
- console.log(`(no tunnel recorded — run \`agents computer start --device ${device}\` to drive it)`);
267
- return;
268
- }
269
- hydrateRemoteEnvFromState(device);
270
- // The relaunched daemon needs a beat to rebind its port; poll through the
271
- // tunnel until it answers.
272
- const deadline = Date.now() + 15_000;
273
- let lastErr = '';
274
- while (Date.now() < deadline) {
275
- try {
276
- const client = openComputerClient();
277
- try {
278
- const r = await client.call('trust_status');
279
- if (!r.error && r.result) {
280
- console.log(`reloaded: daemon answering (pid ${r.result.pid ?? '?'})`);
281
- return;
282
- }
283
- lastErr = r.error ? `${r.error.code}: ${r.error.message}` : 'empty result';
284
- }
285
- finally {
286
- await client.close();
287
- }
288
- }
289
- catch (err) {
290
- lastErr = err.message;
291
- }
292
- await sleep(500);
293
- }
294
- console.error(`daemon did not answer within 15s after restart${lastErr ? ` (${lastErr})` : ''}`);
295
- process.exit(1);
297
+ function registerReloadCommand(program) {
298
+ program
299
+ .command('reload')
300
+ .description('Reload the allow-list policy (SIGHUP the local daemon) — or restart a remote Windows daemon with --device')
301
+ .option('--device <name>', 'Restart the remote Windows daemon (its scheduled task) instead of SIGHUPing the local one')
302
+ .allowUnknownOption(true)
303
+ .allowExcessArguments(true)
304
+ .helpOption(false)
305
+ .action(async (opts, cmd) => {
306
+ // Reload EXISTS to re-render the allow list; doing it before the signal is
307
+ // the whole command. A `--device` reload bounces the remote daemon, which
308
+ // enforces no allow list — rendering (and printing) this machine's would
309
+ // claim a policy that device never reads.
310
+ await forwardAndExit({ argv: ['reload', ...cmd.args], device: opts.device, record: false });
311
+ });
296
312
  }
297
- // run — the embedded observe -> act -> verify agent loop. A reasoning model
298
- // (Claude API by default, or any Anthropic-shaped endpoint via --base-url for
299
- // Ollama / vLLM / LiteLLM) drives the EXISTING computer verbs as tools over the
300
- // daemon socket. New subcommand: the explicit verb interface external agents
301
- // use is unchanged. The loop auto-switches to the screenshot/coordinate path
302
- // when an app's AX tree comes back opaque (WebView / canvas).
303
- function registerRunCommand(program) {
313
+ function registerStatusCommand(program) {
304
314
  program
305
- .command('run')
306
- .description('Autonomously drive an app from a natural-language task (embedded model loop over the computer verbs)')
307
- .requiredOption('--task <s>', 'Natural-language task, e.g. "open Notes and write a haiku"')
308
- .option('--bundle <id>', 'Bundle id to focus the loop on (default: frontmost allow-listed app)')
309
- .option('--base-url <url>', `Reasoning model base URL — Anthropic wire shape (default: ${DEFAULT_CLAUDE_BASE_URL}; set to a local Ollama/vLLM/LiteLLM endpoint for offline parity)`)
310
- .option('--model <id>', `Model id (default: ${DEFAULT_CLAUDE_MODEL})`)
311
- .option('--max-steps <n>', 'Max model turns before giving up', (v) => parseInt(v, 10), 12)
312
- .option('--max-tokens <n>', 'Max tokens per model turn', (v) => parseInt(v, 10), 1024)
313
- .option('--device <name>', 'Drive a remote Windows device (requires `agents computer start --device <name>` first)')
314
- .option('--json', 'Emit the final loop result as JSON')
315
- .action(async (opts) => {
316
- const apiKey = resolveApiKey({ apiKey: undefined, baseUrl: opts.baseUrl });
317
- // The Claude API needs a key; a local/offline endpoint (non-default base
318
- // URL) usually ignores it, so only hard-fail on the default endpoint.
319
- if (!apiKey && !opts.baseUrl) {
320
- console.error('no API key. Set ANTHROPIC_API_KEY (or AGENTS_COMPUTER_API_KEY), or point --base-url at a local endpoint.');
321
- process.exit(1);
315
+ .command('status')
316
+ .description('Report install state, daemon state, and Accessibility trust — or a remote Windows daemon with --device')
317
+ .option('--device <name>', 'Report the remote Windows daemon (tunnel + liveness) instead of the local helper')
318
+ .allowUnknownOption(true)
319
+ .allowExcessArguments(true)
320
+ .helpOption(false)
321
+ .action(async (opts, cmd) => {
322
+ const json = cmd.args.includes('--json');
323
+ // The allow list is agents-cli's answer, not the engine's — report it here
324
+ // so `status` stays the one place that tells you why an app is refused. It
325
+ // governs the LOCAL helper only: the Windows daemon enforces none, and the
326
+ // engine reports that device's target, transport and liveness itself.
327
+ if (!json && !opts.device) {
328
+ const allowed = loadComputerAllowList();
329
+ const preview = allowed.slice(0, 5).join(', ');
330
+ const suffix = allowed.length > 5 ? ` (+${allowed.length - 5} more)` : '';
331
+ console.log(`policy: ${allowed.length} app${allowed.length === 1 ? '' : 's'} allowed${allowed.length > 0 ? `: ${preview}${suffix}` : ''}`);
332
+ console.log(`peers: ${loadDefaultPeers().length} caller(s) (peer-auth on socket)`);
322
333
  }
323
- const responder = makeClaudeResponder({
324
- baseUrl: opts.baseUrl,
325
- model: opts.model,
326
- maxTokens: opts.maxTokens,
327
- });
328
- emitComputerRunTaskMarker({ task: opts.task, bundle: opts.bundle, device: opts.device });
329
- await withClient(async (client) => {
330
- const dispatch = makeVerbDispatcher(client, { device: opts.device });
331
- const targetInput = opts.bundle ? { bundle: opts.bundle } : {};
332
- const result = await runComputerLoop({
333
- task: opts.task,
334
- responder: (state) => responder({ ...state, task: describeTaskWithTarget(opts.task, opts.bundle) }),
335
- dispatch: (call) => dispatch({ ...call, input: { ...targetInput, ...call.input } }),
336
- maxSteps: opts.maxSteps,
337
- onEvent: opts.json ? undefined : (e) => printLoopEvent(e),
338
- });
339
- if (opts.json) {
340
- console.log(JSON.stringify(result, null, 2));
341
- return;
342
- }
343
- console.log('');
344
- if (result.status === 'done') {
345
- console.log(`done (${result.turns} turn${result.turns === 1 ? '' : 's'}): ${result.finalText ?? ''}`);
346
- }
347
- else {
348
- console.log(`stopped: hit max-steps (${result.turns} turns) without a completion signal`);
349
- }
350
- });
334
+ await forwardAndExit({ argv: ['status', ...cmd.args], device: opts.device, record: false });
351
335
  });
352
336
  }
353
337
  // sessions — task-first history over the computer.action event ledger (RUSH-2432),
354
338
  // the computer counterpart of `agents browser sessions` (RUSH-2407). `agents
355
339
  // sessions --computer` (sessions.ts) routes to the same runComputerSessionsCommand.
340
+ // It reads agents-cli's own ledger, so it never reaches the engine.
356
341
  function registerSessionsCommand(program) {
357
342
  program
358
343
  .command('sessions')
@@ -366,591 +351,66 @@ function registerSessionsCommand(program) {
366
351
  });
367
352
  }
368
353
  /**
369
- * Emit a `computer.action` marker for `computer run`'s whole loop process, so
370
- * `agents computer sessions` groups every verb the loop drives under one
371
- * task-labeled row instead of a bare pid with no task text (see
372
- * lib/computer/sessions-list.ts's "Grouping key" docblock note). The task
373
- * text is bounded to `TASK_PREVIEW_MAX_CHARS` — never the full unbounded
374
- * `--task` string — matching that module's retention/privacy note. Exported
375
- * so the truncation behavior is directly unit-testable against the real
376
- * event ledger, the same seam `computer-actions.test.ts` already uses for
377
- * `emitComputerAction`.
354
+ * Install the macOS helper through the engine. Used by the `agents setup
355
+ * computer` wizard, which still owns the TCC hand-holding — that is a
356
+ * conversation with the user, not a daemon operation.
378
357
  */
379
- export function emitComputerRunTaskMarker(opts) {
380
- emitComputerAction('run', undefined, { bundle: opts.bundle, device: opts.device }, {
381
- task: truncate(opts.task, TASK_PREVIEW_MAX_CHARS),
382
- });
383
- }
384
- // Fold the target bundle into the task text so the model biases toward it.
385
- function describeTaskWithTarget(task, bundle) {
386
- return bundle ? `${task}\n(Target app bundle id: ${bundle})` : task;
387
- }
388
- // Human progress line per loop event — verb + a one-line result digest.
389
- function printLoopEvent(e) {
390
- if (e.kind === 'turn') {
391
- console.log(`--- turn ${e.index + 1} ---`);
392
- }
393
- else if (e.kind === 'dispatch') {
394
- const tag = e.visionFallback ? ' [ax opaque]' : '';
395
- const status = e.result.ok ? 'ok' : `error: ${e.result.error ?? 'unknown'}`;
396
- console.log(` ${e.call.name}${tag} -> ${status}`);
397
- }
398
- else if (e.kind === 'vision_switch') {
399
- console.log(' (switching to vision path: screenshot + coordinate clicks)');
400
- }
401
- else if (e.kind === 'done') {
402
- console.log(` model: ${e.text}`);
403
- }
404
- }
405
- function registerScreenshotCommand(program) {
406
- program
407
- .command('screenshot')
408
- .description('Capture a window (default: largest), enumerate windows (--list), or the whole display (--display)')
409
- .option('--bundle <id>', 'Bundle id to capture (default: frontmost allow-listed app)')
410
- .option('--pid <n>', 'Target pid directly (overrides --bundle)', (v) => parseInt(v, 10))
411
- .option('--device <name>', 'Drive a remote Windows device (requires `agents computer start --device <name>` first)')
412
- .option('--list', 'List the app\'s windows (id/title/layer/bounds) instead of capturing — reveals modals/popups')
413
- .option('--window-id <n>', 'Capture a specific window by id (from --list)', (v) => parseInt(v, 10))
414
- .option('--display', 'Capture the whole display the app is on (composites stacked modals)')
415
- .option('--out <path>', 'Output image path — extension auto-corrected to the encoded format (JPEG on macOS, PNG on a Windows --device)', './computer-screenshot.jpg')
416
- .option('--quality <n>', 'JPEG quality 1-100 (macOS capture only; the Windows helper encodes lossless PNG and ignores this)', (v) => parseInt(v, 10), 85)
417
- .option('--json', 'Emit JSON (metadata for captures; window list for --list)')
418
- .action(async (opts) => {
419
- const quality = Math.max(1, Math.min(100, opts.quality || 85));
420
- await withClient(async (client) => {
421
- // Resolve the target pid (explicit --pid, else --bundle, else frontmost).
422
- let pid = opts.pid;
423
- if (pid == null) {
424
- const list = unwrap(await client.call('list_apps')).apps || [];
425
- const picked = pickTarget(list, { bundle: opts.bundle });
426
- if (!picked.ok) {
427
- console.error(picked.error);
428
- process.exit(1);
429
- }
430
- pid = picked.app.pid;
431
- }
432
- // --list: enumerate windows, no image.
433
- if (opts.list) {
434
- const res = unwrap(await client.call('screenshot', { pid, list: true }));
435
- emitComputerAction('screenshot', pid, opts, { list: true });
436
- const windows = res.windows || [];
437
- if (opts.json) {
438
- console.log(JSON.stringify(res, null, 2));
439
- }
440
- else if (windows.length === 0) {
441
- console.log('(no windows)');
442
- }
443
- else {
444
- for (const w of windows) {
445
- const b = w.bounds || [];
446
- console.log(`${String(w.window_id).padStart(8)} layer ${w.layer} [${b.join(',')}] ${w.title || '(untitled)'}`);
447
- }
448
- }
449
- return;
450
- }
451
- // Capture: window (default / --window-id) or full display.
452
- const params = { pid, quality };
453
- if (opts.display)
454
- params.display = true;
455
- else if (opts.windowId != null)
456
- params.window_id = opts.windowId;
457
- const res = unwrap(await client.call('screenshot', params));
458
- const b64 = res.image_data;
459
- if (!b64) {
460
- console.error('helper returned no image_data');
461
- process.exit(1);
462
- }
463
- emitComputerAction('screenshot', pid, opts, { display: opts.display, windowId: opts.windowId });
464
- const buf = Buffer.from(b64, 'base64');
465
- // Sniff the real format and correct the extension so the filename never
466
- // lies about its bytes (macOS -> JPEG, Windows helper -> PNG).
467
- const requested = path.resolve(opts.out);
468
- const { path: outPath, corrected } = reconcileScreenshotExt(requested, buf);
469
- fs.writeFileSync(outPath, buf);
470
- if (opts.json) {
471
- // Drop the heavy base64 from the metadata echo; report where it went.
472
- const meta = { ...res, image_data: `<saved to ${outPath}>` };
473
- console.log(JSON.stringify(meta, null, 2));
474
- }
475
- else {
476
- if (corrected) {
477
- console.log(`note: bytes are ${path.extname(outPath).slice(1).toUpperCase()}; corrected extension from ${path.basename(requested)}`);
478
- }
479
- const origin = res.origin || [];
480
- const originStr = origin.length === 2 ? `, origin [${origin.join(',')}], scale ${res.scale ?? '?'}` : '';
481
- console.log(`saved: ${outPath} (${res.width ?? '?'}x${res.height ?? '?'}, ${buf.byteLength} bytes${originStr})`);
482
- }
483
- });
484
- });
358
+ export async function installComputerHelperMacLocal() {
359
+ const { exitCode } = await forwardToComputer({ argv: ['setup'], record: false });
360
+ if (exitCode !== 0)
361
+ throw new Error(`\`computer setup\` failed (exit ${exitCode})`);
485
362
  }
486
- // setup (alias: install-helper):
487
- // 1. resolve dist .app
488
- // 2. copy to /Applications/Computer Helper.app
489
- // 3. codesign --verify the destination
490
- // 4. write LaunchAgent plist with absolute HOME paths
491
- // 5. launchctl bootout (ignore failure) -> bootstrap -> kickstart -k
492
- // 6. wait for socket to appear
493
- // 7. probe trust_status, print grant instructions if needed
494
- //
495
- // macOS TCC is keyed by signed-bundle identity + bundle id. Putting the
496
- // .app at a stable absolute path under /Applications/ means the AX grant
497
- // survives across npm updates. The CLI itself is unsigned but doesn't
498
- // need AX — it sends JSON-RPC to the daemon, which has AX.
499
- const HELPER_BUNDLE_ID = 'com.phnx-labs.computer-helper';
500
- const HELPER_APP_NAME = 'Computer Helper.app';
501
- const HELPER_APP_DEST = `/Applications/${HELPER_APP_NAME}`;
502
363
  /**
503
- * launchd Label for this process's helper — the production identifier for a real
504
- * invocation, namespaced under a redirected HOME (RUSH-2639). launchd routes
505
- * bootout/bootstrap/kickstart/print by identifier alone, never by the plist's
506
- * path, so without this a process running under a sandbox HOME tears down the
507
- * operator's live helper.
364
+ * Activate the local daemon through the engine and report whether Accessibility
365
+ * trust is granted, so the wizard knows whether to walk the user to System
366
+ * Settings.
508
367
  */
509
- export function helperLabel() {
510
- return namespacedServiceLabel(HELPER_BUNDLE_ID);
368
+ export async function activateComputerHelperMacLocal() {
369
+ const { exitCode } = await forwardToComputer({ argv: ['start'], record: false });
370
+ if (exitCode !== 0)
371
+ throw new Error(`\`computer start\` failed (exit ${exitCode})`);
372
+ return { trusted: await probeComputerTrust() };
511
373
  }
512
374
  /**
513
- * Install the macOS helper locally: resolve (or download + verify) the signed,
514
- * notarized .app, copy it to /Applications, verify the destination signature,
515
- * and write the LaunchAgent plist (inactive — activation is a separate opt-in
516
- * step via `start`). Throws on any failure. Shared by `agents computer setup`
517
- * and the unified `agents setup computer` wizard so there is one install path.
375
+ * Read `trusted` out of `computer status --json`.
376
+ *
377
+ * Tolerant by construction: the engine may print a banner line before its JSON,
378
+ * so scan for the first parseable object rather than assuming the whole stream
379
+ * is JSON. Pure, so the parsing contract is testable without a daemon.
518
380
  */
519
- export async function installComputerHelperMacLocal() {
520
- let srcApp = resolveHelperApp();
521
- if (!srcApp || !fs.existsSync(srcApp)) {
522
- // No local build / bundled copy (the normal case on an npm-installed CLI).
523
- // Fetch the signed + notarized helper release asset for this CLI version;
524
- // it is sha256- and signature-verified before we touch /Applications.
525
- const { ensureMacHelperApp } = await import('../lib/computer/download.js');
526
- srcApp = await ensureMacHelperApp();
527
- console.log(`helper: ${srcApp} (downloaded + verified)`);
528
- }
529
- const socketPath = resolveSocketPath();
530
- const logPath = resolveLogPath();
531
- const plistPath = path.join(os.homedir(), 'Library', 'LaunchAgents', `${helperLabel()}.plist`);
532
- console.log(`source: ${srcApp}`);
533
- console.log(`dest: ${HELPER_APP_DEST}`);
534
- // 1. Copy to /Applications/ via ditto (preserves xattrs + codesign metadata).
535
- if (fs.existsSync(HELPER_APP_DEST)) {
536
- try {
537
- fs.rmSync(HELPER_APP_DEST, { recursive: true, force: true });
538
- }
539
- catch (err) {
540
- throw new Error(`failed to remove prior install at ${HELPER_APP_DEST}: ${err.message}. try: sudo rm -rf "${HELPER_APP_DEST}"`);
541
- }
542
- }
543
- try {
544
- execFileSync('/usr/bin/ditto', [srcApp, HELPER_APP_DEST], { stdio: 'inherit' });
545
- }
546
- catch (err) {
547
- throw new Error(`ditto copy failed: ${err.message}`);
548
- }
549
- console.log(`copied to ${HELPER_APP_DEST}`);
550
- // 2. Verify codesign on the destination — TCC needs a valid signature.
381
+ export function parseTrustFromStatusJson(stdout) {
382
+ const start = stdout.indexOf('{');
383
+ if (start < 0)
384
+ return false;
551
385
  try {
552
- execFileSync('/usr/bin/codesign', ['--verify', '--deep', '--strict', HELPER_APP_DEST], { stdio: 'inherit' });
553
- console.log('codesign verify: OK');
386
+ const parsed = JSON.parse(stdout.slice(start));
387
+ return parsed.trusted === true;
554
388
  }
555
389
  catch {
556
- throw new Error(`codesign verify FAILED for ${HELPER_APP_DEST}. The destination .app is unsigned or its signature was stripped.`);
390
+ return false;
557
391
  }
558
- // 3. Ensure socket + log parent dirs exist.
559
- fs.mkdirSync(path.dirname(socketPath), { recursive: true });
560
- fs.mkdirSync(path.dirname(logPath), { recursive: true });
561
- // 4. Write the LaunchAgent plist but DO NOT bootstrap it (opt-in via `start`).
562
- const execInsideApp = path.join(HELPER_APP_DEST, 'Contents', 'MacOS', 'ComputerHelper');
563
- const plistContent = renderLaunchAgentPlist({ label: helperLabel(), exec: execInsideApp, socketPath, logPath });
564
- fs.mkdirSync(path.dirname(plistPath), { recursive: true });
565
- fs.writeFileSync(plistPath, plistContent);
566
- console.log(`wrote plist: ${plistPath} (NOT activated)`);
567
- return { appDest: HELPER_APP_DEST, plistPath };
568
392
  }
569
393
  /**
570
- * Activate the local helper daemon via launchd (render policy + peers, bootout
571
- * → bootstrap → kickstart, wait for the socket, probe AX trust). Throws on any
572
- * failure. Returns the trust status so callers can guide the permission grant.
573
- * Shared by `agents computer start` and the `agents setup computer` wizard.
394
+ * Probe Accessibility trust without re-activating. Returns false (never throws)
395
+ * when the engine is absent, the daemon is down, or the probe errors — the
396
+ * wizard polls this while the user grants permissions in System Settings, and a
397
+ * throw there would abort the very flow that fixes it.
398
+ *
399
+ * This is the ONE place agents-cli reads engine stdout instead of passing it
400
+ * through, because it needs an answer rather than a display.
574
401
  */
575
- export async function activateComputerHelperMacLocal() {
576
- const plistPath = path.join(os.homedir(), 'Library', 'LaunchAgents', `${helperLabel()}.plist`);
577
- const socketPath = resolveSocketPath();
578
- const logPath = resolveLogPath();
579
- if (!fs.existsSync(plistPath))
580
- throw new Error(`plist not found at ${plistPath}. run: agents computer setup`);
581
- if (!fs.existsSync(HELPER_APP_DEST))
582
- throw new Error(`helper app not found at ${HELPER_APP_DEST}. run: agents computer setup`);
583
- const uid = process.getuid?.();
584
- if (typeof uid !== 'number')
585
- throw new Error('cannot resolve uid');
586
- const reg = serviceManagerRegistrationAllowed();
587
- if (!reg.allowed) {
588
- throw new Error(reg.reason);
589
- }
590
- const domain = `gui/${uid}`;
591
- // Render the policy file BEFORE bootstrap so the daemon reads a fresh allow
592
- // list at startup (fail-safe: missing/unparseable → everything denied).
593
- const allowed = loadComputerAllowList();
594
- writeComputerPolicy(allowed);
595
- console.log(`policy: ${allowed.length} app${allowed.length === 1 ? '' : 's'} allowed (${resolvePolicyPath()})`);
596
- if (allowed.length > 0) {
597
- const preview = allowed.slice(0, 5).join(', ');
598
- const more = allowed.length > 5 ? ` (+${allowed.length - 5} more)` : '';
599
- console.log(` ${preview}${more}`);
600
- }
601
- else {
602
- console.log(` (no Computer(...) patterns found — everything will be denied)`);
603
- console.log(` add to ~/.agents/permissions/groups/<name>.yaml under allow:`);
604
- console.log(` - "Computer(com.apple.finder)"`);
605
- }
606
- // Peer-auth allow list — which caller executables may connect to the socket.
607
- const callers = loadDefaultPeers();
608
- writeComputerPeers(callers);
609
- console.log(`peers: ${callers.length} caller${callers.length === 1 ? '' : 's'} allowed (${resolvePeersPath()})`);
610
- for (const p of callers)
611
- console.log(` ${p}`);
612
- // Bootout first to clear any prior registration (best-effort).
613
- try {
614
- execFileSync('/bin/launchctl', ['bootout', domain, plistPath], { stdio: 'pipe' });
615
- }
616
- catch {
617
- // expected when not previously loaded
618
- }
619
- try {
620
- execFileSync('/bin/launchctl', ['bootstrap', domain, plistPath], { stdio: 'pipe' });
621
- }
622
- catch (err) {
623
- throw new Error(`launchctl bootstrap failed: ${err.message}`);
624
- }
625
- // Force restart so we pick up the latest binary.
626
- try {
627
- execFileSync('/bin/launchctl', ['kickstart', '-k', `${domain}/${helperLabel()}`], { stdio: 'pipe' });
628
- }
629
- catch (err) {
630
- throw new Error(`launchctl kickstart failed: ${err.message}`);
631
- }
632
- // Wait up to 5s for the socket.
633
- const deadline = Date.now() + 5000;
634
- while (Date.now() < deadline) {
635
- if (fs.existsSync(socketPath))
636
- break;
637
- await sleep(100);
638
- }
639
- if (!fs.existsSync(socketPath)) {
640
- throw new Error(`socket did not appear at ${socketPath} within 5s. check ${logPath} for helper startup errors`);
641
- }
642
- // Probe trust through the socket.
643
- let trusted = false;
644
- let trustStr = 'unknown';
645
- try {
646
- const client = openComputerClient();
647
- try {
648
- const r = await client.call('trust_status');
649
- trusted = Boolean(r.result?.trusted);
650
- trustStr = r.error ? `error (${r.error.code})` : (trusted ? 'granted' : 'denied');
651
- }
652
- finally {
653
- await client.close();
654
- }
655
- }
656
- catch (err) {
657
- trustStr = `error (${err.message})`;
658
- }
659
- console.log(`daemon: running`);
660
- console.log(`socket: ${socketPath}`);
661
- console.log(`trust: ${trustStr}`);
662
- return { trusted, socketPath, logPath };
663
- }
664
- /** Probe the running daemon's Accessibility trust status without re-activating.
665
- * Returns false (never throws) if the socket is down or the RPC errors — used to
666
- * poll while the user grants permissions in System Settings. */
667
402
  export async function probeComputerTrust() {
668
403
  try {
669
- const client = openComputerClient();
670
- try {
671
- const r = await client.call('trust_status');
672
- return Boolean(r.result?.trusted);
673
- }
674
- finally {
675
- await client.close();
676
- }
404
+ const { exitCode, stdout } = await forwardToComputer({
405
+ argv: ['status', '--json'],
406
+ record: false,
407
+ capture: true,
408
+ });
409
+ if (exitCode !== 0)
410
+ return false;
411
+ return parseTrustFromStatusJson(stdout);
677
412
  }
678
413
  catch {
679
414
  return false;
680
415
  }
681
416
  }
682
- function registerSetupCommand(program) {
683
- program
684
- .command('setup')
685
- .alias('install-helper')
686
- .description('Install the helper — locally to /Applications/ (macOS), or to a remote Windows device with --device')
687
- .option('--device <name>', 'Provision a remote Windows device (push the exe + register a LOGON task) instead of installing locally')
688
- .action(async (opts) => {
689
- if (opts.device) {
690
- try {
691
- const { target, taskName } = await setupRemoteHelper(opts.device);
692
- console.log(`pushed computer-helper-win.exe to ${target}`);
693
- console.log(`registered LOGON scheduled task "${taskName}" (interactive session, started now)`);
694
- console.log('');
695
- console.log(`Next: agents computer start --device ${opts.device}`);
696
- }
697
- catch (err) {
698
- console.error(`error: ${err.message}`);
699
- process.exit(1);
700
- }
701
- return;
702
- }
703
- let plistPath;
704
- try {
705
- ({ plistPath } = await installComputerHelperMacLocal());
706
- }
707
- catch (err) {
708
- console.error(`error: ${err.message}`);
709
- process.exit(1);
710
- }
711
- console.log('');
712
- console.log('Helper installed (inactive).');
713
- console.log('');
714
- console.log(` app: ${HELPER_APP_DEST}`);
715
- console.log(` plist: ${plistPath}`);
716
- console.log('');
717
- console.log('Next steps:');
718
- console.log(' 1. Grant TCC permissions (one-time):');
719
- console.log(' System Settings > Privacy & Security > Accessibility — add Agents Computer');
720
- console.log(' System Settings > Privacy & Security > Screen Recording — add Agents Computer');
721
- console.log(' 2. Whitelist the apps the daemon may drive. Add a YAML under ~/.agents/permissions/groups/:');
722
- console.log(' name: computer');
723
- console.log(' allow:');
724
- console.log(' - "Computer(com.apple.mail)"');
725
- console.log(' - "Computer(com.apple.notes)"');
726
- console.log(' Default policy is deny-all.');
727
- console.log(' 3. When you want to use it: agents computer start');
728
- console.log(' 4. When you are done: agents computer stop');
729
- });
730
- }
731
- function registerStartCommand(program) {
732
- program
733
- .command('start')
734
- .description('Activate the helper daemon — local launchd (macOS) or a remote Windows tunnel with --device')
735
- .option('--device <name>', 'Open a tunnel to the remote Windows daemon and record it for --device verbs')
736
- .action(async (opts) => {
737
- if (opts.device) {
738
- try {
739
- const state = await startRemoteTunnel(opts.device);
740
- console.log(`tunnel: 127.0.0.1:${state.localPort} -> ${state.target} (127.0.0.1:${state.remotePort})`);
741
- console.log(`daemon: answering (ssh pid ${state.tunnelPid})`);
742
- console.log('');
743
- console.log(`Drive it: agents computer apps --device ${opts.device}`);
744
- console.log(`Stop: agents computer stop --device ${opts.device}`);
745
- }
746
- catch (err) {
747
- console.error(`error: ${err.message}`);
748
- process.exit(1);
749
- }
750
- return;
751
- }
752
- let trusted;
753
- try {
754
- ({ trusted } = await activateComputerHelperMacLocal());
755
- }
756
- catch (err) {
757
- console.error(`error: ${err.message}`);
758
- process.exit(1);
759
- }
760
- if (!trusted) {
761
- console.log('');
762
- console.log('Grant Accessibility + Screen Recording to Agents Computer, then run `agents computer start` again.');
763
- }
764
- });
765
- }
766
- function registerReloadCommand(program) {
767
- program
768
- .command('reload')
769
- .description('Reload the allow-list policy (SIGHUP the local daemon) — or restart a remote Windows daemon with --device')
770
- .option('--device <name>', 'Restart the remote Windows daemon (its scheduled task) instead of SIGHUPing the local one')
771
- .action(async (opts) => {
772
- if (opts.device) {
773
- try {
774
- await reloadRemoteHelper(opts.device);
775
- }
776
- catch (err) {
777
- console.error(`error: ${err.message}`);
778
- process.exit(1);
779
- }
780
- return;
781
- }
782
- const socketPath = resolveSocketPath();
783
- if (!fs.existsSync(socketPath)) {
784
- console.error(`daemon not running (no socket at ${socketPath})`);
785
- console.error('run: agents computer start');
786
- process.exit(1);
787
- }
788
- const allowed = loadComputerAllowList();
789
- writeComputerPolicy(allowed);
790
- console.log(`policy: ${allowed.length} app${allowed.length === 1 ? '' : 's'} allowed (${resolvePolicyPath()})`);
791
- // Rewrite peers list too — an upgrade of the npm-global CLI moves
792
- // its node path; without this the reloaded daemon would reject the
793
- // very binary that just signaled it.
794
- const callers = loadDefaultPeers();
795
- writeComputerPeers(callers);
796
- console.log(`peers: ${callers.length} caller${callers.length === 1 ? '' : 's'} allowed (${resolvePeersPath()})`);
797
- // Resolve the daemon's pid via `launchctl list <label>`. The plist
798
- // output includes a "PID" key when the service is running.
799
- const uid = process.getuid?.();
800
- if (typeof uid !== 'number') {
801
- console.error('cannot resolve uid');
802
- process.exit(1);
803
- }
804
- const domain = `gui/${uid}`;
805
- let pid = null;
806
- try {
807
- const out = execFileSync('/bin/launchctl', ['print', `${domain}/${helperLabel()}`], { encoding: 'utf-8' });
808
- const m = out.match(/\bpid\s*=\s*(\d+)/);
809
- if (m)
810
- pid = parseInt(m[1], 10);
811
- }
812
- catch (err) {
813
- console.error(`launchctl print failed: ${err.message}`);
814
- process.exit(1);
815
- }
816
- if (pid === null || !Number.isFinite(pid) || pid <= 0) {
817
- console.error('could not resolve daemon pid from launchctl print output');
818
- process.exit(1);
819
- }
820
- try {
821
- process.kill(pid, 'SIGHUP');
822
- }
823
- catch (err) {
824
- console.error(`kill -HUP ${pid} failed: ${err.message}`);
825
- process.exit(1);
826
- }
827
- // Brief socket-up check so the user knows the daemon survived the
828
- // signal (it should — SIGHUP just triggers a re-read).
829
- await sleep(150);
830
- if (!fs.existsSync(socketPath)) {
831
- console.error(`socket disappeared after SIGHUP — check ${resolveLogPath()}`);
832
- process.exit(1);
833
- }
834
- console.log(`reloaded: daemon pid ${pid}`);
835
- if (allowed.length > 0) {
836
- const preview = allowed.slice(0, 5).join(', ');
837
- const more = allowed.length > 5 ? ` (+${allowed.length - 5} more)` : '';
838
- console.log(` ${preview}${more}`);
839
- }
840
- });
841
- }
842
- function registerStopCommand(program) {
843
- program
844
- .command('stop')
845
- .description('Deactivate the helper daemon — local launchd (macOS) or a remote Windows tunnel with --device')
846
- .option('--device <name>', 'Tear down the remote tunnel and unregister the scheduled task')
847
- .action(async (opts) => {
848
- if (opts.device) {
849
- try {
850
- const { tunnelKilled, taskRemoved } = await stopRemoteHelper(opts.device);
851
- console.log(`tunnel: ${tunnelKilled ? 'closed' : 'not running'}`);
852
- console.log(`task: ${taskRemoved ? 'unregistered' : 'not removed (device offline?)'}`);
853
- }
854
- catch (err) {
855
- console.error(`error: ${err.message}`);
856
- process.exit(1);
857
- }
858
- return;
859
- }
860
- const home = os.homedir();
861
- const plistPath = path.join(home, 'Library', 'LaunchAgents', `${helperLabel()}.plist`);
862
- const socketPath = resolveSocketPath();
863
- const uid = process.getuid?.();
864
- if (typeof uid !== 'number') {
865
- console.error('cannot resolve uid');
866
- process.exit(1);
867
- }
868
- const domain = `gui/${uid}`;
869
- // Same invariant as the start path: a redirected-HOME process must never
870
- // talk to the real launchd (RUSH-2968). Stop has a local fallback — the
871
- // plist/socket cleanup below — so skip the bootout with the reason
872
- // rather than throwing like start does.
873
- const reg = serviceManagerRegistrationAllowed();
874
- if (!reg.allowed) {
875
- console.warn(`skipping launchctl bootout: ${reg.reason}`);
876
- }
877
- else {
878
- try {
879
- execFileSync('/bin/launchctl', ['bootout', domain, plistPath], { stdio: 'pipe' });
880
- }
881
- catch {
882
- // already gone — fine
883
- }
884
- }
885
- // launchd unlinks the socket when the daemon exits; helper also has an
886
- // atexit unlink. Best-effort cleanup if either path didn't fire.
887
- try {
888
- fs.unlinkSync(socketPath);
889
- }
890
- catch { }
891
- console.log('daemon: stopped');
892
- if (fs.existsSync(socketPath)) {
893
- console.warn(`(socket still present at ${socketPath} — may belong to a different process)`);
894
- }
895
- });
896
- }
897
- /**
898
- * The helper's launchd plist.
899
- *
900
- * The `EnvironmentVariables` dict carries HOME (RUSH-2639, see
901
- * `lib/service-manifest.ts`): launchd applies this dict on top of the LOGIN
902
- * SESSION's environment, never the environment of whoever called `launchctl
903
- * bootstrap`, so a manifest that omits HOME hands the helper the account home no
904
- * matter which home the caller resolved. That is a silent escape from any
905
- * redirected HOME — the hermetic test harness's, and an agent's isolated version
906
- * home alike.
907
- */
908
- export function renderLaunchAgentPlist(opts) {
909
- const envXml = Object.entries(serviceManifestHomeEnv())
910
- .map(([k, v]) => ` <key>${escapeXml(k)}</key>\n <string>${escapeXml(v)}</string>`)
911
- .join('\n');
912
- return `<?xml version="1.0" encoding="UTF-8"?>
913
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
914
- <plist version="1.0">
915
- <dict>
916
- <key>Label</key>
917
- <string>${escapeXml(opts.label)}</string>
918
- <key>ProgramArguments</key>
919
- <array>
920
- <string>${escapeXml(opts.exec)}</string>
921
- <string>--socket</string>
922
- <string>${escapeXml(opts.socketPath)}</string>
923
- </array>
924
- <key>EnvironmentVariables</key>
925
- <dict>
926
- ${envXml}
927
- </dict>
928
- <key>RunAtLoad</key>
929
- <true/>
930
- <key>KeepAlive</key>
931
- <true/>
932
- <key>ProcessType</key>
933
- <string>Background</string>
934
- <key>StandardErrorPath</key>
935
- <string>${escapeXml(opts.logPath)}</string>
936
- <key>StandardOutPath</key>
937
- <string>${escapeXml(opts.logPath)}</string>
938
- </dict>
939
- </plist>
940
- `;
941
- }
942
- function escapeXml(s) {
943
- return s
944
- .replace(/&/g, '&amp;')
945
- .replace(/</g, '&lt;')
946
- .replace(/>/g, '&gt;')
947
- .replace(/"/g, '&quot;')
948
- .replace(/'/g, '&apos;');
949
- }
950
- function sleep(ms) {
951
- return new Promise((resolve) => setTimeout(resolve, ms));
952
- }
953
- // Backwards-compat: a few external callers may still import these.
954
- // Re-export from the shared lib so existing imports keep working.
955
- export { resolveHelperExec as resolveHelperPath };
956
- export { resolveSocketPath };