@phnx-labs/agents-cli 1.22.103 → 1.22.105

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +1 -1
  3. package/dist/commands/accounts.js +2 -2
  4. package/dist/commands/computer.d.ts +100 -79
  5. package/dist/commands/computer.js +290 -830
  6. package/dist/commands/run-device-picker.d.ts +58 -0
  7. package/dist/commands/run-device-picker.js +222 -0
  8. package/dist/commands/setup-computer.js +20 -1
  9. package/dist/commands/setup-secrets.d.ts +2 -2
  10. package/dist/commands/setup-secrets.js +1 -1
  11. package/dist/commands/view.js +4 -6
  12. package/dist/lib/account-catalog.d.ts +22 -1
  13. package/dist/lib/account-catalog.js +72 -38
  14. package/dist/lib/accounting/usage.js +18 -14
  15. package/dist/lib/agent-spec/agents.d.ts +1 -0
  16. package/dist/lib/agent-spec/agents.js +1 -1
  17. package/dist/lib/browser/drivers/ssh.js +1 -1
  18. package/dist/lib/computer/context.d.ts +83 -0
  19. package/dist/lib/computer/context.js +91 -0
  20. package/dist/lib/computer/policy.d.ts +46 -0
  21. package/dist/lib/computer/policy.js +160 -0
  22. package/dist/lib/computer/record.d.ts +38 -0
  23. package/dist/lib/computer/record.js +86 -0
  24. package/dist/lib/computer/sessions-list.js +6 -6
  25. package/dist/lib/computer-client.d.ts +150 -0
  26. package/dist/lib/computer-client.js +222 -0
  27. package/dist/lib/exec.js +35 -1
  28. package/dist/lib/harness/adapters/claude.d.ts +37 -0
  29. package/dist/lib/harness/adapters/claude.js +69 -0
  30. package/dist/lib/helper-download.d.ts +1 -1
  31. package/dist/lib/helper-download.js +1 -1
  32. package/dist/lib/helper-versions.d.ts +9 -4
  33. package/dist/lib/helper-versions.js +8 -8
  34. package/dist/lib/installations/shims.js +8 -4
  35. package/dist/lib/menubar/download-menubar.d.ts +2 -1
  36. package/dist/lib/menubar/download-menubar.js +2 -1
  37. package/dist/lib/secrets-client.d.ts +3 -3
  38. package/dist/lib/secrets-client.js +15 -38
  39. package/dist/lib/session/db.js +1 -1
  40. package/dist/lib/sha256-asset.d.ts +2 -1
  41. package/dist/lib/sha256-asset.js +2 -1
  42. package/dist/lib/ssh-tunnel.d.ts +61 -0
  43. package/dist/lib/ssh-tunnel.js +105 -0
  44. package/dist/lib/summarizer/summarize.d.ts +2 -2
  45. package/dist/lib/summarizer/summarize.js +10 -3
  46. package/package.json +2 -3
  47. package/dist/commands/computer-actions.d.ts +0 -55
  48. package/dist/commands/computer-actions.js +0 -594
  49. package/dist/computer.d.ts +0 -2
  50. package/dist/computer.js +0 -7
  51. package/dist/lib/computer/actions.d.ts +0 -36
  52. package/dist/lib/computer/actions.js +0 -162
  53. package/dist/lib/computer/computer-rpc.d.ts +0 -39
  54. package/dist/lib/computer/computer-rpc.js +0 -447
  55. package/dist/lib/computer/des.d.ts +0 -1
  56. package/dist/lib/computer/des.js +0 -114
  57. package/dist/lib/computer/dispatch.d.ts +0 -10
  58. package/dist/lib/computer/dispatch.js +0 -133
  59. package/dist/lib/computer/download.d.ts +0 -54
  60. package/dist/lib/computer/download.js +0 -83
  61. package/dist/lib/computer/loop.d.ts +0 -62
  62. package/dist/lib/computer/loop.js +0 -98
  63. package/dist/lib/computer/model.d.ts +0 -44
  64. package/dist/lib/computer/model.js +0 -157
  65. package/dist/lib/computer/rfb-client.d.ts +0 -53
  66. package/dist/lib/computer/rfb-client.js +0 -562
  67. package/dist/lib/computer/ssh-tunnel.d.ts +0 -189
  68. package/dist/lib/computer/ssh-tunnel.js +0 -584
@@ -1,584 +0,0 @@
1
- /**
2
- * Shared SSH port-forward tunnel + remote computer-helper provisioning.
3
- *
4
- * Two layers live here:
5
- *
6
- * 1. `startSSHTunnel` — the generic `ssh -L localPort:127.0.0.1:remotePort -N`
7
- * spawn, extracted verbatim from the browser CDP driver so both the browser
8
- * and `agents computer --device` reach a remote loopback service through one
9
- * hardened tunnel. Behavior for the browser caller is unchanged (default,
10
- * foreground, stderr-captured).
11
- *
12
- * 2. Remote computer-helper orchestration — resolve a registered device to an
13
- * ssh target, push the cross-published Windows daemon exe, register it as a
14
- * LOGON scheduled task (interactive session so real-desktop UIA/screenshot
15
- * works and it survives the ssh disconnect), and open a tunnel the TS RPC
16
- * client drives via TCP. Everything rides the existing `ssh-exec` /
17
- * `devices/connect` primitives — no parallel SSH implementation.
18
- */
19
- import { spawn } from 'child_process';
20
- import * as net from 'net';
21
- import * as fs from 'fs';
22
- import * as path from 'path';
23
- import { fileURLToPath } from 'url';
24
- import { randomBytes } from 'crypto';
25
- import { parseSha256Asset, sha256File } from '../sha256-asset.js';
26
- import { Readable } from 'stream';
27
- import { pipeline } from 'stream/promises';
28
- import { sshExec, SSH_OPTS, assertValidSshTarget } from '../ssh-exec.js';
29
- import { backgroundSpawnOptions } from '../platform/process.js';
30
- import { encodePowerShell } from '../browser/drivers/ssh.js';
31
- import { getDevice } from '../devices/registry.js';
32
- import { deviceIdentityArgs, sshTargetFor } from '../devices/connect.js';
33
- import { hostNameFor } from '../devices/ssh-config.js';
34
- import { getCacheDir } from '../state.js';
35
- import { openComputerClient, resolveTcpEndpoint } from './computer-rpc.js';
36
- import { helperFloor, helperTag } from '../helper-versions.js';
37
- /** Build the ssh argv (after the `ssh` program name) for an `-L` tunnel. Pure.
38
- *
39
- * Composes the shared hardened baseline (`SSH_OPTS`) rather than re-listing it,
40
- * so the tunnel inherits the same options — crucially the keepalive, which lets
41
- * a dropped `-N` tunnel exit instead of lingering as a zombie on the laptop. */
42
- export function buildTunnelArgs(user, host, localPort, remotePort, extraSshArgs = []) {
43
- return [
44
- '-L',
45
- `${localPort}:127.0.0.1:${remotePort}`,
46
- `${user}@${host}`,
47
- '-N',
48
- ...extraSshArgs,
49
- ...SSH_OPTS,
50
- ];
51
- }
52
- /**
53
- * Spawn `ssh -L localPort:127.0.0.1:remotePort -N user@host`.
54
- *
55
- * Foreground (default): stderr is captured so a tunnel that dies inside 500ms
56
- * rejects with the ssh error — the browser driver's original contract. Detached
57
- * mode ignores stdio and `unref`s the child so the parent can exit while the
58
- * tunnel lives; liveness is then confirmed by the caller probing the service.
59
- */
60
- export function startSSHTunnel(user, host, localPort, remotePort, opts = {}) {
61
- return new Promise((resolve, reject) => {
62
- // `user`/`host` can originate from a browser ssh:// profile or a device
63
- // record. buildTunnelArgs places `${user}@${host}` before `-N`/SSH_OPTS, so
64
- // a `-`-leading user would be parsed as an ssh option flag (option
65
- // injection). Validate at the spawn sink so every caller is covered; reject
66
- // (rather than throw synchronously) to keep the Promise contract.
67
- try {
68
- assertValidSshTarget(`${user}@${host}`);
69
- }
70
- catch (err) {
71
- reject(err);
72
- return;
73
- }
74
- const args = buildTunnelArgs(user, host, localPort, remotePort, opts.extraSshArgs);
75
- const tunnel = spawn('ssh', args, {
76
- stdio: opts.detached ? 'ignore' : ['ignore', 'ignore', 'pipe'],
77
- ...(opts.detached ? backgroundSpawnOptions() : { detached: false, windowsHide: true }),
78
- });
79
- let stderr = '';
80
- tunnel.stderr?.on('data', (data) => {
81
- stderr += data.toString();
82
- });
83
- tunnel.on('error', (err) => {
84
- reject(new Error(`SSH tunnel failed: ${err.message}`));
85
- });
86
- setTimeout(() => {
87
- if (tunnel.killed) {
88
- reject(new Error(`SSH tunnel died: ${stderr}`));
89
- }
90
- else {
91
- // Let the CLI exit without waiting on a persistent tunnel.
92
- if (opts.detached)
93
- tunnel.unref();
94
- resolve(tunnel);
95
- }
96
- }, 500);
97
- });
98
- }
99
- // ---------------------------------------------------------------------------
100
- // 2. Remote computer-helper orchestration
101
- // ---------------------------------------------------------------------------
102
- /** Loopback TCP port the Windows daemon binds on the remote (Program.cs default). */
103
- export const REMOTE_HELPER_PORT = 8765;
104
- /** Task Scheduler task name for the daemon. Stable so setup/stop pair up. */
105
- export const REMOTE_TASK_NAME = 'AgentsComputerHelper';
106
- /** Basename of the cross-published exe under native/computer-win/dist. */
107
- export const WIN_HELPER_EXE = 'computer-helper-win.exe';
108
- /** Basename of the daemon's shared-secret token file under %LOCALAPPDATA%\agents. */
109
- export const WIN_HELPER_TOKEN_FILE = 'helper-token';
110
- /**
111
- * Locate a locally built Windows daemon exe — a repo-checkout build output from
112
- * `scripts/build-win.sh`, or one bundled next to the package. Local paths are
113
- * only the first candidate; npm-installed CLIs have neither and fall through to
114
- * the release-asset download (`ensureWinHelperExe`).
115
- */
116
- export function resolveWinHelperExe() {
117
- const here = path.dirname(fileURLToPath(import.meta.url));
118
- const candidates = [
119
- // Running from the agents-cli checkout. cli/dist/lib/computer -> repo root (4 up) -> native/computer-win.
120
- path.resolve(here, '..', '..', '..', '..', 'native', 'computer-win', 'dist', WIN_HELPER_EXE),
121
- // Bundled with the npm package (dist/lib/computer -> dist/).
122
- path.resolve(here, '..', '..', 'computer-helper-win', WIN_HELPER_EXE),
123
- ];
124
- for (const c of candidates) {
125
- if (fs.existsSync(c))
126
- return c;
127
- }
128
- return null;
129
- }
130
- /** GitHub repo whose `computer-win/v<version>` releases carry the exe as an asset. */
131
- export const WIN_HELPER_RELEASE_REPO = 'phnx-labs/agi-cli';
132
- /** Cache dir for downloaded helper exes, one subdir per release tag. */
133
- export function winHelperCacheDir(version) {
134
- return path.join(getCacheDir(), 'computer', 'win-helper', `v${version}`);
135
- }
136
- /** Release-asset URLs for the exe + its checksum at one exact `computer-win/v<version>` tag. */
137
- export function winHelperAssetUrls(version) {
138
- // The helper's OWN tag, not the CLI's. This exe is not an .app bundle so it
139
- // cannot share helper-download.ts's zip/codesign machinery, but it had the
140
- // identical coupling: keyed to `v${cliVersion}`, every CLI release had to
141
- // re-stage a ~165MB exe or the download 404'd, and a helper fix could not
142
- // reach anyone without a CLI release. Same fix, same tag namespace.
143
- const base = `https://github.com/${WIN_HELPER_RELEASE_REPO}/releases/download/${helperTag('computer-win', version)}`;
144
- return { exe: `${base}/${WIN_HELPER_EXE}`, sha256: `${base}/${WIN_HELPER_EXE}.sha256` };
145
- }
146
- /**
147
- * Parse the published `.sha256` asset — `sha256sum` format (`<hex> <name>`) or
148
- * a bare hex digest. Throws on anything that does not lead with 64 hex chars.
149
- */
150
- /**
151
- * Download the exe release asset for this HELPER version, verify its sha256
152
- * against the published `.sha256` asset, and cache it under the agents cache
153
- * dir. Only the exact `computer-win/v<version>` tag is consulted — a missing
154
- * asset is a hard error naming that tag, never a silent fallback to another
155
- * release. The version is the HELPER's own, not the CLI's.
156
- */
157
- export async function downloadWinHelperExe(version) {
158
- const cached = path.join(winHelperCacheDir(version), WIN_HELPER_EXE);
159
- if (fs.existsSync(cached))
160
- return cached;
161
- // The SAME tag the URL is built from. This said `v${version}` — the CLI's tag
162
- // shape — while winHelperAssetUrls uses helperTag('computer-win', ...), so a
163
- // missing asset sent you looking for a tag that does not exist. The mac path
164
- // had this fixed already; the Windows path was left behind by the tag split.
165
- const tag = helperTag('computer-win', version);
166
- const { exe: exeUrl, sha256: shaUrl } = winHelperAssetUrls(version);
167
- const missing = (status, url) => new Error(`no ${WIN_HELPER_EXE} release asset for tag ${tag} (HTTP ${status} on ${url}). ` +
168
- `The Windows helper ships as a GitHub release asset on its own helper tag; ` +
169
- `from a repo checkout you can build it locally instead: bash scripts/build-win.sh`);
170
- // Checksum first: it is tiny and 404s fast when the tag has no assets.
171
- const shaRes = await fetch(shaUrl, { signal: AbortSignal.timeout(30_000) });
172
- if (!shaRes.ok)
173
- throw missing(shaRes.status, shaUrl);
174
- const expected = parseSha256Asset(await shaRes.text());
175
- console.error(`Downloading ${WIN_HELPER_EXE} ${tag} from GitHub releases (~160 MB)...`);
176
- const exeRes = await fetch(exeUrl, { signal: AbortSignal.timeout(15 * 60_000) });
177
- if (!exeRes.ok || !exeRes.body)
178
- throw missing(exeRes.status, exeUrl);
179
- fs.mkdirSync(path.dirname(cached), { recursive: true });
180
- // Stream to a partial file and rename only after the checksum passes, so an
181
- // interrupted download can never be picked up as a valid cache hit.
182
- const partial = `${cached}.download`;
183
- try {
184
- await pipeline(Readable.fromWeb(exeRes.body), fs.createWriteStream(partial));
185
- const actual = await sha256File(partial);
186
- if (actual !== expected) {
187
- throw new Error(`sha256 mismatch for ${exeUrl}: expected ${expected}, got ${actual}`);
188
- }
189
- fs.renameSync(partial, cached);
190
- }
191
- finally {
192
- fs.rmSync(partial, { force: true });
193
- }
194
- return cached;
195
- }
196
- /**
197
- * Resolve the helper exe for `setup --device`: local build outputs first (repo
198
- * checkout / bundled), then the checksum-verified release-asset download for
199
- * the running CLI version. Throws with the tag it checked when neither exists.
200
- */
201
- export async function ensureWinHelperExe(version = helperFloor('computer-win')) {
202
- const local = resolveWinHelperExe();
203
- if (local)
204
- return local;
205
- return downloadWinHelperExe(version);
206
- }
207
- function remoteStateDir() {
208
- return path.join(getCacheDir(), 'computer', 'remote');
209
- }
210
- /** State file path for a device. Device names are ssh-alias safe (validated). */
211
- export function remoteStatePath(device) {
212
- return path.join(remoteStateDir(), `${device}.json`);
213
- }
214
- export function readRemoteState(device) {
215
- try {
216
- return JSON.parse(fs.readFileSync(remoteStatePath(device), 'utf-8'));
217
- }
218
- catch {
219
- return null;
220
- }
221
- }
222
- export function writeRemoteState(state) {
223
- const dir = remoteStateDir();
224
- fs.mkdirSync(dir, { recursive: true });
225
- fs.writeFileSync(remoteStatePath(state.device), JSON.stringify(state, null, 2), { mode: 0o600 });
226
- }
227
- export function clearRemoteState(device) {
228
- try {
229
- fs.unlinkSync(remoteStatePath(device));
230
- }
231
- catch {
232
- /* already gone */
233
- }
234
- }
235
- /**
236
- * Local file holding the shared-secret token for a device's helper daemon.
237
- * Written at `setup --device` (0600), read at `start --device` so the RPC client
238
- * authenticates. The remote daemon reads the same value from its own
239
- * `--token-file`; the CLI is the source of truth and never round-trips it back.
240
- */
241
- export function helperTokenPath(device) {
242
- return path.join(remoteStateDir(), `${device}.token`);
243
- }
244
- export function readHelperToken(device) {
245
- try {
246
- const t = fs.readFileSync(helperTokenPath(device), 'utf-8').trim();
247
- return t.length > 0 ? t : null;
248
- }
249
- catch {
250
- return null;
251
- }
252
- }
253
- export function writeHelperToken(device, token) {
254
- const dir = remoteStateDir();
255
- fs.mkdirSync(dir, { recursive: true });
256
- fs.writeFileSync(helperTokenPath(device), token, { mode: 0o600 });
257
- }
258
- export function clearHelperToken(device) {
259
- try {
260
- fs.unlinkSync(helperTokenPath(device));
261
- }
262
- catch {
263
- /* already gone */
264
- }
265
- }
266
- /** Resolve a registered device to its ssh pieces, or throw a clear error. */
267
- export async function resolveRemoteDevice(name) {
268
- const device = await getDevice(name);
269
- if (!device) {
270
- throw new Error(`Unknown device '${name}'. Register it with \`agents devices add\` / \`agents devices sync\`, then retry.`);
271
- }
272
- if (device.platform !== 'windows') {
273
- throw new Error(`Device '${name}' is ${device.platform}, not windows. \`agents computer --device\` drives the Windows computer-helper daemon.`);
274
- }
275
- const target = sshTargetFor(device); // validates address + injection guard
276
- const host = hostNameFor(device); // sshTargetFor already threw if absent
277
- const user = device.user || process.env.USER || 'Administrator';
278
- return { device, target, user, host };
279
- }
280
- /**
281
- * Single-quote a string for embedding inside a PowerShell literal.
282
- */
283
- function psSingleQuote(s) {
284
- return "'" + s.replace(/'/g, "''") + "'";
285
- }
286
- /**
287
- * PowerShell that resolves the destination under %LOCALAPPDATA%\agents and
288
- * stops any running instance first so the file is not locked. The caller copies
289
- * the exe with scp and then verifies the byte count separately.
290
- */
291
- export function buildPushScript() {
292
- return [
293
- `$dir = Join-Path $env:LOCALAPPDATA 'agents'`,
294
- `New-Item -ItemType Directory -Force -Path $dir | Out-Null`,
295
- `$dst = Join-Path $dir '${WIN_HELPER_EXE}'`,
296
- `Get-Process -Name 'computer-helper-win' -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue`,
297
- `Write-Output $dst`,
298
- ].join('; ');
299
- }
300
- /** PowerShell that verifies scp wrote the expected number of bytes. */
301
- export function buildVerifyPushScript(remotePath, expectedBytes) {
302
- return [
303
- `$dst = ${psSingleQuote(remotePath)}`,
304
- `$item = Get-Item -LiteralPath $dst -ErrorAction Stop`,
305
- `if ($item.Length -ne ${expectedBytes}) { throw "helper copy length mismatch: expected ${expectedBytes}, got $($item.Length)" }`,
306
- `Write-Output "$dst $($item.Length)"`,
307
- ].join('; ');
308
- }
309
- /**
310
- * PowerShell that registers the daemon as a LOGON scheduled task. Interactive
311
- * logon type + Highest run level so the daemon runs in the real desktop session
312
- * (UIAutomation and ScreenCapture need a live session, not Session 0) and
313
- * survives ssh disconnect — the same rationale as the browser WMI launch. The
314
- * task is started immediately so the caller need not log out/in.
315
- */
316
- /**
317
- * PowerShell that writes the shared-secret token under %LOCALAPPDATA%\agents
318
- * with an owner-only ACL (inheritance removed, read granted only to the current
319
- * user) and echoes the resolved path so the caller can pass it to `--token-file`.
320
- */
321
- export function buildWriteTokenScript(token) {
322
- return [
323
- `$dir = Join-Path $env:LOCALAPPDATA 'agents'`,
324
- `New-Item -ItemType Directory -Force -Path $dir | Out-Null`,
325
- `$tok = Join-Path $dir '${WIN_HELPER_TOKEN_FILE}'`,
326
- `Set-Content -LiteralPath $tok -Value ${psSingleQuote(token)} -NoNewline -Encoding ascii`,
327
- `icacls $tok /inheritance:r /grant:r ("$($env:USERNAME):(R)") | Out-Null`,
328
- `Write-Output $tok`,
329
- ].join('; ');
330
- }
331
- export function buildRegisterTaskScript(port, taskName, tokenPath) {
332
- const argument = `--port ${port} --token-file "${tokenPath}"`;
333
- return [
334
- `$exe = Join-Path (Join-Path $env:LOCALAPPDATA 'agents') '${WIN_HELPER_EXE}'`,
335
- `$action = New-ScheduledTaskAction -Execute $exe -Argument ${psSingleQuote(argument)}`,
336
- `$trigger = New-ScheduledTaskTrigger -AtLogOn`,
337
- `$principal = New-ScheduledTaskPrincipal -UserId $env:USERNAME -LogonType Interactive -RunLevel Highest`,
338
- `$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit ([TimeSpan]::Zero)`,
339
- `Register-ScheduledTask -TaskName '${taskName}' -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force | Out-Null`,
340
- `Start-ScheduledTask -TaskName '${taskName}'`,
341
- ].join('; ');
342
- }
343
- /** PowerShell that unregisters the task and stops any running daemon process. */
344
- export function buildUnregisterTaskScript(taskName) {
345
- return [
346
- `Unregister-ScheduledTask -TaskName '${taskName}' -Confirm:$false -ErrorAction SilentlyContinue`,
347
- `Get-Process -Name 'computer-helper-win' -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue`,
348
- ].join('; ');
349
- }
350
- /** Convert a Windows path returned by PowerShell into the scp/SFTP path form. */
351
- export function scpRemotePath(remotePath) {
352
- return remotePath.replace(/\\/g, '/');
353
- }
354
- /**
355
- * Build the scp argv used for the helper exe transfer. Exported so tests can
356
- * assert the real binary copy path keeps BatchMode and does not route bytes
357
- * through a PowerShell decoder.
358
- */
359
- export function buildScpArgs(target, remotePath, filePath, extraSshArgs = []) {
360
- return [...extraSshArgs, ...SSH_OPTS, filePath, `${target}:${scpRemotePath(remotePath)}`];
361
- }
362
- /**
363
- * Copy a local file to the remote destination with scp. This is a binary
364
- * transfer; no base64 transform runs on either side.
365
- */
366
- function copyFileOverScp(target, remotePath, filePath, extraSshArgs = [], timeoutMs = 600_000) {
367
- return new Promise((resolve, reject) => {
368
- const child = spawn('scp', buildScpArgs(target, remotePath, filePath, extraSshArgs), {
369
- stdio: ['ignore', 'ignore', 'pipe'],
370
- });
371
- let stderr = '';
372
- child.stderr.on('data', (d) => (stderr += d.toString()));
373
- const timer = setTimeout(() => {
374
- child.kill('SIGKILL');
375
- reject(new Error(`scp push to ${target} timed out after ${timeoutMs}ms`));
376
- }, timeoutMs);
377
- const fail = (e) => {
378
- clearTimeout(timer);
379
- child.kill('SIGKILL');
380
- reject(e);
381
- };
382
- child.on('error', fail);
383
- child.on('close', (code) => {
384
- clearTimeout(timer);
385
- resolve({ code, stderr });
386
- });
387
- });
388
- }
389
- /**
390
- * `setup --device`: push the exe, then register + start the LOGON task. Remote
391
- * PowerShell hops go through `sshExec` (BatchMode key auth — the same hardening
392
- * the browser driver and `agents ssh` use), and the large exe rides a binary
393
- * scp transfer. Throws with the remote stderr on any failure.
394
- */
395
- export async function setupRemoteHelper(name) {
396
- const { device, target } = await resolveRemoteDevice(name);
397
- const identityArgs = deviceIdentityArgs(device);
398
- // Local build output, else the checksum-verified GitHub release asset for
399
- // this CLI version. Throws naming the tag it checked when neither exists.
400
- const exe = await ensureWinHelperExe();
401
- const prep = sshExec(target, encodePowerShell(buildPushScript()), { timeoutMs: 60_000, extraSshArgs: identityArgs });
402
- if (prep.code !== 0) {
403
- throw new Error(`preparing helper exe path on '${name}' failed (exit ${prep.code ?? 'null'}): ${prep.stderr.trim() || prep.stdout.trim()}`);
404
- }
405
- const remotePath = prep.stdout.trim().split(/\r?\n/).filter(Boolean).at(-1);
406
- if (!remotePath) {
407
- throw new Error(`preparing helper exe path on '${name}' did not return a destination path`);
408
- }
409
- const push = await copyFileOverScp(target, remotePath, exe, identityArgs);
410
- if (push.code !== 0) {
411
- throw new Error(`pushing helper exe to '${name}' failed (exit ${push.code ?? 'null'}): ${push.stderr.trim()}`);
412
- }
413
- const expectedBytes = fs.statSync(exe).size;
414
- const verify = sshExec(target, encodePowerShell(buildVerifyPushScript(remotePath, expectedBytes)), { timeoutMs: 60_000, extraSshArgs: identityArgs });
415
- if (verify.code !== 0) {
416
- throw new Error(`verifying helper exe on '${name}' failed (exit ${verify.code ?? 'null'}): ${verify.stderr.trim() || verify.stdout.trim()}`);
417
- }
418
- // Provision the auth token: generate it locally, write it on the remote with
419
- // an owner-only ACL, and register the task with --token-file. The daemon now
420
- // refuses to start without a token, so a token-less (open-to-any-local-process)
421
- // daemon can no longer be stood up through the CLI.
422
- const token = generateToken();
423
- const tokWrite = sshExec(target, encodePowerShell(buildWriteTokenScript(token)), { timeoutMs: 60_000, extraSshArgs: identityArgs });
424
- if (tokWrite.code !== 0) {
425
- throw new Error(`writing helper token on '${name}' failed (exit ${tokWrite.code ?? 'null'}): ${tokWrite.stderr.trim() || tokWrite.stdout.trim()}`);
426
- }
427
- const remoteTokenPath = tokWrite.stdout.trim().split(/\r?\n/).filter(Boolean).at(-1);
428
- if (!remoteTokenPath) {
429
- throw new Error(`writing helper token on '${name}' did not return a destination path`);
430
- }
431
- // Register + start the LOGON task, pointing it at the token file.
432
- const reg = sshExec(target, encodePowerShell(buildRegisterTaskScript(REMOTE_HELPER_PORT, REMOTE_TASK_NAME, remoteTokenPath)), {
433
- timeoutMs: 60_000,
434
- extraSshArgs: identityArgs,
435
- });
436
- if (reg.code !== 0) {
437
- throw new Error(`registering scheduled task on '${name}' failed (exit ${reg.code ?? 'null'}): ${reg.stderr.trim() || reg.stdout.trim()}`);
438
- }
439
- // Persist the token locally so `start --device` authenticates to the daemon.
440
- writeHelperToken(name, token);
441
- return { target, taskName: REMOTE_TASK_NAME };
442
- }
443
- /** Reserve a free local TCP port by binding :0 and reading the assigned port. */
444
- export function pickFreePort() {
445
- return new Promise((resolve, reject) => {
446
- const srv = net.createServer();
447
- srv.once('error', reject);
448
- srv.listen(0, '127.0.0.1', () => {
449
- const addr = srv.address();
450
- const port = typeof addr === 'object' && addr ? addr.port : 0;
451
- srv.close(() => (port ? resolve(port) : reject(new Error('could not reserve a local port'))));
452
- });
453
- });
454
- }
455
- /**
456
- * `start --device`: open a detached ssh -L tunnel to the remote daemon, verify it
457
- * answers over TCP, and persist the tunnel state so verbs can reconnect. Returns
458
- * the state (and leaves the tunnel running in the background).
459
- */
460
- export async function startRemoteTunnel(name) {
461
- const { device, target, user, host } = await resolveRemoteDevice(name);
462
- const remotePort = REMOTE_HELPER_PORT;
463
- const localPort = await pickFreePort();
464
- const tunnel = await startSSHTunnel(user, host, localPort, remotePort, {
465
- detached: true,
466
- extraSshArgs: deviceIdentityArgs(device),
467
- });
468
- const tunnelPid = tunnel.pid ?? 0;
469
- // Verify the daemon answers through the tunnel before we record it. This is
470
- // the real end-to-end check: tunnel up + daemon listening + RPC round-trips.
471
- // The daemon now requires a token, so the probe must authenticate with the one
472
- // provisioned at `setup`. A missing token means setup predates auth — guide
473
- // the user to re-run it rather than silently failing the RPC.
474
- const token = readHelperToken(name);
475
- const prevTcp = process.env.COMPUTER_HELPER_TCP;
476
- const prevTok = process.env.COMPUTER_HELPER_TOKEN;
477
- process.env.COMPUTER_HELPER_TCP = `127.0.0.1:${localPort}`;
478
- if (token)
479
- process.env.COMPUTER_HELPER_TOKEN = token;
480
- else
481
- delete process.env.COMPUTER_HELPER_TOKEN;
482
- const client = openComputerClient();
483
- let ok = false;
484
- let probeErr = '';
485
- try {
486
- const r = await client.call('list_apps');
487
- ok = !r.error;
488
- if (r.error)
489
- probeErr = `${r.error.code}: ${r.error.message}`;
490
- }
491
- catch (e) {
492
- probeErr = e.message;
493
- }
494
- finally {
495
- await client.close();
496
- if (prevTcp === undefined)
497
- delete process.env.COMPUTER_HELPER_TCP;
498
- else
499
- process.env.COMPUTER_HELPER_TCP = prevTcp;
500
- if (prevTok === undefined)
501
- delete process.env.COMPUTER_HELPER_TOKEN;
502
- else
503
- process.env.COMPUTER_HELPER_TOKEN = prevTok;
504
- }
505
- if (!ok) {
506
- try {
507
- if (tunnelPid)
508
- process.kill(tunnelPid);
509
- }
510
- catch { /* gone */ }
511
- const hint = token
512
- ? `Is it installed? Run: agents computer setup --device ${name}`
513
- : `No auth token on record for '${name}' — re-run: agents computer setup --device ${name}`;
514
- throw new Error(`tunnel to '${name}' opened but the daemon did not answer (${probeErr}). ${hint}`);
515
- }
516
- const state = {
517
- device: name,
518
- target,
519
- localPort,
520
- remotePort,
521
- tunnelPid,
522
- token,
523
- taskName: REMOTE_TASK_NAME,
524
- startedAt: Date.now(),
525
- };
526
- writeRemoteState(state);
527
- return state;
528
- }
529
- /**
530
- * `stop --device`: kill the local tunnel, unregister the remote task (best-effort
531
- * — the box may be offline), and clear the persisted state.
532
- */
533
- export async function stopRemoteHelper(name) {
534
- const state = readRemoteState(name);
535
- let tunnelKilled = false;
536
- if (state?.tunnelPid) {
537
- try {
538
- process.kill(state.tunnelPid);
539
- tunnelKilled = true;
540
- }
541
- catch {
542
- /* already gone */
543
- }
544
- }
545
- let taskRemoved = false;
546
- try {
547
- const { device, target } = await resolveRemoteDevice(name);
548
- const res = sshExec(target, encodePowerShell(buildUnregisterTaskScript(REMOTE_TASK_NAME)), {
549
- timeoutMs: 60_000,
550
- extraSshArgs: deviceIdentityArgs(device),
551
- });
552
- taskRemoved = res.code === 0;
553
- }
554
- catch {
555
- /* device gone / offline — local teardown still succeeds */
556
- }
557
- clearRemoteState(name);
558
- clearHelperToken(name);
559
- return { tunnelKilled, taskRemoved };
560
- }
561
- /**
562
- * Point this process's RPC client at a device's live tunnel by setting
563
- * COMPUTER_HELPER_TCP / COMPUTER_HELPER_TOKEN from persisted state. Called for
564
- * remote verbs (`apps --device`, `click --device`, …) so the shared
565
- * openComputerClient() transparently selects the TcpClient transport — no
566
- * per-verb wiring. Exits with guidance when there is no active tunnel.
567
- */
568
- export function hydrateRemoteEnvFromState(name) {
569
- const state = readRemoteState(name);
570
- if (!state) {
571
- console.error(`No active remote tunnel for '${name}'.`);
572
- console.error(`Run: agents computer start --device ${name}`);
573
- process.exit(1);
574
- }
575
- process.env.COMPUTER_HELPER_TCP = `127.0.0.1:${state.localPort}`;
576
- if (state.token)
577
- process.env.COMPUTER_HELPER_TOKEN = state.token;
578
- // Touch resolveTcpEndpoint so a later platform-gate check sees the endpoint.
579
- void resolveTcpEndpoint();
580
- }
581
- /** Generate a shared-secret token (reserved for token-file provisioning). */
582
- export function generateToken() {
583
- return randomBytes(24).toString('hex');
584
- }