@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.
- package/CHANGELOG.md +100 -0
- package/README.md +14 -1
- package/dist/bin/agents +0 -0
- package/dist/commands/check.js +96 -2
- package/dist/commands/computer.d.ts +26 -0
- package/dist/commands/computer.js +173 -149
- package/dist/commands/doctor.js +37 -9
- package/dist/commands/exec.d.ts +18 -0
- package/dist/commands/exec.js +126 -6
- package/dist/commands/fork.d.ts +9 -0
- package/dist/commands/fork.js +67 -0
- package/dist/commands/plugins.js +11 -2
- package/dist/commands/repo.js +47 -2
- package/dist/commands/run-account-picker.d.ts +14 -0
- package/dist/commands/run-account-picker.js +131 -0
- package/dist/commands/setup-browser.d.ts +18 -0
- package/dist/commands/setup-browser.js +142 -0
- package/dist/commands/setup-computer.d.ts +19 -0
- package/dist/commands/setup-computer.js +133 -0
- package/dist/commands/setup-share.d.ts +17 -0
- package/dist/commands/setup-share.js +85 -0
- package/dist/commands/setup.d.ts +1 -1
- package/dist/commands/setup.js +58 -1
- package/dist/commands/share.d.ts +15 -0
- package/dist/commands/share.js +10 -4
- package/dist/commands/ssh.js +267 -2
- package/dist/commands/teams.d.ts +11 -0
- package/dist/commands/teams.js +39 -1
- package/dist/commands/view.d.ts +4 -14
- package/dist/commands/view.js +39 -36
- package/dist/index.js +2 -1
- package/dist/lib/agents.d.ts +21 -5
- package/dist/lib/agents.js +59 -24
- package/dist/lib/auth-health.d.ts +103 -0
- package/dist/lib/auth-health.js +232 -0
- package/dist/lib/browser/chrome.d.ts +9 -0
- package/dist/lib/browser/chrome.js +22 -0
- package/dist/lib/browser/profiles.js +4 -25
- package/dist/lib/cli-entry.d.ts +23 -0
- package/dist/lib/cli-entry.js +116 -0
- package/dist/lib/computer/download.d.ts +51 -0
- package/dist/lib/computer/download.js +145 -0
- package/dist/lib/computer-rpc.js +9 -3
- package/dist/lib/daemon.d.ts +2 -2
- package/dist/lib/daemon.js +8 -89
- package/dist/lib/devices/connect.d.ts +15 -0
- package/dist/lib/devices/connect.js +17 -5
- package/dist/lib/devices/fleet.d.ts +23 -0
- package/dist/lib/devices/fleet.js +38 -0
- package/dist/lib/devices/health-report.d.ts +46 -0
- package/dist/lib/devices/health-report.js +159 -0
- package/dist/lib/devices/health.d.ts +14 -4
- package/dist/lib/devices/health.js +49 -8
- package/dist/lib/devices/terminfo.d.ts +44 -0
- package/dist/lib/devices/terminfo.js +167 -0
- package/dist/lib/git.d.ts +10 -0
- package/dist/lib/git.js +23 -0
- package/dist/lib/hosts/option.js +1 -1
- package/dist/lib/hosts/ready.js +5 -3
- package/dist/lib/hosts/remote-cmd.d.ts +12 -0
- package/dist/lib/hosts/remote-cmd.js +17 -1
- package/dist/lib/mcp.d.ts +21 -0
- package/dist/lib/mcp.js +278 -13
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/resources/mcp.js +20 -342
- package/dist/lib/rotate.d.ts +12 -8
- package/dist/lib/rotate.js +22 -23
- package/dist/lib/routines.d.ts +13 -0
- package/dist/lib/routines.js +21 -0
- package/dist/lib/runner.js +18 -40
- package/dist/lib/secrets/agent.js +11 -15
- package/dist/lib/session/fork.d.ts +32 -0
- package/dist/lib/session/fork.js +101 -0
- package/dist/lib/share/capture.js +21 -3
- package/dist/lib/signin-badge.d.ts +41 -0
- package/dist/lib/signin-badge.js +64 -0
- package/dist/lib/ssh-exec.d.ts +5 -0
- package/dist/lib/ssh-exec.js +55 -1
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +2 -0
- package/dist/lib/usage.d.ts +40 -3
- package/dist/lib/usage.js +147 -16
- 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
|
+
}
|
package/dist/lib/computer-rpc.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|
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) {
|
package/dist/lib/daemon.d.ts
CHANGED
|
@@ -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
|
|
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 {};
|
package/dist/lib/daemon.js
CHANGED
|
@@ -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
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|