@phnx-labs/agents-cli 1.20.67 → 1.20.69

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 (83) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/README.md +14 -1
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/check.js +96 -2
  5. package/dist/commands/computer.d.ts +26 -0
  6. package/dist/commands/computer.js +173 -149
  7. package/dist/commands/doctor.js +37 -9
  8. package/dist/commands/exec.d.ts +18 -0
  9. package/dist/commands/exec.js +126 -6
  10. package/dist/commands/fork.d.ts +9 -0
  11. package/dist/commands/fork.js +67 -0
  12. package/dist/commands/plugins.js +11 -2
  13. package/dist/commands/repo.js +47 -2
  14. package/dist/commands/run-account-picker.d.ts +14 -0
  15. package/dist/commands/run-account-picker.js +131 -0
  16. package/dist/commands/setup-browser.d.ts +18 -0
  17. package/dist/commands/setup-browser.js +142 -0
  18. package/dist/commands/setup-computer.d.ts +19 -0
  19. package/dist/commands/setup-computer.js +133 -0
  20. package/dist/commands/setup-share.d.ts +17 -0
  21. package/dist/commands/setup-share.js +85 -0
  22. package/dist/commands/setup.d.ts +1 -1
  23. package/dist/commands/setup.js +58 -1
  24. package/dist/commands/share.d.ts +15 -0
  25. package/dist/commands/share.js +10 -4
  26. package/dist/commands/ssh.js +267 -2
  27. package/dist/commands/teams.d.ts +11 -0
  28. package/dist/commands/teams.js +39 -1
  29. package/dist/commands/view.d.ts +4 -14
  30. package/dist/commands/view.js +39 -36
  31. package/dist/index.js +2 -1
  32. package/dist/lib/agents.d.ts +21 -5
  33. package/dist/lib/agents.js +59 -24
  34. package/dist/lib/auth-health.d.ts +103 -0
  35. package/dist/lib/auth-health.js +232 -0
  36. package/dist/lib/browser/chrome.d.ts +9 -0
  37. package/dist/lib/browser/chrome.js +22 -0
  38. package/dist/lib/browser/profiles.js +4 -25
  39. package/dist/lib/cli-entry.d.ts +23 -0
  40. package/dist/lib/cli-entry.js +116 -0
  41. package/dist/lib/computer/download.d.ts +51 -0
  42. package/dist/lib/computer/download.js +145 -0
  43. package/dist/lib/computer-rpc.js +9 -3
  44. package/dist/lib/daemon.d.ts +2 -2
  45. package/dist/lib/daemon.js +8 -89
  46. package/dist/lib/devices/connect.d.ts +15 -0
  47. package/dist/lib/devices/connect.js +17 -5
  48. package/dist/lib/devices/fleet.d.ts +23 -0
  49. package/dist/lib/devices/fleet.js +38 -0
  50. package/dist/lib/devices/health-report.d.ts +46 -0
  51. package/dist/lib/devices/health-report.js +159 -0
  52. package/dist/lib/devices/health.d.ts +14 -4
  53. package/dist/lib/devices/health.js +49 -8
  54. package/dist/lib/devices/terminfo.d.ts +44 -0
  55. package/dist/lib/devices/terminfo.js +167 -0
  56. package/dist/lib/git.d.ts +10 -0
  57. package/dist/lib/git.js +23 -0
  58. package/dist/lib/hosts/option.js +1 -1
  59. package/dist/lib/hosts/ready.js +5 -3
  60. package/dist/lib/hosts/remote-cmd.d.ts +12 -0
  61. package/dist/lib/hosts/remote-cmd.js +17 -1
  62. package/dist/lib/mcp.d.ts +21 -0
  63. package/dist/lib/mcp.js +278 -13
  64. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  65. package/dist/lib/resources/mcp.js +20 -342
  66. package/dist/lib/rotate.d.ts +12 -8
  67. package/dist/lib/rotate.js +22 -23
  68. package/dist/lib/routines.d.ts +13 -0
  69. package/dist/lib/routines.js +21 -0
  70. package/dist/lib/runner.js +18 -40
  71. package/dist/lib/secrets/agent.js +11 -15
  72. package/dist/lib/session/fork.d.ts +32 -0
  73. package/dist/lib/session/fork.js +101 -0
  74. package/dist/lib/share/capture.js +21 -3
  75. package/dist/lib/signin-badge.d.ts +41 -0
  76. package/dist/lib/signin-badge.js +64 -0
  77. package/dist/lib/ssh-exec.d.ts +5 -0
  78. package/dist/lib/ssh-exec.js +55 -1
  79. package/dist/lib/startup/command-registry.d.ts +1 -0
  80. package/dist/lib/startup/command-registry.js +2 -0
  81. package/dist/lib/usage.d.ts +40 -3
  82. package/dist/lib/usage.js +147 -16
  83. package/package.json +1 -1
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Resolve how to re-invoke THIS `agents` CLI as a child process, correctly across
3
+ * both install shapes:
4
+ *
5
+ * 1. **JS install** — `agents` is a `dist/index.js` (or a symlink / `#!node`
6
+ * shim to it). `process.execPath` is `node`, `process.argv[1]` is the script.
7
+ * Relaunch as `node <entry> <sub…>`.
8
+ * 2. **Bun standalone binary** (#315) — `agents` is a compiled Mach-O/ELF/PE.
9
+ * `process.execPath` is the physical signed binary, and `process.argv[1]` is
10
+ * the *virtual* embedded entry `/$bunfs/root/agents`, which Bun reports as an
11
+ * existing path. Passing that virtual path as an argv element makes the CLI
12
+ * receive it as a subcommand and die with `unknown command '/$bunfs/root/agents'`.
13
+ * Relaunch by executing the physical binary directly: `<binary> <sub…>`.
14
+ *
15
+ * Both `getDaemonLaunch` (daemon.ts) and the secrets-broker `cliSpawn`
16
+ * (secrets/agent.ts) route through here so the two never drift. This module is a
17
+ * leaf — it imports nothing from `lib/` — so it can be pulled into either without
18
+ * an import cycle (daemon.ts ↔ secrets/agent.ts already form one).
19
+ */
20
+ import { execFileSync } from 'child_process';
21
+ import * as fs from 'fs';
22
+ import * as path from 'path';
23
+ export const BUN_VIRTUAL_ROOT = /[/\\]\$bunfs[/\\]root[/\\]/;
24
+ function resolveBunStandaloneEntry(entry, execPath) {
25
+ if (!BUN_VIRTUAL_ROOT.test(entry))
26
+ return entry;
27
+ if (!execPath || BUN_VIRTUAL_ROOT.test(execPath) || !fs.existsSync(execPath)) {
28
+ throw new Error(`Cannot resolve agents CLI: Bun standalone executable not found at ${execPath || '(empty path)'}`);
29
+ }
30
+ return execPath;
31
+ }
32
+ export function getAgentsBinPath(argv1 = process.argv[1], execPath = process.execPath) {
33
+ // Prefer the binary actively executing this code. `which agents` returns
34
+ // whatever happens to be first on PATH, which means a side-by-side dev
35
+ // build at ~/.local/bin would silently spawn the registry-installed
36
+ // daemon and run stale code. For a JS install, process.argv[1] is the
37
+ // absolute entrypoint the user actually invoked. A Bun standalone instead
38
+ // exposes its embedded /$bunfs/root entry at argv[1] and its physical signed
39
+ // executable at process.execPath; Bun reports both as existing paths.
40
+ const runningEntry = argv1 ? resolveBunStandaloneEntry(argv1, execPath) : undefined;
41
+ if (runningEntry && fs.existsSync(runningEntry)) {
42
+ // The package's browser/computer entrypoints are sibling shims without a
43
+ // `daemon` command. A daemon started as their IPC side effect must launch
44
+ // through the main agents entrypoint instead of replaying the shim path.
45
+ const entryName = path.basename(runningEntry);
46
+ const compiledShim = /^(browser|computer)\.(c|m)?js$/.test(entryName);
47
+ const installedShim = /^(browser|computer)$/.test(entryName);
48
+ if (compiledShim || installedShim) {
49
+ const agentsEntry = path.join(path.dirname(runningEntry), compiledShim ? 'index.js' : 'agents');
50
+ if (!fs.existsSync(agentsEntry)) {
51
+ throw new Error(`Cannot start agents daemon: main CLI entry not found at ${agentsEntry}`);
52
+ }
53
+ return agentsEntry;
54
+ }
55
+ return runningEntry;
56
+ }
57
+ try {
58
+ return execFileSync('which', ['agents'], { encoding: 'utf-8' }).trim();
59
+ }
60
+ catch {
61
+ return 'agents';
62
+ }
63
+ }
64
+ /**
65
+ * A CLI entry must be launched through the Node runtime when it is a Node
66
+ * script — a `.js`/`.cjs`/`.mjs` file, OR a symlink/extension-less shim whose
67
+ * shebang names `node`. Package installs link `bin/agents` to a `dist/index.js`
68
+ * (a symlink) or drop an extension-less `#!/usr/bin/env node` shim, so an
69
+ * extension check alone misses them and they get run directly. A real compiled
70
+ * binary (Mach-O/ELF/PE) has no `#!node` shebang, so it takes the direct branch
71
+ * and owns its own runtime resolution.
72
+ */
73
+ export function isNodeScriptEntry(agentsBin) {
74
+ let resolved = agentsBin;
75
+ try {
76
+ resolved = fs.realpathSync(agentsBin);
77
+ }
78
+ catch {
79
+ // Unresolvable (e.g. a template path that does not exist on this box): fall
80
+ // back to the extension check on the path as given.
81
+ }
82
+ if (/\.(c|m)?js$/.test(resolved))
83
+ return true;
84
+ try {
85
+ const fd = fs.openSync(resolved, 'r');
86
+ try {
87
+ const buf = Buffer.alloc(128);
88
+ const n = fs.readSync(fd, buf, 0, 128, 0);
89
+ const firstLine = buf.toString('utf-8', 0, n).split('\n', 1)[0];
90
+ return firstLine.startsWith('#!') && /\bnode\b/.test(firstLine);
91
+ }
92
+ finally {
93
+ fs.closeSync(fd);
94
+ }
95
+ }
96
+ catch {
97
+ return false;
98
+ }
99
+ }
100
+ /**
101
+ * Build the `{ command, args }` to re-invoke this CLI with `sub` as its argv,
102
+ * resolving the JS-vs-standalone shape above. This is the single primitive behind
103
+ * both the daemon launch and the secrets-broker spawn — never hand-roll
104
+ * `[process.execPath, process.argv[1], …]`, which appends the bun virtual entry
105
+ * as a bogus subcommand on standalone builds.
106
+ */
107
+ export function getCliLaunch(sub, agentsBin = getAgentsBinPath()) {
108
+ // Resolve a bun virtual entry to the physical executable even when a caller
109
+ // passes agentsBin explicitly (getAgentsBinPath already does this for the
110
+ // default), so a `/$bunfs/root/agents` never becomes the command or an argv.
111
+ const bin = resolveBunStandaloneEntry(agentsBin, process.execPath);
112
+ if (isNodeScriptEntry(bin)) {
113
+ return { command: process.execPath, args: [bin, ...sub] };
114
+ }
115
+ return { command: bin, args: [...sub] };
116
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * On-demand download + verification of the macOS `agents computer` helper
3
+ * ("ComputerHelper.app").
4
+ *
5
+ * The helper is a signed + notarized universal `.app` bundle published as a
6
+ * GitHub release asset per tagged CLI version — the same distribution model as
7
+ * the Windows helper (see `lib/ssh-tunnel.ts`). A fresh `npm i -g` machine has
8
+ * no local build, so `agents computer setup` / `agents setup computer` fetch the
9
+ * asset for the running CLI version, verify its sha256 against the published
10
+ * `.sha256`, then verify the code signature (Developer ID Team + notarization)
11
+ * before it is ever copied to /Applications.
12
+ *
13
+ * A `.app` is a directory, so the asset is a zip (`ditto -c -k --keepParent`);
14
+ * we extract it with `ditto -x -k` after the checksum passes.
15
+ */
16
+ /** GitHub repo whose `v<version>` releases carry the helper asset. */
17
+ export declare const HELPER_RELEASE_REPO = "phnx-labs/agents-cli";
18
+ /** The zipped `.app` release asset name. */
19
+ export declare const MAC_HELPER_ASSET = "ComputerHelper.app.zip";
20
+ /** The bundle directory name once extracted. */
21
+ export declare const MAC_HELPER_APP_NAME = "ComputerHelper.app";
22
+ /** Apple Developer ID Team the helper must be signed by (defense in depth on top
23
+ * of `spctl` notarization assessment). "Developer ID Application: Muqit Nawaz". */
24
+ export declare const EXPECTED_TEAM_ID = "2HTP252L87";
25
+ /** Cache dir for the downloaded helper, one subdir per release tag. */
26
+ export declare function macHelperCacheDir(version: string): string;
27
+ /** Release-asset URLs for the helper zip + its checksum at one `v<version>` tag. */
28
+ export declare function macHelperAssetUrls(version: string): {
29
+ zip: string;
30
+ sha256: string;
31
+ };
32
+ /**
33
+ * Verify a helper `.app` bundle is intact, signed by the expected Developer ID
34
+ * Team, and notarized (Gatekeeper-accepted). Throws with an actionable message
35
+ * on any failure — a downloaded bundle is never trusted without this.
36
+ */
37
+ export declare function verifyMacHelper(appPath: string): void;
38
+ /**
39
+ * Download the helper release asset for `version`, verify sha256, extract the
40
+ * `.app`, and verify its signature. Returns the path to the extracted
41
+ * `ComputerHelper.app`. A missing asset is a hard error naming the exact tag —
42
+ * never a silent fallback to another release.
43
+ */
44
+ export declare function downloadMacHelperApp(version: string): Promise<string>;
45
+ /**
46
+ * Resolve the helper `.app` to install from: a local build / bundled copy first
47
+ * (repo checkout), else the checksum + signature-verified release-asset download
48
+ * for the running CLI version. Throws with the tag it checked when neither
49
+ * exists. macOS only.
50
+ */
51
+ export declare function ensureMacHelperApp(version?: string): Promise<string>;
@@ -0,0 +1,145 @@
1
+ /**
2
+ * On-demand download + verification of the macOS `agents computer` helper
3
+ * ("ComputerHelper.app").
4
+ *
5
+ * The helper is a signed + notarized universal `.app` bundle published as a
6
+ * GitHub release asset per tagged CLI version — the same distribution model as
7
+ * the Windows helper (see `lib/ssh-tunnel.ts`). A fresh `npm i -g` machine has
8
+ * no local build, so `agents computer setup` / `agents setup computer` fetch the
9
+ * asset for the running CLI version, verify its sha256 against the published
10
+ * `.sha256`, then verify the code signature (Developer ID Team + notarization)
11
+ * before it is ever copied to /Applications.
12
+ *
13
+ * A `.app` is a directory, so the asset is a zip (`ditto -c -k --keepParent`);
14
+ * we extract it with `ditto -x -k` after the checksum passes.
15
+ */
16
+ import { execFileSync } from 'node:child_process';
17
+ import * as fs from 'node:fs';
18
+ import * as os from 'node:os';
19
+ import * as path from 'node:path';
20
+ import { Readable } from 'node:stream';
21
+ import { pipeline } from 'node:stream/promises';
22
+ import { getCacheDir } from '../state.js';
23
+ import { getCliVersion } from '../version.js';
24
+ import { parseSha256Asset, sha256File } from '../ssh-tunnel.js';
25
+ import { resolveHelperApp } from '../computer-rpc.js';
26
+ /** GitHub repo whose `v<version>` releases carry the helper asset. */
27
+ export const HELPER_RELEASE_REPO = 'phnx-labs/agents-cli';
28
+ /** The zipped `.app` release asset name. */
29
+ export const MAC_HELPER_ASSET = 'ComputerHelper.app.zip';
30
+ /** The bundle directory name once extracted. */
31
+ export const MAC_HELPER_APP_NAME = 'ComputerHelper.app';
32
+ /** Apple Developer ID Team the helper must be signed by (defense in depth on top
33
+ * of `spctl` notarization assessment). "Developer ID Application: Muqit Nawaz". */
34
+ export const EXPECTED_TEAM_ID = '2HTP252L87';
35
+ /** Cache dir for the downloaded helper, one subdir per release tag. */
36
+ export function macHelperCacheDir(version) {
37
+ return path.join(getCacheDir(), 'computer', 'mac-helper', `v${version}`);
38
+ }
39
+ /** Release-asset URLs for the helper zip + its checksum at one `v<version>` tag. */
40
+ export function macHelperAssetUrls(version) {
41
+ const base = `https://github.com/${HELPER_RELEASE_REPO}/releases/download/v${version}`;
42
+ return { zip: `${base}/${MAC_HELPER_ASSET}`, sha256: `${base}/${MAC_HELPER_ASSET}.sha256` };
43
+ }
44
+ /**
45
+ * Verify a helper `.app` bundle is intact, signed by the expected Developer ID
46
+ * Team, and notarized (Gatekeeper-accepted). Throws with an actionable message
47
+ * on any failure — a downloaded bundle is never trusted without this.
48
+ */
49
+ export function verifyMacHelper(appPath) {
50
+ // 1. Structural + signature integrity.
51
+ try {
52
+ execFileSync('/usr/bin/codesign', ['--verify', '--deep', '--strict', appPath], { stdio: 'pipe' });
53
+ }
54
+ catch (e) {
55
+ throw new Error(`code signature invalid for ${appPath}: ${e.message}`);
56
+ }
57
+ // 2. Team identity — must be our Developer ID, not some other valid signer.
58
+ let info = '';
59
+ try {
60
+ info = execFileSync('/usr/bin/codesign', ['-dv', '--verbose=4', appPath], { stdio: 'pipe' }).toString();
61
+ }
62
+ catch (e) {
63
+ // codesign -dv writes to stderr; capture whatever it emitted.
64
+ info = (e.stderr?.toString() ?? '') + (e.stdout?.toString() ?? '');
65
+ if (!info)
66
+ throw new Error(`could not read code signature of ${appPath}: ${e.message}`);
67
+ }
68
+ const team = info.match(/TeamIdentifier=([A-Z0-9]+)/)?.[1];
69
+ if (team !== EXPECTED_TEAM_ID) {
70
+ throw new Error(`helper signed by unexpected Team (${team ?? 'none'}), expected ${EXPECTED_TEAM_ID}. Refusing to install.`);
71
+ }
72
+ // 3. Notarization / Gatekeeper — confirms Apple stapled a notarization ticket.
73
+ try {
74
+ execFileSync('/usr/sbin/spctl', ['--assess', '--type', 'execute', '--verbose', appPath], { stdio: 'pipe' });
75
+ }
76
+ catch (e) {
77
+ throw new Error(`helper is not notarized / rejected by Gatekeeper: ${e.message}. Refusing to install.`);
78
+ }
79
+ }
80
+ /**
81
+ * Download the helper release asset for `version`, verify sha256, extract the
82
+ * `.app`, and verify its signature. Returns the path to the extracted
83
+ * `ComputerHelper.app`. A missing asset is a hard error naming the exact tag —
84
+ * never a silent fallback to another release.
85
+ */
86
+ export async function downloadMacHelperApp(version) {
87
+ const dir = macHelperCacheDir(version);
88
+ const cachedApp = path.join(dir, MAC_HELPER_APP_NAME);
89
+ if (fs.existsSync(cachedApp)) {
90
+ // Re-verify a cached bundle cheaply; a tampered cache must not be trusted.
91
+ verifyMacHelper(cachedApp);
92
+ return cachedApp;
93
+ }
94
+ const tag = `v${version}`;
95
+ const { zip: zipUrl, sha256: shaUrl } = macHelperAssetUrls(version);
96
+ const missing = (status, url) => new Error(`no ${MAC_HELPER_ASSET} release asset for tag ${tag} (HTTP ${status} on ${url}). ` +
97
+ `The macOS helper ships as a GitHub release asset per tagged CLI version; ` +
98
+ `from a repo checkout you can build it locally instead: ` +
99
+ `bash native/computer-mac/scripts/build.sh release`);
100
+ // Checksum first: it is tiny and 404s fast when the tag has no assets.
101
+ const shaRes = await fetch(shaUrl, { signal: AbortSignal.timeout(30_000) });
102
+ if (!shaRes.ok)
103
+ throw missing(shaRes.status, shaUrl);
104
+ const expected = parseSha256Asset(await shaRes.text());
105
+ console.error(`Downloading ${MAC_HELPER_ASSET} ${tag} from GitHub releases...`);
106
+ const zipRes = await fetch(zipUrl, { signal: AbortSignal.timeout(15 * 60_000) });
107
+ if (!zipRes.ok || !zipRes.body)
108
+ throw missing(zipRes.status, zipUrl);
109
+ fs.mkdirSync(dir, { recursive: true });
110
+ const partial = path.join(dir, `${MAC_HELPER_ASSET}.download`);
111
+ try {
112
+ await pipeline(Readable.fromWeb(zipRes.body), fs.createWriteStream(partial));
113
+ const actual = await sha256File(partial);
114
+ if (actual !== expected) {
115
+ throw new Error(`sha256 mismatch for ${zipUrl}: expected ${expected}, got ${actual}`);
116
+ }
117
+ // Extract the zip (created with `ditto -c -k --keepParent`, so it contains
118
+ // ComputerHelper.app/ at top level) into the version cache dir.
119
+ fs.rmSync(cachedApp, { recursive: true, force: true });
120
+ execFileSync('/usr/bin/ditto', ['-x', '-k', partial, dir], { stdio: 'pipe' });
121
+ if (!fs.existsSync(cachedApp)) {
122
+ throw new Error(`extracted asset did not contain ${MAC_HELPER_APP_NAME}`);
123
+ }
124
+ verifyMacHelper(cachedApp);
125
+ }
126
+ finally {
127
+ fs.rmSync(partial, { force: true });
128
+ }
129
+ return cachedApp;
130
+ }
131
+ /**
132
+ * Resolve the helper `.app` to install from: a local build / bundled copy first
133
+ * (repo checkout), else the checksum + signature-verified release-asset download
134
+ * for the running CLI version. Throws with the tag it checked when neither
135
+ * exists. macOS only.
136
+ */
137
+ export async function ensureMacHelperApp(version = getCliVersion()) {
138
+ if (os.platform() !== 'darwin') {
139
+ throw new Error('The macOS computer helper is only available on macOS.');
140
+ }
141
+ const local = resolveHelperApp();
142
+ if (local)
143
+ return local;
144
+ return downloadMacHelperApp(version);
145
+ }
@@ -163,14 +163,20 @@ export function writeComputerPeers(allowedExecPaths) {
163
163
  }
164
164
  fs.writeFileSync(resolvePeersPath(), JSON.stringify({ allow: allowedExecPaths }, null, 2), { mode: 0o600 });
165
165
  }
166
- // Resolve the helper executable inside the dist .app bundle. Used by the
167
- // stdio fallback and by install-helper to find the source bundle.
166
+ // Resolve the helper executable inside the dist .app bundle. Used by the stdio
167
+ // fallback and by install-helper to find the source bundle. Only TRUSTED sources
168
+ // are listed here: a local checkout build and the bundled tarball copy. The
169
+ // download cache is deliberately NOT a candidate — a cached bundle is
170
+ // user-writable and must only ever be read through downloadMacHelperApp(), which
171
+ // re-verifies its signature + Team ID + notarization on every cache hit
172
+ // (lib/computer/download.ts). Listing the cache here would let a same-user
173
+ // process plant a differently-signed .app that install-helper trusts.
168
174
  export function resolveHelperExec() {
169
175
  const here = path.dirname(fileURLToPath(import.meta.url));
170
176
  const candidates = [
171
177
  // Local build (running from the agents-cli checkout). apps/cli/dist/lib -> repo root (4 up) -> native/computer-mac.
172
178
  path.resolve(here, '..', '..', '..', '..', 'native', 'computer-mac', 'dist', 'ComputerHelper.app', 'Contents', 'MacOS', 'ComputerHelper'),
173
- // Bundled with the npm package (later: CDN download lands here).
179
+ // Bundled with the npm package.
174
180
  path.resolve(here, '..', 'computer-helper', 'ComputerHelper.app', 'Contents', 'MacOS', 'ComputerHelper'),
175
181
  ];
176
182
  for (const c of candidates) {
@@ -6,6 +6,7 @@
6
6
  * (macOS), systemd (Linux), or as a plain detached process. PID tracking,
7
7
  * log output, reload (SIGHUP), and graceful shutdown are handled here.
8
8
  */
9
+ import { getAgentsBinPath } from './cli-entry.js';
9
10
  /** Read the stored daemon PID from disk. Returns null if not present or invalid. */
10
11
  export declare function readDaemonPid(): number | null;
11
12
  /** Write the daemon PID to the pid file. */
@@ -101,7 +102,7 @@ export declare function generateLaunchdPlist(agentsBin?: string): string;
101
102
  * store and never touches the unit file.
102
103
  */
103
104
  export declare function generateSystemdUnit(agentsBin?: string): string;
104
- export declare function getAgentsBinPath(argv1?: string | undefined, execPath?: string): string;
105
+ export { getAgentsBinPath };
105
106
  /** Start the daemon via launchd, systemd, or as a detached process. */
106
107
  export declare function startDaemon(agentsBin?: string): {
107
108
  pid: number | null;
@@ -201,4 +202,3 @@ export declare function getDaemonStatus(): {
201
202
  export declare function readDaemonLog(lines?: number): string;
202
203
  /** Send SIGHUP to the daemon to trigger a job reload. */
203
204
  export declare function signalDaemonReload(): boolean;
204
- export {};
@@ -21,6 +21,7 @@ import { BrowserService } from './browser/service.js';
21
21
  import { BrowserIPCServer } from './browser/ipc.js';
22
22
  import { readAndResolveBundleEnv } from './secrets/bundles.js';
23
23
  import { redactSecrets } from './redact.js';
24
+ import { getAgentsBinPath, getCliLaunch, BUN_VIRTUAL_ROOT } from './cli-entry.js';
24
25
  const PID_FILE = 'daemon.pid';
25
26
  const LOCK_FILE = 'daemon.lock';
26
27
  const LOG_FILE = 'logs.jsonl';
@@ -781,47 +782,11 @@ Environment=PATH=${daemonNodeBinDir()}:/usr/local/bin:/usr/bin:/bin
781
782
  [Install]
782
783
  WantedBy=default.target`;
783
784
  }
784
- const BUN_VIRTUAL_ROOT = /[/\\]\$bunfs[/\\]root[/\\]/;
785
- function resolveBunStandaloneEntry(entry, execPath) {
786
- if (!BUN_VIRTUAL_ROOT.test(entry))
787
- return entry;
788
- if (!execPath || BUN_VIRTUAL_ROOT.test(execPath) || !fs.existsSync(execPath)) {
789
- throw new Error(`Cannot resolve agents CLI: Bun standalone executable not found at ${execPath || '(empty path)'}`);
790
- }
791
- return execPath;
792
- }
793
- export function getAgentsBinPath(argv1 = process.argv[1], execPath = process.execPath) {
794
- // Prefer the binary actively executing this code. `which agents` returns
795
- // whatever happens to be first on PATH, which means a side-by-side dev
796
- // build at ~/.local/bin would silently spawn the registry-installed
797
- // daemon and run stale code. For a JS install, process.argv[1] is the
798
- // absolute entrypoint the user actually invoked. A Bun standalone instead
799
- // exposes its embedded /$bunfs/root entry at argv[1] and its physical signed
800
- // executable at process.execPath; Bun reports both as existing paths.
801
- const runningEntry = argv1 ? resolveBunStandaloneEntry(argv1, execPath) : undefined;
802
- if (runningEntry && fs.existsSync(runningEntry)) {
803
- // The package's browser/computer entrypoints are sibling shims without a
804
- // `daemon` command. A daemon started as their IPC side effect must launch
805
- // through the main agents entrypoint instead of replaying the shim path.
806
- const entryName = path.basename(runningEntry);
807
- const compiledShim = /^(browser|computer)\.(c|m)?js$/.test(entryName);
808
- const installedShim = /^(browser|computer)$/.test(entryName);
809
- if (compiledShim || installedShim) {
810
- const agentsEntry = path.join(path.dirname(runningEntry), compiledShim ? 'index.js' : 'agents');
811
- if (!fs.existsSync(agentsEntry)) {
812
- throw new Error(`Cannot start agents daemon: main CLI entry not found at ${agentsEntry}`);
813
- }
814
- return agentsEntry;
815
- }
816
- return runningEntry;
817
- }
818
- try {
819
- return execFileSync('which', ['agents'], { encoding: 'utf-8' }).trim();
820
- }
821
- catch {
822
- return 'agents';
823
- }
824
- }
785
+ // Binary-resolution helpers (getAgentsBinPath / isNodeScriptEntry / getCliLaunch)
786
+ // live in ./cli-entry.js a leaf module the secrets broker also imports without
787
+ // forming a cycle. Re-exported so existing `from './daemon.js'` importers of
788
+ // getAgentsBinPath keep resolving.
789
+ export { getAgentsBinPath };
825
790
  /**
826
791
  * Ask the service manager for the daemon's live PID. Used as a fallback when
827
792
  * the daemon hasn't yet written its pid file but launchd/systemd already report
@@ -980,49 +945,7 @@ export function getDaemonLaunch(agentsBin = getAgentsBinPath()) {
980
945
  const { warnings } = validateDaemonBinary(agentsBin);
981
946
  for (const w of warnings)
982
947
  process.stderr.write(`[agents] ${w}\n`);
983
- if (isNodeScriptEntry(agentsBin)) {
984
- return { command: process.execPath, args: [agentsBin, 'daemon', '_run'] };
985
- }
986
- return { command: agentsBin, args: ['daemon', '_run'] };
987
- }
988
- /**
989
- * A daemon entry must be launched through the Node runtime when it is a Node
990
- * script — a `.js`/`.cjs`/`.mjs` file, OR a symlink/extension-less shim whose
991
- * shebang names `node`. Package installs link `bin/agents` to a `dist/index.js`
992
- * (a symlink) or drop an extension-less `#!/usr/bin/env node` shim, so an
993
- * extension check alone misses them and they get run directly. Executing such an
994
- * entry then relies on the shebang resolving `node` off the daemon's PATH — and
995
- * when that PATH points at a pruned nvm version (or an ancient system node), the
996
- * daemon crash-loops at import (`node:util` has no `styleText` on Node 18). A
997
- * real compiled binary (Mach-O/ELF/PE) has no `#!node` shebang, so it takes the
998
- * direct branch and owns its own runtime resolution.
999
- */
1000
- function isNodeScriptEntry(agentsBin) {
1001
- let resolved = agentsBin;
1002
- try {
1003
- resolved = fs.realpathSync(agentsBin);
1004
- }
1005
- catch {
1006
- // Unresolvable (e.g. a template path that does not exist on this box): fall
1007
- // back to the extension check on the path as given.
1008
- }
1009
- if (/\.(c|m)?js$/.test(resolved))
1010
- return true;
1011
- try {
1012
- const fd = fs.openSync(resolved, 'r');
1013
- try {
1014
- const buf = Buffer.alloc(128);
1015
- const n = fs.readSync(fd, buf, 0, 128, 0);
1016
- const firstLine = buf.toString('utf-8', 0, n).split('\n', 1)[0];
1017
- return firstLine.startsWith('#!') && /\bnode\b/.test(firstLine);
1018
- }
1019
- finally {
1020
- fs.closeSync(fd);
1021
- }
1022
- }
1023
- catch {
1024
- return false;
1025
- }
948
+ return getCliLaunch(['daemon', '_run'], agentsBin);
1026
949
  }
1027
950
  /**
1028
951
  * The directory of the Node runtime that generated this service manifest, kept
@@ -1050,11 +973,7 @@ function daemonNodeBinDir() {
1050
973
  * getAgentsBinPath() resolves that virtual entry to the physical process.execPath.
1051
974
  */
1052
975
  export function getAgentsInvocation(subArgs, agentsBin = getAgentsBinPath()) {
1053
- const resolvedBin = resolveBunStandaloneEntry(agentsBin, process.execPath);
1054
- if (/\.(c|m)?js$/.test(resolvedBin)) {
1055
- return { command: process.execPath, args: [resolvedBin, ...subArgs] };
1056
- }
1057
- return { command: resolvedBin, args: subArgs };
976
+ return getCliLaunch(subArgs, agentsBin);
1058
977
  }
1059
978
  export function validateDaemonBinary(binPath) {
1060
979
  const warnings = [];
@@ -43,6 +43,21 @@ export declare function buildSshInvocation(device: DeviceProfile, cmd: string[],
43
43
  args: string[];
44
44
  env: Record<string, string>;
45
45
  };
46
+ /**
47
+ * Build the askpass shim's `#!/bin/sh` body: a script that re-invokes this CLI
48
+ * as `agents ssh __askpass`. The relaunch argv comes from {@link getCliLaunch},
49
+ * never a hand-rolled `[process.execPath, process.argv[1], …]` — on a Bun
50
+ * standalone binary `process.argv[1]` is the *virtual* embedded entry
51
+ * `/$bunfs/root/agents`, which the CLI would then receive as a bogus subcommand
52
+ * (`unknown command '/$bunfs/root/agents'`), print nothing, and hand ssh an
53
+ * empty password. `getCliLaunch` resolves the physical executable so the shim
54
+ * works on both the standalone and JS/dev builds. Every argv element is
55
+ * shell-quoted. Pure (takes the launch as a parameter) so it is unit-testable.
56
+ */
57
+ export declare function buildAskpassShimBody(launch?: {
58
+ command: string;
59
+ args: string[];
60
+ }): string;
46
61
  /**
47
62
  * Write (idempotently) the askpass shim — a tiny executable that re-invokes
48
63
  * this CLI as `agents ssh __askpass`. ssh execs `SSH_ASKPASS` with no usable
@@ -16,6 +16,7 @@
16
16
  import * as fs from 'fs';
17
17
  import * as path from 'path';
18
18
  import { assertValidSshTarget, shellQuote } from '../ssh-exec.js';
19
+ import { getCliLaunch } from '../cli-entry.js';
19
20
  import { encodePwshBase64 } from '../pwsh.js';
20
21
  import { getCacheDir } from '../state.js';
21
22
  import { hostKeyCheckingOpts } from './known-hosts.js';
@@ -92,6 +93,21 @@ export function buildSshInvocation(device, cmd, askpassShimPath, hostKey = {}) {
92
93
  args.push(remote);
93
94
  return { args, env };
94
95
  }
96
+ /**
97
+ * Build the askpass shim's `#!/bin/sh` body: a script that re-invokes this CLI
98
+ * as `agents ssh __askpass`. The relaunch argv comes from {@link getCliLaunch},
99
+ * never a hand-rolled `[process.execPath, process.argv[1], …]` — on a Bun
100
+ * standalone binary `process.argv[1]` is the *virtual* embedded entry
101
+ * `/$bunfs/root/agents`, which the CLI would then receive as a bogus subcommand
102
+ * (`unknown command '/$bunfs/root/agents'`), print nothing, and hand ssh an
103
+ * empty password. `getCliLaunch` resolves the physical executable so the shim
104
+ * works on both the standalone and JS/dev builds. Every argv element is
105
+ * shell-quoted. Pure (takes the launch as a parameter) so it is unit-testable.
106
+ */
107
+ export function buildAskpassShimBody(launch = getCliLaunch(['ssh', '__askpass'])) {
108
+ const exec = [launch.command, ...launch.args].map(shellQuote).join(' ');
109
+ return `#!/bin/sh\n# Generated by agents-cli — bridges ssh SSH_ASKPASS back into the CLI.\nexec ${exec}\n`;
110
+ }
95
111
  /**
96
112
  * Write (idempotently) the askpass shim — a tiny executable that re-invokes
97
113
  * this CLI as `agents ssh __askpass`. ssh execs `SSH_ASKPASS` with no usable
@@ -102,10 +118,6 @@ export function writeAskpassShim() {
102
118
  const dir = path.join(getCacheDir(), 'devices');
103
119
  fs.mkdirSync(dir, { recursive: true });
104
120
  const shimPath = path.join(dir, 'askpass.sh');
105
- // process.execPath = the node/bun binary; argv[1] = this CLI's entry script.
106
- const node = process.execPath;
107
- const entry = process.argv[1] ?? '';
108
- const body = `#!/bin/sh\n# Generated by agents-cli — bridges ssh SSH_ASKPASS back into the CLI.\nexec ${shellQuote(node)} ${shellQuote(entry)} ssh __askpass\n`;
109
- fs.writeFileSync(shimPath, body, { mode: 0o700 });
121
+ fs.writeFileSync(shimPath, buildAskpassShimBody(), { mode: 0o700 });
110
122
  return shimPath;
111
123
  }
@@ -24,6 +24,17 @@ export interface FleetRunResult {
24
24
  /** Truncated combined stderr/stdout for failures. */
25
25
  detail?: string;
26
26
  }
27
+ export interface FanOutDeviceTarget {
28
+ name: string;
29
+ skip?: FleetSkipReason | string;
30
+ }
31
+ export interface FanOutDeviceResult<T> {
32
+ name: string;
33
+ status: 'ok' | 'failed' | 'skipped';
34
+ value?: T;
35
+ error?: string;
36
+ reason?: FleetSkipReason | string;
37
+ }
27
38
  /**
28
39
  * Classify each registered device for a fleet operation.
29
40
  *
@@ -34,6 +45,16 @@ export interface FleetRunResult {
34
45
  * it has a registry address — same path as any other box).
35
46
  */
36
47
  export declare function planFleetTargets(reg: DeviceRegistry): FleetTarget[];
48
+ /**
49
+ * Remote fan-out targets for the fleet health/drift gates (`fleet status`,
50
+ * `check --devices`): every planned device except this machine and control-only
51
+ * cockpits. A control device never runs agents (mirrors doctor's fan-out, which
52
+ * drops it via `isControlDevice`), so counting it as unreachable/drift would make
53
+ * the CI gate fail on every run for a fleet that merely has a cockpit registered.
54
+ * Offline / no-address devices are kept — those are genuine faults a gate should
55
+ * surface — so their `skip` reason still flows through as an `unreachable` row.
56
+ */
57
+ export declare function remoteFleetTargets(planned: FleetTarget[], self: string): FleetTarget[];
37
58
  /** Human label for a skip reason. */
38
59
  export declare function skipLabel(reason: FleetSkipReason): string;
39
60
  /**
@@ -61,3 +82,5 @@ export declare function upgradeCommand(version?: string): string[];
61
82
  * the runner are recorded as `failed` so one bad device never aborts the rest.
62
83
  */
63
84
  export declare function runFleet(targets: FleetTarget[], cmd: string[], runner?: typeof runOnDevice): FleetRunResult[];
85
+ /** Run one async probe per device in parallel, preserving input order. */
86
+ export declare function fanOutDevices<T, Target extends FanOutDeviceTarget = FanOutDeviceTarget>(targets: Target[], probe: (target: Target) => Promise<T>): Promise<FanOutDeviceResult<T>[]>;
@@ -42,6 +42,18 @@ export function planFleetTargets(reg) {
42
42
  return { device };
43
43
  });
44
44
  }
45
+ /**
46
+ * Remote fan-out targets for the fleet health/drift gates (`fleet status`,
47
+ * `check --devices`): every planned device except this machine and control-only
48
+ * cockpits. A control device never runs agents (mirrors doctor's fan-out, which
49
+ * drops it via `isControlDevice`), so counting it as unreachable/drift would make
50
+ * the CI gate fail on every run for a fleet that merely has a cockpit registered.
51
+ * Offline / no-address devices are kept — those are genuine faults a gate should
52
+ * surface — so their `skip` reason still flows through as an `unreachable` row.
53
+ */
54
+ export function remoteFleetTargets(planned, self) {
55
+ return planned.filter((t) => t.device.name !== self && t.skip !== 'control');
56
+ }
45
57
  /** Human label for a skip reason. */
46
58
  export function skipLabel(reason) {
47
59
  switch (reason) {
@@ -135,3 +147,29 @@ export function runFleet(targets, cmd, runner = runOnDevice) {
135
147
  }
136
148
  return results;
137
149
  }
150
+ /** Run one async probe per device in parallel, preserving input order. */
151
+ export async function fanOutDevices(targets, probe) {
152
+ return Promise.all(targets.map(async (target) => {
153
+ if (target.skip) {
154
+ return {
155
+ name: target.name,
156
+ status: 'skipped',
157
+ reason: target.skip,
158
+ };
159
+ }
160
+ try {
161
+ return {
162
+ name: target.name,
163
+ status: 'ok',
164
+ value: await probe(target),
165
+ };
166
+ }
167
+ catch (err) {
168
+ return {
169
+ name: target.name,
170
+ status: 'failed',
171
+ error: err instanceof Error ? err.message : String(err),
172
+ };
173
+ }
174
+ }));
175
+ }