@phnx-labs/agents-cli 1.22.56 → 1.22.58
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 +70 -0
- package/README.md +4 -4
- package/dist/bootstrap.js +11 -2
- package/dist/cli/command-registry.d.ts +0 -1
- package/dist/cli/command-registry.js +0 -3
- package/dist/commands/accounts.js +7 -3
- package/dist/commands/apply.js +10 -2
- package/dist/commands/exec.js +1 -1
- package/dist/commands/fork.d.ts +23 -10
- package/dist/commands/fork.js +115 -58
- package/dist/commands/hooks.js +4 -4
- package/dist/commands/insights.d.ts +7 -5
- package/dist/commands/insights.js +16 -9
- package/dist/commands/monitors.js +11 -0
- package/dist/commands/perf.d.ts +16 -7
- package/dist/commands/perf.js +29 -20
- package/dist/commands/prune.js +5 -3
- package/dist/commands/routines.d.ts +8 -0
- package/dist/commands/routines.js +57 -3
- package/dist/commands/rules.js +1 -1
- package/dist/commands/sessions-picker.d.ts +11 -0
- package/dist/commands/sessions-picker.js +16 -0
- package/dist/commands/sessions.js +1 -0
- package/dist/commands/share.d.ts +14 -0
- package/dist/commands/share.js +43 -2
- package/dist/commands/ssh.js +24 -14
- package/dist/commands/status.js +1 -1
- package/dist/commands/sync.js +83 -7
- package/dist/commands/traces.js +7 -0
- package/dist/commands/trash.d.ts +2 -2
- package/dist/commands/trash.js +2 -6
- package/dist/commands/versions.d.ts +2 -2
- package/dist/commands/versions.js +1 -10
- package/dist/commands/view.d.ts +2 -2
- package/dist/commands/view.js +7 -6
- package/dist/index.d.ts +1 -0
- package/dist/index.js +14 -0
- package/dist/lib/account-registry.d.ts +5 -1
- package/dist/lib/account-registry.js +47 -14
- package/dist/lib/accounting/capacity.d.ts +18 -7
- package/dist/lib/accounting/capacity.js +19 -8
- package/dist/lib/accounting/usage-ingest.d.ts +1 -0
- package/dist/lib/accounting/usage-ingest.js +75 -0
- package/dist/lib/accounting/usage-sync.d.ts +97 -0
- package/dist/lib/accounting/usage-sync.js +203 -0
- package/dist/lib/accounting/usage.d.ts +48 -2
- package/dist/lib/accounting/usage.js +79 -2
- package/dist/lib/agent-spec/agents.js +1 -1
- package/dist/lib/analytics/mix-commands.d.ts +8 -7
- package/dist/lib/analytics/mix-commands.js +50 -73
- package/dist/lib/auth-mint.d.ts +11 -1
- package/dist/lib/auth-mint.js +21 -6
- package/dist/lib/browser/ipc.d.ts +8 -0
- package/dist/lib/browser/ipc.js +87 -0
- package/dist/lib/browser/service.d.ts +19 -0
- package/dist/lib/browser/service.js +96 -11
- package/dist/lib/browser/sessions-list.js +10 -1
- package/dist/lib/daemon/daemon.js +5 -0
- package/dist/lib/daemon/runner.d.ts +3 -0
- package/dist/lib/daemon/runner.js +95 -53
- package/dist/lib/daemon/usage-sync-service.d.ts +21 -0
- package/dist/lib/daemon/usage-sync-service.js +42 -0
- package/dist/lib/daemon-services.d.ts +1 -1
- package/dist/lib/daemon-services.js +5 -0
- package/dist/lib/device-config.d.ts +17 -6
- package/dist/lib/device-config.js +25 -11
- package/dist/lib/devices/connect.d.ts +17 -8
- package/dist/lib/devices/connect.js +31 -14
- package/dist/lib/devices/pool.d.ts +4 -3
- package/dist/lib/devices/pool.js +13 -5
- package/dist/lib/doctor-diff.js +77 -7
- package/dist/lib/exec.d.ts +6 -41
- package/dist/lib/exec.js +6 -41
- package/dist/lib/fleet/manifest.d.ts +17 -0
- package/dist/lib/fleet/manifest.js +26 -0
- package/dist/lib/git.d.ts +13 -1
- package/dist/lib/git.js +36 -7
- package/dist/lib/harness/adapter.d.ts +7 -7
- package/dist/lib/harness/adapters/claude.js +3 -2
- package/dist/lib/hooks/install.d.ts +27 -11
- package/dist/lib/hooks/install.js +42 -17
- package/dist/lib/hosts/reconnect.d.ts +52 -203
- package/dist/lib/hosts/reconnect.js +64 -284
- package/dist/lib/hosts/remote-cmd.d.ts +9 -0
- package/dist/lib/hosts/remote-cmd.js +22 -0
- package/dist/lib/installations/migrate.d.ts +6 -120
- package/dist/lib/installations/migrate.js +27 -259
- package/dist/lib/installations/shims.d.ts +13 -95
- package/dist/lib/installations/shims.js +22 -139
- package/dist/lib/installations/store.js +1 -1
- package/dist/lib/installations/versions.d.ts +26 -133
- package/dist/lib/installations/versions.js +41 -204
- package/dist/lib/perf/db.d.ts +1 -1
- package/dist/lib/perf/db.js +1 -1
- package/dist/lib/plugins/skills.d.ts +8 -1
- package/dist/lib/plugins/skills.js +18 -2
- package/dist/lib/refresh.d.ts +9 -0
- package/dist/lib/refresh.js +3 -1
- package/dist/lib/routine-readiness.d.ts +15 -1
- package/dist/lib/routine-readiness.js +41 -0
- package/dist/lib/sandbox.d.ts +4 -1
- package/dist/lib/sandbox.js +30 -1
- package/dist/lib/secrets/agent.d.ts +80 -225
- package/dist/lib/secrets/agent.js +139 -401
- package/dist/lib/secrets/bundles.d.ts +73 -222
- package/dist/lib/secrets/bundles.js +168 -467
- package/dist/lib/secrets/reaper.d.ts +28 -70
- package/dist/lib/secrets/reaper.js +30 -85
- package/dist/lib/secrets/remote.d.ts +42 -129
- package/dist/lib/secrets/remote.js +55 -173
- package/dist/lib/self-heal/checks/install-staging.d.ts +4 -0
- package/dist/lib/self-heal/checks/install-staging.js +96 -0
- package/dist/lib/self-heal/registry.js +2 -0
- package/dist/lib/self-heal/types.d.ts +1 -1
- package/dist/lib/self-update.d.ts +23 -0
- package/dist/lib/self-update.js +50 -0
- package/dist/lib/session/active.d.ts +16 -32
- package/dist/lib/session/active.js +10 -68
- package/dist/lib/session/db.d.ts +24 -36
- package/dist/lib/session/db.js +143 -44
- package/dist/lib/session/discover.d.ts +6 -58
- package/dist/lib/session/discover.js +5 -43
- package/dist/lib/session/fork.d.ts +45 -26
- package/dist/lib/session/fork.js +32 -95
- package/dist/lib/session/parse.d.ts +1 -19
- package/dist/lib/session/parse.js +2 -15
- package/dist/lib/session/tool-calls.d.ts +43 -1
- package/dist/lib/session/tool-calls.js +74 -44
- package/dist/lib/session/tool-store.d.ts +33 -2
- package/dist/lib/session/tool-store.js +56 -3
- package/dist/lib/staleness/writers/sources.d.ts +5 -0
- package/dist/lib/staleness/writers/sources.js +2 -1
- package/dist/lib/startup/command-registry.d.ts +8 -2
- package/dist/lib/startup/command-registry.js +12 -4
- package/dist/lib/sync-status.d.ts +22 -0
- package/dist/lib/sync-status.js +27 -0
- package/dist/lib/sync-umbrella.d.ts +9 -0
- package/dist/lib/sync-umbrella.js +21 -2
- package/dist/lib/traces/insights.d.ts +47 -14
- package/dist/lib/traces/insights.js +92 -21
- package/dist/lib/traces/phenotype.d.ts +23 -3
- package/dist/lib/traces/phenotype.js +72 -24
- package/dist/lib/traces/sync.d.ts +15 -0
- package/dist/lib/traces/sync.js +104 -19
- package/dist/lib/traces/worker-template.js +154 -1
- package/package.json +1 -1
|
@@ -1,19 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Remote secrets — read and use `agents secrets` bundles that live on another
|
|
3
|
-
* host
|
|
4
|
-
* (the write inverse) already uses.
|
|
3
|
+
* host over the same SSH path that `agents secrets export --device` uses.
|
|
5
4
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* stdout back verbatim (lossless, no parsing).
|
|
9
|
-
* - use: resolve a remote bundle to an env map (JSON over ssh stdout) and
|
|
10
|
-
* inject it ephemerally — never written to this machine's keychain.
|
|
11
|
-
*
|
|
12
|
-
* Trust model: relies on the operator's existing SSH access to the host (same
|
|
13
|
-
* boundary as `export --device` / `run --device`). Bundle names are shell-quoted
|
|
14
|
-
* into the remote command; resolved VALUES return over ssh stdout. File-backend
|
|
15
|
-
* import never forwards AGENTS_SECRETS_PASSPHRASE (PHNX-2371). Nothing is
|
|
16
|
-
* persisted locally.
|
|
5
|
+
* Browse streams remote stdout verbatim; use resolves a bundle to an env map
|
|
6
|
+
* and injects it ephemerally without persisting it locally.
|
|
17
7
|
*/
|
|
18
8
|
import { sshExec, sshStream, assertValidSshTarget, shellQuote } from '../ssh-exec.js';
|
|
19
9
|
import { resolveHost } from '../hosts/registry.js';
|
|
@@ -29,21 +19,8 @@ function hostKeyLookupName(target) {
|
|
|
29
19
|
return target.split('@').pop() ?? target;
|
|
30
20
|
}
|
|
31
21
|
/**
|
|
32
|
-
* SSH options for
|
|
33
|
-
*
|
|
34
|
-
* (resolve/read-back) — so it MUST NOT ride the shared `accept-new` baseline
|
|
35
|
-
* against the user's own `~/.ssh/known_hosts` with a reusable control socket.
|
|
36
|
-
* Two hardenings over that baseline, matching the posture the `--copy-creds`
|
|
37
|
-
* dispatch already uses (`hosts/dispatch.ts` -> `hostKeyCheckingOpts`):
|
|
38
|
-
*
|
|
39
|
-
* - **Managed pinned host keys.** Verify against the CLI-owned known_hosts
|
|
40
|
-
* store (`known-hosts.ts`), not `~/.ssh/known_hosts`. A CHANGED key on a
|
|
41
|
-
* known host is refused (`StrictHostKeyChecking` `yes` once pinned,
|
|
42
|
-
* `accept-new` before that). Callers that copy durable provider credentials
|
|
43
|
-
* must separately require an existing pin before invoking this transport.
|
|
44
|
-
* - **No multiplex reuse.** `multiplex: false` — a credential channel never
|
|
45
|
-
* leaves a persistent `ControlMaster` socket lingering (60s `ControlPersist`)
|
|
46
|
-
* that any other `agents` invocation to that host would silently reuse.
|
|
22
|
+
* SSH options for secret-carrying transport. Pins the managed host key and
|
|
23
|
+
* disables multiplex so no reusable control socket lingers for other invocations.
|
|
47
24
|
*/
|
|
48
25
|
export function credentialTransportSshOpts(target) {
|
|
49
26
|
return { hostKeyOpts: hostKeyCheckingOpts(isHostPinned(hostKeyLookupName(target))), multiplex: false };
|
|
@@ -56,47 +33,34 @@ export function assertCredentialTransportHostPinned(target, pinned = isHostPinne
|
|
|
56
33
|
`Connect once with 'agents ssh ${target}' and verify the host, then retry.`);
|
|
57
34
|
}
|
|
58
35
|
/**
|
|
59
|
-
* Trust boundary for a remote-resolved env map. A peer's
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
* never the remote bundle). Block the dangerous-override classes here — at the
|
|
64
|
-
* source — so every consumer (`run --secrets b@host`, `secrets exec --host`) is
|
|
65
|
-
* protected, not just one call site:
|
|
66
|
-
* - LD_* / DYLD_* / NODE_OPTIONS and the other loader/interpreter injections
|
|
67
|
-
* (reuses the canonical bundles.ts predicate);
|
|
68
|
-
* - GIT_* — GIT_SSH_COMMAND et al. hijack every git subprocess;
|
|
69
|
-
* - *_PROXY — HTTP(S)_PROXY / ALL_PROXY reroute outbound traffic (MITM);
|
|
70
|
-
* - *_BASE_URL — ANTHROPIC_BASE_URL / OPENAI_BASE_URL redirect the model API.
|
|
71
|
-
* These keys are already rejected on the ADD side (validateEnvKey for loaders),
|
|
72
|
-
* so a legitimate bundle never carries them — only a hostile peer would.
|
|
36
|
+
* Trust boundary for a remote-resolved env map. A peer's export output is
|
|
37
|
+
* untrusted input that could reshape this process once merged into the env, so
|
|
38
|
+
* block dangerous override classes here for every consumer:
|
|
39
|
+
* loader/interpreter vars, GIT_*, *_PROXY, and *_BASE_URL.
|
|
73
40
|
*/
|
|
74
41
|
export function isDangerousRemoteEnvKey(name) {
|
|
75
42
|
const upper = name.toUpperCase();
|
|
76
43
|
if (isLoaderOrInterpreterEnv(upper))
|
|
77
44
|
return true;
|
|
78
45
|
if (upper.startsWith('GIT_'))
|
|
79
|
-
return true;
|
|
46
|
+
return true; // GIT_SSH_COMMAND hijacks git subprocesses
|
|
80
47
|
if (upper.endsWith('_PROXY'))
|
|
81
|
-
return true;
|
|
48
|
+
return true; // *_PROXY = MITM
|
|
82
49
|
if (upper.endsWith('_BASE_URL'))
|
|
83
|
-
return true;
|
|
50
|
+
return true; // *_BASE_URL = model API redirect
|
|
84
51
|
return false;
|
|
85
52
|
}
|
|
86
53
|
/** Remote OS for a host name or target string. Prefer the original host name
|
|
87
|
-
*
|
|
88
|
-
*
|
|
54
|
+
* because enrolled inline hosts resolve to `user@address`, while the OS
|
|
55
|
+
* registry is keyed by the host name. */
|
|
89
56
|
function osForTarget(target, lookupName) {
|
|
90
57
|
const byName = lookupName ? resolveRemoteOsSync(lookupName) : undefined;
|
|
91
58
|
return byName ?? resolveRemoteOsSync(target.split('@').pop() ?? target);
|
|
92
59
|
}
|
|
93
60
|
/**
|
|
94
|
-
* Resolve a `--device` value to an ssh target
|
|
95
|
-
* Delegates to the
|
|
96
|
-
*
|
|
97
|
-
* value as a raw ssh target and validates it against injection. Named distinctly
|
|
98
|
-
* from `../devices/resolve-target.ts` (which returns richer shapes) so importing
|
|
99
|
-
* the wrong one can't silently change which machine you dial.
|
|
61
|
+
* Resolve a `--device` value to an ssh target string for the remote-secrets
|
|
62
|
+
* path. Delegates to the same resolver `run --device` uses; on a miss, treats
|
|
63
|
+
* the value as a raw ssh target and validates it.
|
|
100
64
|
*/
|
|
101
65
|
export async function resolveHostSshTarget(nameOrAlias) {
|
|
102
66
|
const host = await resolveHost(nameOrAlias);
|
|
@@ -106,11 +70,8 @@ export async function resolveHostSshTarget(nameOrAlias) {
|
|
|
106
70
|
return nameOrAlias;
|
|
107
71
|
}
|
|
108
72
|
/**
|
|
109
|
-
* Merge `--host
|
|
110
|
-
*
|
|
111
|
-
* works. `--device`/`--devices` resolve identically to `--device`/`--hosts` so the
|
|
112
|
-
* fleet-wide `--device` vocabulary (see `agents run --device`, `agents feed --host`)
|
|
113
|
-
* works on the secrets remote commands too. Empty when none is set.
|
|
73
|
+
* Merge `--host` / `--hosts` (and their `--device` / `--devices` aliases) into
|
|
74
|
+
* an ordered, de-duplicated list. Empty when none is set.
|
|
114
75
|
*/
|
|
115
76
|
export function parseHostsOption(opts) {
|
|
116
77
|
const out = [];
|
|
@@ -135,11 +96,9 @@ export function parseHostsOption(opts) {
|
|
|
135
96
|
return out;
|
|
136
97
|
}
|
|
137
98
|
/**
|
|
138
|
-
* Split a `bundle@host` reference. No `@` → a local bundle
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
* `user@host` (e.g. `r2.backups@muqsit@box` → bundle `r2.backups`, host
|
|
142
|
-
* `muqsit@box`).
|
|
99
|
+
* Split a `bundle@host` reference. No `@` → a local bundle. Bundle names can't
|
|
100
|
+
* contain `@`, so the FIRST `@` separates bundle from ssh target; the target
|
|
101
|
+
* itself may be `user@host` (e.g. `r2.backups@muqsit@box`).
|
|
143
102
|
*/
|
|
144
103
|
export function splitBundleRef(ref) {
|
|
145
104
|
const at = ref.indexOf('@');
|
|
@@ -154,19 +113,13 @@ export function splitBundleRef(ref) {
|
|
|
154
113
|
}
|
|
155
114
|
/**
|
|
156
115
|
* Run `agents secrets <args>` on a remote host over ssh and return the raw
|
|
157
|
-
* result. Used by
|
|
158
|
-
*
|
|
159
|
-
* remote Touch-ID / passphrase prompt can surface (e.g. `view --reveal`).
|
|
116
|
+
* result. Used by browse commands; `tty` forces `-tt` so remote passphrase
|
|
117
|
+
* prompts can surface.
|
|
160
118
|
*/
|
|
161
119
|
export function remoteSecretsRaw(target, args, opts = {}) {
|
|
162
120
|
const remoteCmd = buildRemoteAgentsInvocation(['secrets', ...args], undefined, osForTarget(target, opts.osLookupName));
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
// (a remote reveal/passphrase prompt) additionally allocates a PTY and never
|
|
166
|
-
// multiplexes, and it COMPOSES with the secret posture: a `view --reveal` over
|
|
167
|
-
// `--device` both prompts AND streams the plaintext value back over ssh stdout,
|
|
168
|
-
// so it needs the managed host-key pin too — `tty` must not short-circuit past
|
|
169
|
-
// `secret`. A plain browse `list` passes neither and keeps the shared baseline.
|
|
121
|
+
// `secret: true` pins the managed host key and refuses multiplex; `-tt`
|
|
122
|
+
// additionally allocates a PTY and never multiplexes. The two compose.
|
|
170
123
|
const posture = opts.secret ? credentialTransportSshOpts(target) : {};
|
|
171
124
|
const conn = opts.tty
|
|
172
125
|
? { ...posture, extraSshArgs: ['-tt'], multiplex: false }
|
|
@@ -178,49 +131,28 @@ export function remoteSecretsRaw(target, args, opts = {}) {
|
|
|
178
131
|
});
|
|
179
132
|
}
|
|
180
133
|
/**
|
|
181
|
-
* Run a remote `agents secrets <args>`
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
* Unlike `remoteSecretsRaw` — which pipes stdin, so even with `-tt` the remote
|
|
185
|
-
* process's `process.stdin.isTTY` is false and a passphrase prompt refuses to
|
|
186
|
-
* appear (the macOS file-store guard then hard-errors "needs
|
|
187
|
-
* AGENTS_SECRETS_PASSPHRASE") — this inherits the caller's real terminal, so the
|
|
188
|
-
* remote sees a genuine TTY and its hidden passphrase prompt surfaces and reads
|
|
189
|
-
* the keystrokes. This is the transport for `unlock --device`: you type the remote
|
|
190
|
-
* bundle's passphrase at your own terminal. Output is NOT captured (it streams
|
|
191
|
-
* to the terminal); only the exit code is returned.
|
|
134
|
+
* Run a remote `agents secrets <args>` foreground with local stdio inherited,
|
|
135
|
+
* so the remote sees a real TTY and its passphrase prompt surfaces and reads
|
|
136
|
+
* keystrokes. Output streams to the terminal; only the exit code is returned.
|
|
192
137
|
*/
|
|
193
138
|
export function remoteSecretsStream(target, args, opts = {}) {
|
|
194
139
|
const remoteCmd = buildRemoteAgentsInvocation(['secrets', ...args], undefined, osForTarget(target, opts.osLookupName));
|
|
195
|
-
// `unlock --device` carries the remote bundle's passphrase to the destination over
|
|
196
|
-
// this interactive channel, so it is secret-bearing: pin the managed host key
|
|
197
|
-
// (a changed key is refused) and never multiplex (RUSH-2527).
|
|
198
140
|
return sshStream(target, remoteCmd, { tty: true, ...credentialTransportSshOpts(target) });
|
|
199
141
|
}
|
|
200
142
|
/**
|
|
201
143
|
* Resolve a remote bundle to a plaintext env map by driving the remote's
|
|
202
144
|
* `agents secrets export <bundle> --plaintext --format json`. Values cross over
|
|
203
|
-
* ssh stdout
|
|
145
|
+
* ssh stdout, parsed in memory, never persisted.
|
|
204
146
|
*
|
|
205
|
-
* The remote unlocks the bundle with
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
* passphrase: the remote bundle is encrypted with the remote's passphrase, so
|
|
209
|
-
* overriding it would break the read. (A macOS remote under non-interactive
|
|
210
|
-
* SSH will block on Touch-ID — use `view`/`exec` with a remote `file` bundle,
|
|
211
|
-
* an already-unlocked remote secrets-agent, or an interactive `-tt` session.)
|
|
147
|
+
* The remote unlocks the bundle with its own credentials; this machine does NOT
|
|
148
|
+
* forward its passphrase because the remote bundle is encrypted with the
|
|
149
|
+
* remote's passphrase.
|
|
212
150
|
*/
|
|
213
151
|
export async function remoteResolveEnv(target, bundle, opts = {}) {
|
|
214
152
|
assertValidSshTarget(target);
|
|
215
|
-
// AGENTS_SECRETS_REMOTE_TRANSPORT
|
|
216
|
-
//
|
|
217
|
-
// export mode was removed (RUSH-2774), and this machine-to-machine resolve is
|
|
218
|
-
// the only surviving caller of the JSON emitter. Riding the legacy argv keeps
|
|
219
|
-
// a new driver compatible with an old remote during a fleet rollout.
|
|
153
|
+
// AGENTS_SECRETS_REMOTE_TRANSPORT marks this as the machine-to-machine JSON
|
|
154
|
+
// resolve path (the public shell-eval export mode was removed).
|
|
220
155
|
const remoteCmd = buildRemoteAgentsInvocation(['secrets', 'export', bundle, '--plaintext', '--format', 'json'], undefined, osForTarget(target, opts.osLookupName), { AGENTS_SECRETS_REMOTE_TRANSPORT: '1' });
|
|
221
|
-
// Resolving a remote bundle streams its plaintext values back over ssh stdout,
|
|
222
|
-
// so this read is secret-bearing: pin the managed host key and never leave a
|
|
223
|
-
// reusable control master to the source (RUSH-2527, `credentialTransportSshOpts`).
|
|
224
156
|
const res = sshExec(target, remoteCmd, {
|
|
225
157
|
timeoutMs: REMOTE_TIMEOUT_MS,
|
|
226
158
|
...credentialTransportSshOpts(target),
|
|
@@ -249,8 +181,7 @@ export async function remoteResolveEnv(target, bundle, opts = {}) {
|
|
|
249
181
|
const env = {};
|
|
250
182
|
const blocked = [];
|
|
251
183
|
for (const [k, v] of Object.entries(parsed)) {
|
|
252
|
-
// Drop dangerous-override keys returned by the (untrusted) peer
|
|
253
|
-
// can reshape this process — see isDangerousRemoteEnvKey.
|
|
184
|
+
// Drop dangerous-override keys returned by the (untrusted) peer.
|
|
254
185
|
if (isDangerousRemoteEnvKey(k)) {
|
|
255
186
|
blocked.push(k);
|
|
256
187
|
continue;
|
|
@@ -261,10 +192,7 @@ export async function remoteResolveEnv(target, bundle, opts = {}) {
|
|
|
261
192
|
process.stderr.write(`[secrets] Dropped ${blocked.length} dangerous key(s) from '${bundle}'@${target} ` +
|
|
262
193
|
`(remote override blocked): ${blocked.join(', ')}\n`);
|
|
263
194
|
}
|
|
264
|
-
//
|
|
265
|
-
// event on the INITIATING host too (values were pulled into this process and
|
|
266
|
-
// injected locally). Covers `secrets exec --host` and `run --secrets b@host`.
|
|
267
|
-
// Values never enter the payload — only the bundle, target host, and count.
|
|
195
|
+
// Audit the event on the initiating host too; values never enter the payload.
|
|
268
196
|
emitSecretAudit({
|
|
269
197
|
event: 'secrets.get',
|
|
270
198
|
bundle,
|
|
@@ -277,56 +205,32 @@ export async function remoteResolveEnv(target, bundle, opts = {}) {
|
|
|
277
205
|
return env;
|
|
278
206
|
}
|
|
279
207
|
/**
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
* signature. Anything else (connection refused, timeout, host key error) is a
|
|
283
|
-
* transient/unrelated failure and must NOT be mislabeled as a locked keychain.
|
|
208
|
+
* True when the remote's headless read-back raised the locked-login-keychain
|
|
209
|
+
* signature. Anything else must NOT be mislabeled as a locked keychain.
|
|
284
210
|
*/
|
|
285
211
|
function isLockedKeychainReadBackError(stderr) {
|
|
286
212
|
const s = stderr.toLowerCase();
|
|
287
|
-
return (
|
|
288
|
-
// bundles.ts agentOnly guard: "…is not unlocked in the secrets agent…"
|
|
289
|
-
s.includes('not unlocked') ||
|
|
213
|
+
return (s.includes('not unlocked') ||
|
|
290
214
|
s.includes('secrets agent') ||
|
|
291
|
-
// resolveBundleEnv: "Bundle '<b>' key '<k>': stored item '<item>' not found."
|
|
292
215
|
s.includes('stored item') ||
|
|
293
216
|
s.includes('not found'));
|
|
294
217
|
}
|
|
295
218
|
/**
|
|
296
|
-
* Decide whether a keychain-backed push to a remote actually
|
|
297
|
-
* value items
|
|
298
|
-
*
|
|
299
|
-
*
|
|
300
|
-
* a macOS host over headless SSH lands the bundle METADATA but not readable value
|
|
301
|
-
* items — the remote login keychain is locked in the non-interactive SSH context,
|
|
302
|
-
* so Security accepts the item WRITE at the DB level but the biometry-ACL'd item is
|
|
303
|
-
* unreadable, and the remote `import` still reports success (values written first,
|
|
304
|
-
* metadata `noAcl` last — bundles.ts writeBundleWithItems). The metadata-only bundle
|
|
305
|
-
* then fails every later read with the confusing `Bundle '<b>' key '<k>': stored
|
|
306
|
-
* item '<item>' not found` (bundles.ts resolveBundleEnv). We catch it by reading
|
|
307
|
-
* the bundle back the same way a later `secrets exec`/resolve will (the marker-gated
|
|
308
|
-
* json transport, driven headlessly on the remote so its `agentOnly` guard FAILS FAST
|
|
309
|
-
* before any keychain read — no Touch ID prompt) and confirming every pushed key
|
|
310
|
-
* returned.
|
|
311
|
-
*
|
|
312
|
-
* Pure so both branches are unit-testable without a real locked keychain: inject the
|
|
313
|
-
* "read-back failed / key absent" condition through `readBack`.
|
|
219
|
+
* Decide whether a keychain-backed push to a remote actually persisted its
|
|
220
|
+
* secret value items by reading the bundle back the same way a later resolve
|
|
221
|
+
* will. Catches the silent failure where headless SSH writes metadata but not
|
|
222
|
+
* readable value items to a locked macOS login keychain.
|
|
314
223
|
*/
|
|
315
224
|
export function evaluateKeychainWriteVerification(pushedKeys, readBack) {
|
|
316
225
|
if (!readBack.ok) {
|
|
317
226
|
const stderr = readBack.stderr.trim();
|
|
318
227
|
if (isLockedKeychainReadBackError(stderr)) {
|
|
319
|
-
// The remote's own headless read raised the not-unlocked / not-found signal —
|
|
320
|
-
// exactly the confusing error the user hits later. Surface it now, at push
|
|
321
|
-
// time, with the fix.
|
|
322
228
|
return {
|
|
323
229
|
ok: false,
|
|
324
230
|
kind: 'locked-keychain',
|
|
325
231
|
reason: `the remote could not read it back${stderr ? ` (${stderr})` : ''}`,
|
|
326
232
|
};
|
|
327
233
|
}
|
|
328
|
-
// A transient / unrelated failure (flaky SSH, timeout, bad payload). Re-surface
|
|
329
|
-
// verbatim — do NOT diagnose a locked keychain from a connection error.
|
|
330
234
|
return {
|
|
331
235
|
ok: false,
|
|
332
236
|
kind: 'error',
|
|
@@ -336,8 +240,6 @@ export function evaluateKeychainWriteVerification(pushedKeys, readBack) {
|
|
|
336
240
|
const present = new Set(readBack.keys);
|
|
337
241
|
const missing = pushedKeys.filter((k) => !present.has(k));
|
|
338
242
|
if (missing.length > 0) {
|
|
339
|
-
// Read-back succeeded but some pushed keys are absent — the value items didn't
|
|
340
|
-
// persist. Same locked-keychain cause and fix.
|
|
341
243
|
return {
|
|
342
244
|
ok: false,
|
|
343
245
|
kind: 'locked-keychain',
|
|
@@ -348,10 +250,8 @@ export function evaluateKeychainWriteVerification(pushedKeys, readBack) {
|
|
|
348
250
|
return { ok: true };
|
|
349
251
|
}
|
|
350
252
|
/**
|
|
351
|
-
*
|
|
352
|
-
*
|
|
353
|
-
* re-run with the headless-readable file backend, or unlock the remote keychain.
|
|
354
|
-
* Pure + exported so the exact guidance is asserted in tests.
|
|
253
|
+
* Actionable error message for a failed keychain-over-SSH push. Names the cause
|
|
254
|
+
* (locked remote login keychain) and steers to the two real fixes.
|
|
355
255
|
*/
|
|
356
256
|
export function keychainWriteFailureMessage(host, bundle, reason) {
|
|
357
257
|
return (`${host}: pushed '${bundle}' but the keychain items did not persist — ${reason}. ` +
|
|
@@ -363,22 +263,12 @@ export function keychainWriteFailureMessage(host, bundle, reason) {
|
|
|
363
263
|
`(e.g. an interactive login / \`agents secrets unlock\` on ${host}) and retry.`);
|
|
364
264
|
}
|
|
365
265
|
/**
|
|
366
|
-
* Read a bundle back from a remote over SSH
|
|
367
|
-
*
|
|
368
|
-
*
|
|
369
|
-
* --format json` under AGENTS_SECRETS_REMOTE_TRANSPORT) but keeps only the KEY
|
|
370
|
-
* NAMES; the plaintext values are dropped immediately and never retained or
|
|
371
|
-
* logged. Returns a verification verdict; the caller renders
|
|
372
|
-
* `keychainWriteFailureMessage` on failure.
|
|
266
|
+
* Read a bundle back from a remote over SSH and confirm the pushed keys
|
|
267
|
+
* materialized. Drives the marker-gated json transport but keeps only KEY NAMES;
|
|
268
|
+
* plaintext values are discarded immediately. Returns a verification verdict.
|
|
373
269
|
*/
|
|
374
270
|
export function verifyRemoteKeychainPush(target, bundle, pushedKeys, opts = {}) {
|
|
375
|
-
const remoteCmd = buildRemoteAgentsInvocation(['secrets', 'export', bundle, '--plaintext', '--format', 'json'], undefined, osForTarget(target, opts.osLookupName),
|
|
376
|
-
// Same transport marker as remoteResolveEnv — see the comment there (RUSH-2774).
|
|
377
|
-
{ AGENTS_SECRETS_REMOTE_TRANSPORT: '1' });
|
|
378
|
-
// This read-back streams the just-pushed plaintext over ssh stdout, so it is
|
|
379
|
-
// as secret-bearing as the push itself — the push path passes `secret: true`
|
|
380
|
-
// so it too pins the managed host key and leaves no reusable control master to
|
|
381
|
-
// the destination (RUSH-2527).
|
|
271
|
+
const remoteCmd = buildRemoteAgentsInvocation(['secrets', 'export', bundle, '--plaintext', '--format', 'json'], undefined, osForTarget(target, opts.osLookupName), { AGENTS_SECRETS_REMOTE_TRANSPORT: '1' });
|
|
382
272
|
const res = sshExec(target, remoteCmd, {
|
|
383
273
|
timeoutMs: REMOTE_TIMEOUT_MS,
|
|
384
274
|
...(opts.secret ? credentialTransportSshOpts(target) : {}),
|
|
@@ -388,8 +278,7 @@ export function verifyRemoteKeychainPush(target, bundle, pushedKeys, opts = {})
|
|
|
388
278
|
const stderr = `${why}${(res.stderr || res.stdout || '').trim() ? `: ${(res.stderr || res.stdout).trim()}` : ''}`;
|
|
389
279
|
return evaluateKeychainWriteVerification(pushedKeys, { ok: false, stderr });
|
|
390
280
|
}
|
|
391
|
-
//
|
|
392
|
-
// names, and immediately discard the values — we only need presence here.
|
|
281
|
+
// Tolerate login-shell banner noise; keep only key names and discard values.
|
|
393
282
|
const raw = res.stdout;
|
|
394
283
|
const start = raw.indexOf('{');
|
|
395
284
|
const end = raw.lastIndexOf('}');
|
|
@@ -414,24 +303,17 @@ export function verifyRemoteKeychainPush(target, bundle, pushedKeys, opts = {})
|
|
|
414
303
|
return evaluateKeychainWriteVerification(pushedKeys, { ok: true, keys });
|
|
415
304
|
}
|
|
416
305
|
/**
|
|
417
|
-
*
|
|
418
|
-
* import for `secrets export --device … --remote-backend file`.
|
|
306
|
+
* `bash -lc` command + stdin payload for a file-backed remote import.
|
|
419
307
|
*
|
|
420
|
-
* The file store is passphrase-free: the remote
|
|
421
|
-
*
|
|
422
|
-
* `~/.agents/.secrets-key/`), so its reads are HEADLESS — no passphrase, no
|
|
423
|
-
* Touch ID. AGENTS_SECRETS_PASSPHRASE is a deprecated override and MUST NOT be
|
|
308
|
+
* The file store is passphrase-free: the remote auto-provisions its own machine-
|
|
309
|
+
* local key, so reads are headless. AGENTS_SECRETS_PASSPHRASE must NOT be
|
|
424
310
|
* forwarded (PHNX-2371): a remote keyed to a secret its daemon does not hold
|
|
425
311
|
* reports "Imported N key(s)" then fails every later decrypt.
|
|
426
|
-
*
|
|
427
|
-
* Pure — no I/O — so the exact command string and stdin ordering are unit-testable
|
|
428
|
-
* against the SSH boundary the same way `remoteSecretsRaw` is.
|
|
429
312
|
*/
|
|
430
313
|
export function buildRemoteFileImportCommand(bundle, dotenv, opts = {}) {
|
|
431
314
|
const force = opts.force ? ' --force' : '';
|
|
432
315
|
const policy = opts.policyNever ? ' --policy never --i-understand' : '';
|
|
433
316
|
const importCmd = `agents secrets import ${shellQuote(bundle)} --from - --backend file${force}${policy}`;
|
|
434
|
-
// No prologue — AGENTS_SECRETS_PASSPHRASE stays unset on the remote
|
|
435
|
-
// file store falls back to its machine-local key (headless).
|
|
317
|
+
// No prologue — AGENTS_SECRETS_PASSPHRASE stays unset on the remote.
|
|
436
318
|
return { remoteCmd: `bash -lc ${shellQuote(importCmd)}`, input: dotenv };
|
|
437
319
|
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// install-staging check — fleet-wide self-heal for the orphaned npm reify
|
|
2
|
+
// staging dir that dead-ends `agents upgrade` forever (PHNX-3393).
|
|
3
|
+
//
|
|
4
|
+
// `agents upgrade` itself sweeps this orphan proactively before every reify
|
|
5
|
+
// (sweepStaleInstallStaging in self-update.ts), so a box that runs `agents
|
|
6
|
+
// upgrade` regularly never accumulates one. This check exists for the box
|
|
7
|
+
// that does NOT: one that stopped upgrading after the crash that left the
|
|
8
|
+
// orphan behind, so the next manual `agents upgrade` would still hit the same
|
|
9
|
+
// ENOTEMPTY the sweep exists to prevent, and nothing runs `agents upgrade` to
|
|
10
|
+
// trigger that sweep in the meantime. Periodic cadence closes that gap
|
|
11
|
+
// fleet-wide (via the daemon's self-heal service) and on demand via
|
|
12
|
+
// `agents doctor --fix`.
|
|
13
|
+
//
|
|
14
|
+
// The age guard is load-bearing: a staging dir can be legitimately mid-write
|
|
15
|
+
// by a CONCURRENT upgrade this instant. Only a dir older than the guard is
|
|
16
|
+
// touched, so an unattended periodic run can never delete a live reify.
|
|
17
|
+
import * as fs from 'fs';
|
|
18
|
+
import * as path from 'path';
|
|
19
|
+
import { fileURLToPath } from 'url';
|
|
20
|
+
import { resultOf } from '../types.js';
|
|
21
|
+
import { resolveRunningPackageRoot } from '../../self-update.js';
|
|
22
|
+
const __installStagingDirname = path.dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
/** A concurrent upgrade must be long finished before an unattended sweep may touch its staging dir. */
|
|
24
|
+
export const STALE_INSTALL_STAGING_AGE_MS = 10 * 60 * 1000;
|
|
25
|
+
/**
|
|
26
|
+
* Find the retire-path staging dir(s) for `packageRoot` older than
|
|
27
|
+
* `maxAgeMs`. Reimplements the same matching self-update.ts's
|
|
28
|
+
* sweepStaleInstallStaging uses, so the age guard can be checked BEFORE
|
|
29
|
+
* anything is deleted — the sweep helper itself removes unconditionally,
|
|
30
|
+
* which is correct for the upgrade hot path (nothing else touches that
|
|
31
|
+
* path while an upgrade you just started is running) but wrong for an
|
|
32
|
+
* unattended periodic sweep that could race a concurrent upgrade.
|
|
33
|
+
*/
|
|
34
|
+
function findAgedInstallStaging(packageRoot, maxAgeMs, now) {
|
|
35
|
+
const resolved = path.resolve(packageRoot);
|
|
36
|
+
const dir = path.dirname(resolved);
|
|
37
|
+
const base = path.basename(resolved);
|
|
38
|
+
const escapedBase = base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
39
|
+
const stagingPattern = new RegExp(`^\\.${escapedBase}-[a-zA-Z0-9]+$`);
|
|
40
|
+
let entries;
|
|
41
|
+
try {
|
|
42
|
+
entries = fs.readdirSync(dir);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
const aged = [];
|
|
48
|
+
for (const entry of entries) {
|
|
49
|
+
if (!stagingPattern.test(entry))
|
|
50
|
+
continue;
|
|
51
|
+
const full = path.join(dir, entry);
|
|
52
|
+
let mtimeMs;
|
|
53
|
+
try {
|
|
54
|
+
mtimeMs = fs.statSync(full).mtimeMs;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
continue; // vanished between readdir and stat — nothing to sweep
|
|
58
|
+
}
|
|
59
|
+
if (now - mtimeMs >= maxAgeMs)
|
|
60
|
+
aged.push(full);
|
|
61
|
+
}
|
|
62
|
+
return aged;
|
|
63
|
+
}
|
|
64
|
+
export const installStagingCheck = {
|
|
65
|
+
id: 'install-staging',
|
|
66
|
+
title: 'Orphaned npm reify staging dir',
|
|
67
|
+
cadence: 'periodic',
|
|
68
|
+
async run(ctx) {
|
|
69
|
+
let packageRoot;
|
|
70
|
+
try {
|
|
71
|
+
packageRoot = resolveRunningPackageRoot(__installStagingDirname);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// Not an npm/bun-managed install (source checkout) — nothing to sweep.
|
|
75
|
+
return resultOf([], []);
|
|
76
|
+
}
|
|
77
|
+
const aged = findAgedInstallStaging(packageRoot, STALE_INSTALL_STAGING_AGE_MS, Date.now());
|
|
78
|
+
if (aged.length === 0)
|
|
79
|
+
return resultOf([], []);
|
|
80
|
+
if (ctx.dryRun) {
|
|
81
|
+
return resultOf(aged.map((p) => `orphaned reify staging dir: ${p}`), []);
|
|
82
|
+
}
|
|
83
|
+
const fixed = [];
|
|
84
|
+
const needsAttention = [];
|
|
85
|
+
for (const stagingPath of aged) {
|
|
86
|
+
try {
|
|
87
|
+
fs.rmSync(stagingPath, { recursive: true, force: true });
|
|
88
|
+
fixed.push(`removed orphaned reify staging dir ${stagingPath} — the next 'agents upgrade' can reify cleanly`);
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
needsAttention.push(`could not remove orphaned reify staging dir ${stagingPath}: ${err.message}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return resultOf(fixed, needsAttention);
|
|
95
|
+
},
|
|
96
|
+
};
|
|
@@ -10,6 +10,7 @@ import { hookManifestCheck } from './checks/hook-manifest.js';
|
|
|
10
10
|
import { shimsCheck } from './checks/shims.js';
|
|
11
11
|
import { shadowingCheck } from './checks/shadowing.js';
|
|
12
12
|
import { pathCheck } from './checks/path.js';
|
|
13
|
+
import { installStagingCheck } from './checks/install-staging.js';
|
|
13
14
|
// Order matters: cheap structural fixes (shims, shadow adoption, PATH, generated
|
|
14
15
|
// hook wrappers) before the heavier resource reconciliation, so a freshly-
|
|
15
16
|
// repaired shim is in place first.
|
|
@@ -22,6 +23,7 @@ export const HEAL_CHECKS = [
|
|
|
22
23
|
// manifest entry that should have produced it resolves nowhere.
|
|
23
24
|
hookManifestCheck,
|
|
24
25
|
resourcesCheck,
|
|
26
|
+
installStagingCheck,
|
|
25
27
|
];
|
|
26
28
|
/** Run the selected checks, isolating per-check failures. */
|
|
27
29
|
export async function runSelfHeal(opts = {}) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type HealCheckId = 'resources' | 'hook-runtime' | 'hook-manifest' | 'shims' | 'shadowing' | 'path';
|
|
1
|
+
export type HealCheckId = 'resources' | 'hook-runtime' | 'hook-manifest' | 'shims' | 'shadowing' | 'path' | 'install-staging';
|
|
2
2
|
/** When the daemon schedules a check. */
|
|
3
3
|
export type HealCadence = 'startup' | 'frequent' | 'periodic';
|
|
4
4
|
export interface HealCtx {
|
|
@@ -130,6 +130,29 @@ export declare function resolveRunningPackageRoot(dirname: string, execPath?: st
|
|
|
130
130
|
* one is exactly the bug this module exists to prevent.
|
|
131
131
|
*/
|
|
132
132
|
export declare function deriveGlobalPrefix(packageRoot: string): string;
|
|
133
|
+
/**
|
|
134
|
+
* Sweep npm arborist's "retired" staging dir for `packageRoot` before a
|
|
135
|
+
* reify (PHNX-3393).
|
|
136
|
+
*
|
|
137
|
+
* npm (@npmcli/arborist) reifies an install by first renaming the tree it is
|
|
138
|
+
* about to replace out of the way into a sibling directory —
|
|
139
|
+
* `retirePath(from)` in arborist's own source names it
|
|
140
|
+
* `.<basename>-<8-char sha1 hash of the full path>`, sibling to `from` — then
|
|
141
|
+
* stages the new tree and renames it into place. Because the hash is a pure
|
|
142
|
+
* function of `packageRoot`'s path, that staging path is IDENTICAL on every
|
|
143
|
+
* reify of this install. A crash between the retire-rename and the final
|
|
144
|
+
* rename (SIGKILL, a killed terminal, a box that lost power mid-upgrade)
|
|
145
|
+
* leaves that exact directory behind, non-empty. `rename(2)` cannot replace a
|
|
146
|
+
* non-empty directory, so every subsequent upgrade's reify fails ENOTEMPTY at
|
|
147
|
+
* the same path forever — nothing about a plain retry ever clears it.
|
|
148
|
+
*
|
|
149
|
+
* Removing any stale `.<basename>-*` sibling before install self-heals this:
|
|
150
|
+
* npm re-stages cleanly once the collision is gone. Matches only the
|
|
151
|
+
* retire-path shape (a dot-prefixed sibling starting with the package's own
|
|
152
|
+
* basename), so an unrelated dotfile in the same directory is left alone.
|
|
153
|
+
* Best-effort per entry: one unremovable sibling must not block the rest.
|
|
154
|
+
*/
|
|
155
|
+
export declare function sweepStaleInstallStaging(packageRoot: string): string[];
|
|
133
156
|
/**
|
|
134
157
|
* Install `spec` into an explicit global prefix. `--prefix` pins the
|
|
135
158
|
* destination no matter which npm binary PATH resolves. `--ignore-scripts`
|
package/dist/lib/self-update.js
CHANGED
|
@@ -292,6 +292,56 @@ export function deriveGlobalPrefix(packageRoot) {
|
|
|
292
292
|
const parent = path.dirname(nodeModulesDir);
|
|
293
293
|
return path.basename(parent) === 'lib' ? path.dirname(parent) : parent;
|
|
294
294
|
}
|
|
295
|
+
/**
|
|
296
|
+
* Sweep npm arborist's "retired" staging dir for `packageRoot` before a
|
|
297
|
+
* reify (PHNX-3393).
|
|
298
|
+
*
|
|
299
|
+
* npm (@npmcli/arborist) reifies an install by first renaming the tree it is
|
|
300
|
+
* about to replace out of the way into a sibling directory —
|
|
301
|
+
* `retirePath(from)` in arborist's own source names it
|
|
302
|
+
* `.<basename>-<8-char sha1 hash of the full path>`, sibling to `from` — then
|
|
303
|
+
* stages the new tree and renames it into place. Because the hash is a pure
|
|
304
|
+
* function of `packageRoot`'s path, that staging path is IDENTICAL on every
|
|
305
|
+
* reify of this install. A crash between the retire-rename and the final
|
|
306
|
+
* rename (SIGKILL, a killed terminal, a box that lost power mid-upgrade)
|
|
307
|
+
* leaves that exact directory behind, non-empty. `rename(2)` cannot replace a
|
|
308
|
+
* non-empty directory, so every subsequent upgrade's reify fails ENOTEMPTY at
|
|
309
|
+
* the same path forever — nothing about a plain retry ever clears it.
|
|
310
|
+
*
|
|
311
|
+
* Removing any stale `.<basename>-*` sibling before install self-heals this:
|
|
312
|
+
* npm re-stages cleanly once the collision is gone. Matches only the
|
|
313
|
+
* retire-path shape (a dot-prefixed sibling starting with the package's own
|
|
314
|
+
* basename), so an unrelated dotfile in the same directory is left alone.
|
|
315
|
+
* Best-effort per entry: one unremovable sibling must not block the rest.
|
|
316
|
+
*/
|
|
317
|
+
export function sweepStaleInstallStaging(packageRoot) {
|
|
318
|
+
const resolved = path.resolve(packageRoot);
|
|
319
|
+
const dir = path.dirname(resolved);
|
|
320
|
+
const base = path.basename(resolved);
|
|
321
|
+
const escapedBase = base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
322
|
+
const stagingPattern = new RegExp(`^\\.${escapedBase}-[a-zA-Z0-9]+$`);
|
|
323
|
+
let entries;
|
|
324
|
+
try {
|
|
325
|
+
entries = fs.readdirSync(dir);
|
|
326
|
+
}
|
|
327
|
+
catch {
|
|
328
|
+
return [];
|
|
329
|
+
}
|
|
330
|
+
const swept = [];
|
|
331
|
+
for (const entry of entries) {
|
|
332
|
+
if (!stagingPattern.test(entry))
|
|
333
|
+
continue;
|
|
334
|
+
const full = path.join(dir, entry);
|
|
335
|
+
try {
|
|
336
|
+
fs.rmSync(full, { recursive: true, force: true });
|
|
337
|
+
swept.push(full);
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
/* best-effort — one unremovable stager must not block the rest */
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return swept;
|
|
344
|
+
}
|
|
295
345
|
/**
|
|
296
346
|
* Install `spec` into an explicit global prefix. `--prefix` pins the
|
|
297
347
|
* destination no matter which npm binary PATH resolves. `--ignore-scripts`
|