@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.
- package/CHANGELOG.md +31 -0
- package/README.md +1 -1
- package/dist/bootstrap.js +15 -0
- 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 +9 -9
- 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/menubar/install-menubar.d.ts +56 -0
- package/dist/lib/menubar/install-menubar.js +133 -17
- package/dist/lib/menubar/resolve-version.d.ts +82 -0
- package/dist/lib/menubar/resolve-version.js +133 -0
- package/dist/lib/profiles.js +13 -2
- package/dist/lib/secrets-client.d.ts +23 -3
- package/dist/lib/secrets-client.js +57 -57
- package/dist/lib/self-heal/checks/menubar-helper.d.ts +2 -0
- package/dist/lib/self-heal/checks/menubar-helper.js +21 -0
- package/dist/lib/self-heal/registry.js +3 -0
- package/dist/lib/self-heal/types.d.ts +1 -1
- 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,20 +1,49 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
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
|
|
56
|
-
if (
|
|
57
|
-
return
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
73
|
-
*
|
|
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
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
134
|
-
registerScreenshotCommand(program);
|
|
135
|
-
registerActionCommands(program);
|
|
233
|
+
registerPassthroughVerbs(program);
|
|
136
234
|
registerSessionsCommand(program);
|
|
137
235
|
registerCommandGroups(program, COMPUTER_HELP_GROUPS);
|
|
138
236
|
}
|
|
139
|
-
|
|
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('
|
|
142
|
-
.
|
|
143
|
-
.
|
|
144
|
-
.
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
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('
|
|
306
|
-
.description('
|
|
307
|
-
.
|
|
308
|
-
.
|
|
309
|
-
.
|
|
310
|
-
.
|
|
311
|
-
.
|
|
312
|
-
.
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
370
|
-
* `
|
|
371
|
-
*
|
|
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
|
|
380
|
-
|
|
381
|
-
|
|
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
|
-
*
|
|
504
|
-
*
|
|
505
|
-
*
|
|
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
|
|
510
|
-
|
|
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
|
-
*
|
|
514
|
-
*
|
|
515
|
-
*
|
|
516
|
-
*
|
|
517
|
-
*
|
|
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
|
|
520
|
-
|
|
521
|
-
if (
|
|
522
|
-
|
|
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
|
-
|
|
553
|
-
|
|
386
|
+
const parsed = JSON.parse(stdout.slice(start));
|
|
387
|
+
return parsed.trusted === true;
|
|
554
388
|
}
|
|
555
389
|
catch {
|
|
556
|
-
|
|
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
|
-
*
|
|
571
|
-
*
|
|
572
|
-
*
|
|
573
|
-
*
|
|
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
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
|
|
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, '&')
|
|
945
|
-
.replace(/</g, '<')
|
|
946
|
-
.replace(/>/g, '>')
|
|
947
|
-
.replace(/"/g, '"')
|
|
948
|
-
.replace(/'/g, ''');
|
|
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 };
|