@phnx-labs/agents-cli 1.20.32 → 1.20.34
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 +7 -0
- package/README.md +28 -2
- package/dist/commands/commands.js +3 -3
- package/dist/commands/computer-actions.js +1 -0
- package/dist/commands/computer.d.ts +23 -0
- package/dist/commands/computer.js +45 -3
- package/dist/commands/cost.js +2 -2
- package/dist/commands/doctor.d.ts +10 -0
- package/dist/commands/doctor.js +51 -2
- package/dist/commands/exec.js +56 -1
- package/dist/commands/hooks.js +3 -3
- package/dist/commands/import.js +1 -1
- package/dist/commands/inspect.js +13 -17
- package/dist/commands/mcp.js +3 -3
- package/dist/commands/permissions.js +3 -3
- package/dist/commands/rules.js +3 -3
- package/dist/commands/secrets-migrate.js +23 -11
- package/dist/commands/secrets.d.ts +20 -0
- package/dist/commands/secrets.js +53 -1
- package/dist/commands/sessions.js +18 -1
- package/dist/commands/skills.js +3 -3
- package/dist/commands/status.d.ts +12 -0
- package/dist/commands/status.js +81 -0
- package/dist/commands/sync.js +2 -2
- package/dist/commands/teams.js +77 -18
- package/dist/commands/usage.js +2 -2
- package/dist/commands/utils.d.ts +8 -0
- package/dist/commands/utils.js +20 -0
- package/dist/commands/versions.js +4 -3
- package/dist/commands/view.d.ts +39 -0
- package/dist/commands/view.js +217 -84
- package/dist/commands/workflows.js +3 -3
- package/dist/index.js +16 -2
- package/dist/lib/acp/harnesses.d.ts +1 -1
- package/dist/lib/acp/harnesses.js +2 -2
- package/dist/lib/agent-spec/index.d.ts +18 -0
- package/dist/lib/agent-spec/index.js +35 -0
- package/dist/lib/agent-spec/primitives.d.ts +28 -0
- package/dist/lib/agent-spec/primitives.js +57 -0
- package/dist/lib/agent-spec/provider.d.ts +2 -0
- package/dist/lib/agent-spec/provider.js +9 -0
- package/dist/lib/agent-spec/resolve.d.ts +33 -0
- package/dist/lib/agent-spec/resolve.js +174 -0
- package/dist/lib/agent-spec/types.d.ts +57 -0
- package/dist/lib/agent-spec/types.js +18 -0
- package/dist/lib/agents.d.ts +12 -0
- package/dist/lib/agents.js +115 -32
- package/dist/lib/browser/chrome.js +20 -0
- package/dist/lib/browser/drivers/ssh.d.ts +19 -0
- package/dist/lib/browser/drivers/ssh.js +18 -3
- package/dist/lib/crabbox/cli.d.ts +98 -0
- package/dist/lib/crabbox/cli.js +218 -0
- package/dist/lib/crabbox/lease.d.ts +41 -0
- package/dist/lib/crabbox/lease.js +73 -0
- package/dist/lib/crabbox/runtimes.d.ts +57 -0
- package/dist/lib/crabbox/runtimes.js +109 -0
- package/dist/lib/doctor-diff.js +29 -2
- package/dist/lib/drift-sync.d.ts +43 -0
- package/dist/lib/drift-sync.js +179 -0
- package/dist/lib/exec.d.ts +15 -0
- package/dist/lib/exec.js +21 -11
- package/dist/lib/hosts/dispatch.d.ts +27 -10
- package/dist/lib/hosts/dispatch.js +55 -19
- package/dist/lib/hosts/option.d.ts +14 -0
- package/dist/lib/hosts/option.js +19 -0
- package/dist/lib/hosts/passthrough.d.ts +30 -0
- package/dist/lib/hosts/passthrough.js +141 -0
- package/dist/lib/hosts/remote-cmd.d.ts +36 -0
- package/dist/lib/hosts/remote-cmd.js +56 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/platform/winpath.d.ts +31 -2
- package/dist/lib/platform/winpath.js +133 -24
- package/dist/lib/pwsh.d.ts +11 -0
- package/dist/lib/pwsh.js +13 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/agent.d.ts +42 -1
- package/dist/lib/secrets/agent.js +89 -11
- package/dist/lib/secrets/bundles.js +61 -21
- package/dist/lib/secrets/filestore.js +31 -1
- package/dist/lib/secrets/index.d.ts +44 -1
- package/dist/lib/secrets/index.js +108 -10
- package/dist/lib/secrets/linux.d.ts +14 -0
- package/dist/lib/secrets/linux.js +21 -0
- package/dist/lib/secrets/windows.d.ts +74 -0
- package/dist/lib/secrets/windows.js +440 -0
- package/dist/lib/session/active.d.ts +8 -0
- package/dist/lib/session/active.js +18 -1
- package/dist/lib/session/provenance.d.ts +56 -0
- package/dist/lib/session/provenance.js +157 -0
- package/dist/lib/shims.d.ts +20 -0
- package/dist/lib/shims.js +53 -20
- package/dist/lib/ssh-exec.d.ts +22 -0
- package/dist/lib/ssh-exec.js +59 -2
- package/dist/lib/ssh-tunnel.d.ts +0 -5
- package/dist/lib/ssh-tunnel.js +65 -8
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +2 -0
- package/dist/lib/sync-status.d.ts +102 -0
- package/dist/lib/sync-status.js +135 -0
- package/dist/lib/teams/agents.d.ts +24 -0
- package/dist/lib/teams/agents.js +30 -1
- package/dist/lib/types.d.ts +20 -1
- package/dist/lib/usage.d.ts +30 -0
- package/dist/lib/usage.js +159 -2
- package/dist/lib/versions.d.ts +2 -4
- package/dist/lib/versions.js +7 -20
- package/package.json +2 -1
- package/dist/lib/agent-spec.d.ts +0 -36
- package/dist/lib/agent-spec.js +0 -157
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic `--host` passthrough — the single choke point that runs an allowlisted
|
|
3
|
+
* `agents <command>` on a remote host instead of locally, so read-only and
|
|
4
|
+
* config commands (`view`, `usage`, `cost`, `doctor`, `inspect`, `list`, `sync`)
|
|
5
|
+
* and the team lifecycle (`teams …`) all gain remote support with no per-command
|
|
6
|
+
* code. Called once from `index.ts` before commander parses; returns `true` when
|
|
7
|
+
* it handled the invocation (the local command must then NOT run).
|
|
8
|
+
*
|
|
9
|
+
* Transport is SSH (via `ssh-exec.ts`), never a daemon: SSH is the one hardened
|
|
10
|
+
* choke point already used everywhere, and it gives auth + encryption + host-key
|
|
11
|
+
* trust for free. Read-only commands stream synchronously (`sshStream`); the one
|
|
12
|
+
* long-running case — `teams start --watch` — dispatches detached so the remote
|
|
13
|
+
* supervisor outlives a dropped connection.
|
|
14
|
+
*
|
|
15
|
+
* `run` and `sessions` are deliberately absent from the table below: they own
|
|
16
|
+
* richer `--host` handling in their own command actions (detached run dispatch;
|
|
17
|
+
* multi-host session fan-out) and must fall through to it.
|
|
18
|
+
*/
|
|
19
|
+
/** Pull the value of `--host`/`-H`/`--remote-cwd` (any form) out of an argv. */
|
|
20
|
+
export declare function flagValue(args: string[], long: string, short?: string): string | undefined;
|
|
21
|
+
/**
|
|
22
|
+
* Route `agents <command> … --host <name>` to a remote if the command is
|
|
23
|
+
* host-routable and a `--host` was given. Returns `false` (run locally) when
|
|
24
|
+
* there is no `--host`, the command isn't in the table, or the target is this
|
|
25
|
+
* very machine.
|
|
26
|
+
*
|
|
27
|
+
* @param command the resolved subcommand name (`process.argv`'s first non-flag).
|
|
28
|
+
* @param allArgs `process.argv.slice(2)` — the command name followed by its args.
|
|
29
|
+
*/
|
|
30
|
+
export declare function maybeRunOnHost(command: string, allArgs: string[]): Promise<boolean>;
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic `--host` passthrough — the single choke point that runs an allowlisted
|
|
3
|
+
* `agents <command>` on a remote host instead of locally, so read-only and
|
|
4
|
+
* config commands (`view`, `usage`, `cost`, `doctor`, `inspect`, `list`, `sync`)
|
|
5
|
+
* and the team lifecycle (`teams …`) all gain remote support with no per-command
|
|
6
|
+
* code. Called once from `index.ts` before commander parses; returns `true` when
|
|
7
|
+
* it handled the invocation (the local command must then NOT run).
|
|
8
|
+
*
|
|
9
|
+
* Transport is SSH (via `ssh-exec.ts`), never a daemon: SSH is the one hardened
|
|
10
|
+
* choke point already used everywhere, and it gives auth + encryption + host-key
|
|
11
|
+
* trust for free. Read-only commands stream synchronously (`sshStream`); the one
|
|
12
|
+
* long-running case — `teams start --watch` — dispatches detached so the remote
|
|
13
|
+
* supervisor outlives a dropped connection.
|
|
14
|
+
*
|
|
15
|
+
* `run` and `sessions` are deliberately absent from the table below: they own
|
|
16
|
+
* richer `--host` handling in their own command actions (detached run dispatch;
|
|
17
|
+
* multi-host session fan-out) and must fall through to it.
|
|
18
|
+
*/
|
|
19
|
+
import chalk from 'chalk';
|
|
20
|
+
import { assertValidSshTarget, sshStream } from '../ssh-exec.js';
|
|
21
|
+
import { resolveHost, resolveHostByCap } from './registry.js';
|
|
22
|
+
import { sshTargetFor } from './types.js';
|
|
23
|
+
import { dispatchAgentsCommand } from './dispatch.js';
|
|
24
|
+
import { stripRoutingFlags, buildRemoteAgentsInvocation, HOST_ROUTING_SPECS, } from './remote-cmd.js';
|
|
25
|
+
import { machineId } from '../session/sync/config.js';
|
|
26
|
+
const REMOTE_PASSTHROUGH = {
|
|
27
|
+
view: {},
|
|
28
|
+
usage: {},
|
|
29
|
+
cost: {},
|
|
30
|
+
doctor: {},
|
|
31
|
+
inspect: {},
|
|
32
|
+
list: {},
|
|
33
|
+
sync: { nonInteractive: ['--yes'] },
|
|
34
|
+
teams: {},
|
|
35
|
+
};
|
|
36
|
+
/** `--no-tty` is stripped like the routing flags but carries no value. */
|
|
37
|
+
const STRIP_SPECS = [...HOST_ROUTING_SPECS, { long: 'no-tty', takesValue: false }];
|
|
38
|
+
/** Pull the value of `--host`/`-H`/`--remote-cwd` (any form) out of an argv. */
|
|
39
|
+
export function flagValue(args, long, short) {
|
|
40
|
+
for (let i = 0; i < args.length; i++) {
|
|
41
|
+
const a = args[i];
|
|
42
|
+
if (a === `--${long}` || (short && a === `-${short}`))
|
|
43
|
+
return args[i + 1];
|
|
44
|
+
if (a.startsWith(`--${long}=`))
|
|
45
|
+
return a.slice(long.length + 3);
|
|
46
|
+
if (short && a.startsWith(`-${short}=`))
|
|
47
|
+
return a.slice(short.length + 2);
|
|
48
|
+
if (short && new RegExp(`^-${short}(.+)`).test(a))
|
|
49
|
+
return a.slice(2);
|
|
50
|
+
}
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
/** Synthesize a `Host` for a raw `user@host` / bare-alias target (not enrolled). */
|
|
54
|
+
function syntheticHost(target) {
|
|
55
|
+
const at = target.indexOf('@');
|
|
56
|
+
if (at !== -1) {
|
|
57
|
+
return { name: target, provider: 'local', source: 'inline', user: target.slice(0, at), address: target.slice(at + 1) };
|
|
58
|
+
}
|
|
59
|
+
// Bare name: ssh resolves it from ~/.ssh/config, or connects to it as a hostname.
|
|
60
|
+
return { name: target, provider: 'local', source: 'ssh-config' };
|
|
61
|
+
}
|
|
62
|
+
/** Resolve a `--host` value to a Host: enrolled name → capability tag → raw target. */
|
|
63
|
+
async function resolveTargetHost(name, any) {
|
|
64
|
+
const enrolled = await resolveHost(name);
|
|
65
|
+
if (enrolled)
|
|
66
|
+
return enrolled;
|
|
67
|
+
try {
|
|
68
|
+
return await resolveHostByCap(name, any);
|
|
69
|
+
}
|
|
70
|
+
catch (e) {
|
|
71
|
+
// "Multiple hosts tagged …" is actionable — surface it. "No host tagged" falls
|
|
72
|
+
// through to treating the value as a literal ssh target.
|
|
73
|
+
if (e instanceof Error && e.message.startsWith('Multiple hosts'))
|
|
74
|
+
throw e;
|
|
75
|
+
}
|
|
76
|
+
assertValidSshTarget(name); // rejects injection / flag-smuggling before it reaches ssh
|
|
77
|
+
return syntheticHost(name);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Route `agents <command> … --host <name>` to a remote if the command is
|
|
81
|
+
* host-routable and a `--host` was given. Returns `false` (run locally) when
|
|
82
|
+
* there is no `--host`, the command isn't in the table, or the target is this
|
|
83
|
+
* very machine.
|
|
84
|
+
*
|
|
85
|
+
* @param command the resolved subcommand name (`process.argv`'s first non-flag).
|
|
86
|
+
* @param allArgs `process.argv.slice(2)` — the command name followed by its args.
|
|
87
|
+
*/
|
|
88
|
+
export async function maybeRunOnHost(command, allArgs) {
|
|
89
|
+
const spec = REMOTE_PASSTHROUGH[command];
|
|
90
|
+
if (!spec)
|
|
91
|
+
return false;
|
|
92
|
+
const hostName = flagValue(allArgs, 'host', 'H');
|
|
93
|
+
if (!hostName)
|
|
94
|
+
return false;
|
|
95
|
+
// Running against your own machine is just a local run — skip the SSH round-trip.
|
|
96
|
+
// `machineId()` is the same self-identifier the device registry and session
|
|
97
|
+
// sync use (lowercased short hostname); compare case-insensitively.
|
|
98
|
+
if (hostName.toLowerCase() === machineId())
|
|
99
|
+
return false;
|
|
100
|
+
const remoteCwd = flagValue(allArgs, 'remote-cwd');
|
|
101
|
+
const any = allArgs.includes('--any');
|
|
102
|
+
let host;
|
|
103
|
+
try {
|
|
104
|
+
host = await resolveTargetHost(hostName, any);
|
|
105
|
+
}
|
|
106
|
+
catch (e) {
|
|
107
|
+
console.error(chalk.red(e instanceof Error ? e.message : String(e)));
|
|
108
|
+
process.exitCode = 1;
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
const target = sshTargetFor(host);
|
|
112
|
+
// Interactive only when our own stdout is a terminal and the caller didn't opt
|
|
113
|
+
// out — otherwise force the command's non-interactive path so no half-drawn
|
|
114
|
+
// picker is piped into a file or another program.
|
|
115
|
+
const interactive = !!process.stdout.isTTY && !allArgs.includes('--no-tty');
|
|
116
|
+
let forwarded = stripRoutingFlags(allArgs, STRIP_SPECS);
|
|
117
|
+
if (!interactive && spec.nonInteractive)
|
|
118
|
+
forwarded = [...forwarded, ...spec.nonInteractive];
|
|
119
|
+
// The one long-running case: keep the remote team supervisor alive past a
|
|
120
|
+
// disconnect by dispatching it detached (nohup), still streaming live.
|
|
121
|
+
const isWatchedTeamStart = command === 'teams' && forwarded[1] === 'start' && forwarded.includes('--watch');
|
|
122
|
+
if (isWatchedTeamStart) {
|
|
123
|
+
try {
|
|
124
|
+
const { exitCode } = await dispatchAgentsCommand(host, { forwardedArgs: forwarded, remoteCwd });
|
|
125
|
+
process.exitCode = exitCode && exitCode > 0 ? exitCode : 0;
|
|
126
|
+
}
|
|
127
|
+
catch (e) {
|
|
128
|
+
console.error(chalk.red(e instanceof Error ? e.message : String(e)));
|
|
129
|
+
process.exitCode = 1;
|
|
130
|
+
}
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
const remoteCmd = buildRemoteAgentsInvocation(forwarded, remoteCwd);
|
|
134
|
+
const code = sshStream(target, remoteCmd, { tty: interactive, multiplex: true });
|
|
135
|
+
if (code === 255) {
|
|
136
|
+
console.error(chalk.red(`${host.name}: unreachable over SSH (asleep, offline, or host key changed?).`) +
|
|
137
|
+
chalk.gray(' Check: agents hosts check ' + host.name));
|
|
138
|
+
}
|
|
139
|
+
process.exitCode = code;
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure argv helpers for `--host` passthrough — build the remote `agents …`
|
|
3
|
+
* invocation and strip the local-only routing flags before forwarding.
|
|
4
|
+
*
|
|
5
|
+
* Kept free of any SSH/process side effects so the two-layer quoting and the
|
|
6
|
+
* flag-stripping edge cases (glued short forms, `=value`, variadic) are unit
|
|
7
|
+
* testable without a live host. The transport itself lives in `ssh-exec.ts`
|
|
8
|
+
* (`sshExec`/`sshStream`); orchestration lives in `passthrough.ts`.
|
|
9
|
+
*/
|
|
10
|
+
/** A flag to strip from a forwarded argv, with whether it consumes a value. */
|
|
11
|
+
export interface StripSpec {
|
|
12
|
+
/** Long form without leading dashes, e.g. `host`, `remote-cwd`. */
|
|
13
|
+
long: string;
|
|
14
|
+
/** Optional single-letter short form without the dash, e.g. `H`. */
|
|
15
|
+
short?: string;
|
|
16
|
+
/** True when the flag takes a following value token (`--host <name>`). */
|
|
17
|
+
takesValue: boolean;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Remove routing flags (and their values) from a command's args, leaving the
|
|
21
|
+
* rest untouched and in order so they forward verbatim to the remote binary.
|
|
22
|
+
* Handles every form commander accepts: `--host h`, `--host=h`, `-H h`, `-H=h`,
|
|
23
|
+
* and the glued short form `-Hh`.
|
|
24
|
+
*
|
|
25
|
+
* @param args the command's args (already past the command name).
|
|
26
|
+
*/
|
|
27
|
+
export declare function stripRoutingFlags(args: string[], specs: StripSpec[]): string[];
|
|
28
|
+
/** The routing flags every `--host`-capable command shares. */
|
|
29
|
+
export declare const HOST_ROUTING_SPECS: StripSpec[];
|
|
30
|
+
/**
|
|
31
|
+
* Build the single command string for `ssh <target> <cmd>`. The forwarded args
|
|
32
|
+
* are quoted for the inner login shell, then the whole `agents …` invocation is
|
|
33
|
+
* quoted again so it survives `bash -lc <...>` — `bash -lc` so the remote login
|
|
34
|
+
* PATH resolves `agents`. An optional `cd` runs first for `--remote-cwd`.
|
|
35
|
+
*/
|
|
36
|
+
export declare function buildRemoteAgentsInvocation(forwardedArgs: string[], remoteCwd?: string): string;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure argv helpers for `--host` passthrough — build the remote `agents …`
|
|
3
|
+
* invocation and strip the local-only routing flags before forwarding.
|
|
4
|
+
*
|
|
5
|
+
* Kept free of any SSH/process side effects so the two-layer quoting and the
|
|
6
|
+
* flag-stripping edge cases (glued short forms, `=value`, variadic) are unit
|
|
7
|
+
* testable without a live host. The transport itself lives in `ssh-exec.ts`
|
|
8
|
+
* (`sshExec`/`sshStream`); orchestration lives in `passthrough.ts`.
|
|
9
|
+
*/
|
|
10
|
+
import { shellQuote } from '../ssh-exec.js';
|
|
11
|
+
/**
|
|
12
|
+
* Remove routing flags (and their values) from a command's args, leaving the
|
|
13
|
+
* rest untouched and in order so they forward verbatim to the remote binary.
|
|
14
|
+
* Handles every form commander accepts: `--host h`, `--host=h`, `-H h`, `-H=h`,
|
|
15
|
+
* and the glued short form `-Hh`.
|
|
16
|
+
*
|
|
17
|
+
* @param args the command's args (already past the command name).
|
|
18
|
+
*/
|
|
19
|
+
export function stripRoutingFlags(args, specs) {
|
|
20
|
+
const out = [];
|
|
21
|
+
for (let i = 0; i < args.length; i++) {
|
|
22
|
+
const a = args[i];
|
|
23
|
+
const spec = specs.find((s) => {
|
|
24
|
+
if (a === `--${s.long}` || a.startsWith(`--${s.long}=`))
|
|
25
|
+
return true;
|
|
26
|
+
if (s.short && (a === `-${s.short}` || a.startsWith(`-${s.short}=`) || new RegExp(`^-${s.short}.+`).test(a)))
|
|
27
|
+
return true;
|
|
28
|
+
return false;
|
|
29
|
+
});
|
|
30
|
+
if (!spec) {
|
|
31
|
+
out.push(a);
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
// Consume a separate value token only for the exact-match (space-separated) forms.
|
|
35
|
+
const isExact = a === `--${spec.long}` || (spec.short && a === `-${spec.short}`);
|
|
36
|
+
if (spec.takesValue && isExact && i + 1 < args.length)
|
|
37
|
+
i++;
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
/** The routing flags every `--host`-capable command shares. */
|
|
42
|
+
export const HOST_ROUTING_SPECS = [
|
|
43
|
+
{ long: 'host', short: 'H', takesValue: true },
|
|
44
|
+
{ long: 'remote-cwd', takesValue: true },
|
|
45
|
+
];
|
|
46
|
+
/**
|
|
47
|
+
* Build the single command string for `ssh <target> <cmd>`. The forwarded args
|
|
48
|
+
* are quoted for the inner login shell, then the whole `agents …` invocation is
|
|
49
|
+
* quoted again so it survives `bash -lc <...>` — `bash -lc` so the remote login
|
|
50
|
+
* PATH resolves `agents`. An optional `cd` runs first for `--remote-cwd`.
|
|
51
|
+
*/
|
|
52
|
+
export function buildRemoteAgentsInvocation(forwardedArgs, remoteCwd) {
|
|
53
|
+
const inner = ['agents', ...forwardedArgs].map(shellQuote).join(' ');
|
|
54
|
+
const withCwd = remoteCwd ? `cd ${shellQuote(remoteCwd)} && ${inner}` : inner;
|
|
55
|
+
return `bash -lc ${shellQuote(withCwd)}`;
|
|
56
|
+
}
|
|
Binary file
|
|
@@ -4,12 +4,41 @@ export interface WinPathResult {
|
|
|
4
4
|
alreadyPresent?: boolean;
|
|
5
5
|
error?: string;
|
|
6
6
|
}
|
|
7
|
+
/**
|
|
8
|
+
* Compute the new User PATH from the RAW (unexpanded) current value. Pure and
|
|
9
|
+
* OS-independent — the single source of truth for the prepend/dedup logic.
|
|
10
|
+
*
|
|
11
|
+
* Idempotent: returns `{ changed: false }` (value unchanged, verbatim) when
|
|
12
|
+
* `dir` is already the first `;`-split entry. Otherwise removes every existing
|
|
13
|
+
* occurrence of `dir` and prepends it, dropping empty segments — matching POSIX
|
|
14
|
+
* `export PATH="${dir}:$PATH"`. `%VAR%` segments are preserved verbatim (never
|
|
15
|
+
* expanded), which is the #308 regression this fix targets.
|
|
16
|
+
*/
|
|
17
|
+
export declare function computeNewUserPath(currentRaw: string, dir: string): {
|
|
18
|
+
changed: boolean;
|
|
19
|
+
value: string;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Decide whether to write the User PATH back as REG_EXPAND_SZ (ExpandString) vs
|
|
23
|
+
* REG_SZ (String). Pure — testable on any host.
|
|
24
|
+
*
|
|
25
|
+
* True (expandable) when the original value was already ExpandString, when the
|
|
26
|
+
* raw value contains a `%VAR%` reference, or when `Path` was absent (default to
|
|
27
|
+
* ExpandString — Windows' native Path type). Only a plain String value with no
|
|
28
|
+
* `%` stays REG_SZ. `originalKind` is the .NET RegistryValueKind name
|
|
29
|
+
* (`ExpandString`/`String`/…) or `null`/`Absent` when `Path` had no value.
|
|
30
|
+
*/
|
|
31
|
+
export declare function shouldWriteExpandable(originalKind: string | null, rawValue: string): boolean;
|
|
7
32
|
/**
|
|
8
33
|
* Prepend `dir` to the Windows User PATH. Idempotent: a no-op when `dir` is
|
|
9
34
|
* already first; moves it to the front when it exists but is positioned later
|
|
10
35
|
* (e.g. appended by an older install) so it overrides conflicting entries.
|
|
11
|
-
*
|
|
12
|
-
*
|
|
36
|
+
*
|
|
37
|
+
* Reads the RAW registry value (preserving `%VAR%` and the REG_EXPAND_SZ type),
|
|
38
|
+
* computes the new value in TS via `computeNewUserPath`, and only writes when
|
|
39
|
+
* the value actually changes — preserving the original value type and
|
|
40
|
+
* broadcasting WM_SETTINGCHANGE. `dir` and the computed value are passed via env
|
|
41
|
+
* vars so they are never interpolated into the script text.
|
|
13
42
|
*/
|
|
14
43
|
export declare function prependToWindowsUserPath(dir: string): WinPathResult;
|
|
15
44
|
/**
|
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Windows User PATH + execution-policy primitives.
|
|
3
3
|
*
|
|
4
|
-
* The single place that mutates the Windows User PATH
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* The single place that mutates the Windows User PATH. It reads and writes the
|
|
5
|
+
* RAW registry value via `Microsoft.Win32.Registry` (NOT the .NET
|
|
6
|
+
* `[Environment]::*Environment*Variable` API, which expands `%VAR%` references
|
|
7
|
+
* on read and downgrades REG_EXPAND_SZ to REG_SZ on write — issue #308,
|
|
8
|
+
* dotnet/runtime#89695 / #1442). The prepend/dedup itself is computed in TS
|
|
9
|
+
* (`computeNewUserPath`, the single source of truth) so it has unit coverage on
|
|
10
|
+
* every OS; PowerShell is used only for the registry primitives. Because a raw
|
|
11
|
+
* `SetValue` does NOT broadcast the change (the old `[Environment]` API did), the
|
|
12
|
+
* write script broadcasts WM_SETTINGCHANGE itself so a new terminal picks up the
|
|
13
|
+
* PATH without re-login.
|
|
7
14
|
* Consumers: `shims.ts` (shims dir) and `scripts/postinstall.js` (npm global-bin
|
|
8
15
|
* dir, so the `agents` command itself resolves).
|
|
9
16
|
*
|
|
@@ -12,34 +19,136 @@
|
|
|
12
19
|
*/
|
|
13
20
|
import { execFileSync } from 'child_process';
|
|
14
21
|
import * as path from 'path';
|
|
22
|
+
/**
|
|
23
|
+
* Compute the new User PATH from the RAW (unexpanded) current value. Pure and
|
|
24
|
+
* OS-independent — the single source of truth for the prepend/dedup logic.
|
|
25
|
+
*
|
|
26
|
+
* Idempotent: returns `{ changed: false }` (value unchanged, verbatim) when
|
|
27
|
+
* `dir` is already the first `;`-split entry. Otherwise removes every existing
|
|
28
|
+
* occurrence of `dir` and prepends it, dropping empty segments — matching POSIX
|
|
29
|
+
* `export PATH="${dir}:$PATH"`. `%VAR%` segments are preserved verbatim (never
|
|
30
|
+
* expanded), which is the #308 regression this fix targets.
|
|
31
|
+
*/
|
|
32
|
+
export function computeNewUserPath(currentRaw, dir) {
|
|
33
|
+
const parts = currentRaw.split(';').filter((p) => p !== '');
|
|
34
|
+
if (parts.length > 0 && parts[0] === dir) {
|
|
35
|
+
return { changed: false, value: currentRaw };
|
|
36
|
+
}
|
|
37
|
+
const others = parts.filter((p) => p !== dir);
|
|
38
|
+
return { changed: true, value: [dir, ...others].join(';') };
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Decide whether to write the User PATH back as REG_EXPAND_SZ (ExpandString) vs
|
|
42
|
+
* REG_SZ (String). Pure — testable on any host.
|
|
43
|
+
*
|
|
44
|
+
* True (expandable) when the original value was already ExpandString, when the
|
|
45
|
+
* raw value contains a `%VAR%` reference, or when `Path` was absent (default to
|
|
46
|
+
* ExpandString — Windows' native Path type). Only a plain String value with no
|
|
47
|
+
* `%` stays REG_SZ. `originalKind` is the .NET RegistryValueKind name
|
|
48
|
+
* (`ExpandString`/`String`/…) or `null`/`Absent` when `Path` had no value.
|
|
49
|
+
*/
|
|
50
|
+
export function shouldWriteExpandable(originalKind, rawValue) {
|
|
51
|
+
if (originalKind === null || originalKind === 'Absent')
|
|
52
|
+
return true;
|
|
53
|
+
if (originalKind === 'ExpandString')
|
|
54
|
+
return true;
|
|
55
|
+
return rawValue.includes('%');
|
|
56
|
+
}
|
|
57
|
+
// Sentinel separating the value kind from the (possibly '%'-laden) raw value in
|
|
58
|
+
// the read script's stdout — PATH entries never contain a newline, so an
|
|
59
|
+
// exclusive line marker parses unambiguously.
|
|
60
|
+
const READ_MARKER = '===AGENTS-PATH-VALUE===';
|
|
61
|
+
// Reads the RAW User PATH preserving REG_EXPAND_SZ: DoNotExpandEnvironmentNames
|
|
62
|
+
// keeps `%VAR%` literal, and GetValueKind reports the original type (throws when
|
|
63
|
+
// 'Path' is absent -> caught, reported as Absent).
|
|
64
|
+
const READ_SCRIPT = [
|
|
65
|
+
"$key = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment', $false)",
|
|
66
|
+
'if ($null -eq $key) {',
|
|
67
|
+
" Write-Output 'KIND:Absent'",
|
|
68
|
+
` Write-Output '${READ_MARKER}'`,
|
|
69
|
+
" Write-Output ''",
|
|
70
|
+
'} else {',
|
|
71
|
+
" try { $kind = $key.GetValueKind('Path').ToString() } catch { $kind = 'Absent' }",
|
|
72
|
+
" $val = $key.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)",
|
|
73
|
+
" Write-Output ('KIND:' + $kind)",
|
|
74
|
+
` Write-Output '${READ_MARKER}'`,
|
|
75
|
+
' Write-Output $val',
|
|
76
|
+
'}',
|
|
77
|
+
].join('\n');
|
|
78
|
+
// Writes the computed value back with the preserved kind and broadcasts
|
|
79
|
+
// WM_SETTINGCHANGE (raw SetValue does not, unlike the old [Environment] API).
|
|
80
|
+
// The value comes in via AGENTS_WINPATH_VALUE so it is never interpolated into
|
|
81
|
+
// the script text (preserves the no-injection property for '%'-laden paths).
|
|
82
|
+
const WRITE_SCRIPT = [
|
|
83
|
+
"$key = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment', $true)",
|
|
84
|
+
"if ($null -eq $key) { $key = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey('Environment') }",
|
|
85
|
+
'$val = $env:AGENTS_WINPATH_VALUE',
|
|
86
|
+
"if ($env:AGENTS_WINPATH_EXPAND -eq '1') {",
|
|
87
|
+
' $kind = [Microsoft.Win32.RegistryValueKind]::ExpandString',
|
|
88
|
+
'} else {',
|
|
89
|
+
' $kind = [Microsoft.Win32.RegistryValueKind]::String',
|
|
90
|
+
'}',
|
|
91
|
+
"$key.SetValue('Path', $val, $kind)",
|
|
92
|
+
'Add-Type @"',
|
|
93
|
+
'using System;',
|
|
94
|
+
'using System.Runtime.InteropServices;',
|
|
95
|
+
'public static class AgentsWinPath {',
|
|
96
|
+
' [DllImport("user32.dll", SetLastError=true, CharSet=CharSet.Auto)]',
|
|
97
|
+
' public static extern IntPtr SendMessageTimeout(IntPtr hWnd, uint Msg, UIntPtr wParam, string lParam, uint fuFlags, uint uTimeout, out UIntPtr lpdwResult);',
|
|
98
|
+
'}',
|
|
99
|
+
'"@',
|
|
100
|
+
'$res = [UIntPtr]::Zero',
|
|
101
|
+
// HWND_BROADCAST=0xffff, WM_SETTINGCHANGE=0x1a, SMTO_ABORTIFHUNG=2, 5s timeout
|
|
102
|
+
"[AgentsWinPath]::SendMessageTimeout([IntPtr]0xffff, 0x1a, [UIntPtr]::Zero, 'Environment', 2, 5000, [ref]$res) | Out-Null",
|
|
103
|
+
"Write-Output 'written'",
|
|
104
|
+
].join('\n');
|
|
105
|
+
function runPowerShell(script, extraEnv) {
|
|
106
|
+
return execFileSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], {
|
|
107
|
+
encoding: 'utf-8',
|
|
108
|
+
env: extraEnv ? { ...process.env, ...extraEnv } : process.env,
|
|
109
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
/** Parse the read script's stdout into the original value kind and RAW value. */
|
|
113
|
+
function parseReadOutput(out) {
|
|
114
|
+
const idx = out.indexOf(READ_MARKER);
|
|
115
|
+
if (idx === -1)
|
|
116
|
+
return { kind: null, raw: '' };
|
|
117
|
+
const head = out.slice(0, idx);
|
|
118
|
+
const kindMatch = head.match(/KIND:(\S+)/);
|
|
119
|
+
const kind = kindMatch ? kindMatch[1] : null;
|
|
120
|
+
// Everything after the marker line, minus the leading/trailing newline PS adds.
|
|
121
|
+
const raw = out
|
|
122
|
+
.slice(idx + READ_MARKER.length)
|
|
123
|
+
.replace(/^\r?\n/, '')
|
|
124
|
+
.replace(/\r?\n$/, '');
|
|
125
|
+
return { kind, raw };
|
|
126
|
+
}
|
|
15
127
|
/**
|
|
16
128
|
* Prepend `dir` to the Windows User PATH. Idempotent: a no-op when `dir` is
|
|
17
129
|
* already first; moves it to the front when it exists but is positioned later
|
|
18
130
|
* (e.g. appended by an older install) so it overrides conflicting entries.
|
|
19
|
-
*
|
|
20
|
-
*
|
|
131
|
+
*
|
|
132
|
+
* Reads the RAW registry value (preserving `%VAR%` and the REG_EXPAND_SZ type),
|
|
133
|
+
* computes the new value in TS via `computeNewUserPath`, and only writes when
|
|
134
|
+
* the value actually changes — preserving the original value type and
|
|
135
|
+
* broadcasting WM_SETTINGCHANGE. `dir` and the computed value are passed via env
|
|
136
|
+
* vars so they are never interpolated into the script text.
|
|
21
137
|
*/
|
|
22
138
|
export function prependToWindowsUserPath(dir) {
|
|
23
|
-
const script = [
|
|
24
|
-
'$d = $env:AGENTS_WINPATH_DIR',
|
|
25
|
-
"$u = [Environment]::GetEnvironmentVariable('Path','User')",
|
|
26
|
-
"if ($null -eq $u) { $u = '' }",
|
|
27
|
-
"$parts = @($u -split ';' | Where-Object { $_ -ne '' })",
|
|
28
|
-
// Already first — nothing to do
|
|
29
|
-
"if ($parts.Count -gt 0 -and $parts[0] -eq $d) { 'present' } else {",
|
|
30
|
-
// Remove any existing occurrence then prepend, matching POSIX `export PATH="${dir}:$PATH"`
|
|
31
|
-
" $newParts = @($d) + @($parts | Where-Object { $_ -ne $d })",
|
|
32
|
-
" [Environment]::SetEnvironmentVariable('Path', ($newParts -join ';'), 'User')",
|
|
33
|
-
" 'added'",
|
|
34
|
-
'}',
|
|
35
|
-
].join('\n');
|
|
36
139
|
try {
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
140
|
+
const readOut = runPowerShell(READ_SCRIPT);
|
|
141
|
+
const { kind, raw } = parseReadOutput(readOut);
|
|
142
|
+
const { changed, value } = computeNewUserPath(raw, dir);
|
|
143
|
+
if (!changed) {
|
|
144
|
+
return { success: true, alreadyPresent: true };
|
|
145
|
+
}
|
|
146
|
+
const expandable = shouldWriteExpandable(kind, value);
|
|
147
|
+
runPowerShell(WRITE_SCRIPT, {
|
|
148
|
+
AGENTS_WINPATH_VALUE: value,
|
|
149
|
+
AGENTS_WINPATH_EXPAND: expandable ? '1' : '0',
|
|
150
|
+
});
|
|
151
|
+
return { success: true, alreadyPresent: false };
|
|
43
152
|
}
|
|
44
153
|
catch (err) {
|
|
45
154
|
return { success: false, error: `Could not update the Windows user PATH: ${err.message}` };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PowerShell `-EncodedCommand` helper.
|
|
3
|
+
*
|
|
4
|
+
* Base64 of a script's UTF-16LE bytes is a single quote-free token, so it rides
|
|
5
|
+
* through Node spawn → Windows sshd → cmd.exe with zero escaping hazards
|
|
6
|
+
* (hand-quoted `powershell -Command "…"` is fragile the moment a path, URL, or
|
|
7
|
+
* newline is involved). Shared by the browser SSH driver (which builds a
|
|
8
|
+
* `powershell -EncodedCommand …` string) and the Windows secrets backend (which
|
|
9
|
+
* spawns powershell.exe with an argv array).
|
|
10
|
+
*/
|
|
11
|
+
export declare function encodePwshBase64(script: string): string;
|
package/dist/lib/pwsh.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PowerShell `-EncodedCommand` helper.
|
|
3
|
+
*
|
|
4
|
+
* Base64 of a script's UTF-16LE bytes is a single quote-free token, so it rides
|
|
5
|
+
* through Node spawn → Windows sshd → cmd.exe with zero escaping hazards
|
|
6
|
+
* (hand-quoted `powershell -Command "…"` is fragile the moment a path, URL, or
|
|
7
|
+
* newline is involved). Shared by the browser SSH driver (which builds a
|
|
8
|
+
* `powershell -EncodedCommand …` string) and the Windows secrets backend (which
|
|
9
|
+
* spawns powershell.exe with an argv array).
|
|
10
|
+
*/
|
|
11
|
+
export function encodePwshBase64(script) {
|
|
12
|
+
return Buffer.from(script, 'utf16le').toString('base64');
|
|
13
|
+
}
|
|
Binary file
|
|
Binary file
|
|
@@ -25,6 +25,19 @@
|
|
|
25
25
|
import type { SecretsBundle } from './bundles.js';
|
|
26
26
|
/** Default lifetime of an unlocked bundle when `--ttl` is not given. */
|
|
27
27
|
export declare const DEFAULT_TTL_MS: number;
|
|
28
|
+
/**
|
|
29
|
+
* Reserved store-key prefix for the `secrets list` metadata snapshot cache.
|
|
30
|
+
* The broker holds the resolved bundle-metadata array (names/policy/timestamps,
|
|
31
|
+
* NO resolved secret values beyond the literals already in metadata) keyed by a
|
|
32
|
+
* hash of the current keychain bundle name-set, so the second and later
|
|
33
|
+
* `secrets list` within the daily window read metadata without a Touch ID
|
|
34
|
+
* prompt. Keyed by the name-set hash so adding/removing/renaming a bundle
|
|
35
|
+
* changes the key and misses the cache automatically — no active invalidation.
|
|
36
|
+
* The '!' sentinel can never collide with a real bundle name
|
|
37
|
+
* (BUNDLE_NAME_PATTERN requires an alphanumeric first char) and is safe as
|
|
38
|
+
* spawnSync argv (unlike a NUL byte); `status` hides these entries.
|
|
39
|
+
*/
|
|
40
|
+
export declare const META_CACHE_PREFIX = "!meta:";
|
|
28
41
|
/**
|
|
29
42
|
* Decide whether a persistent broker should self-heal onto freshly-installed
|
|
30
43
|
* code (exit so launchd relaunches it). Only when the store is EMPTY: exiting
|
|
@@ -119,6 +132,14 @@ export type Response = {
|
|
|
119
132
|
* unit-testable with a controlled `now`, without a socket or a spawned process.
|
|
120
133
|
* Mutates `store` in place; returns the wire response.
|
|
121
134
|
*/
|
|
135
|
+
/**
|
|
136
|
+
* Count of real unlocked bundles in the store, excluding the internal
|
|
137
|
+
* `secrets list` metadata cache. Used to decide broker "warmth" for self-heal
|
|
138
|
+
* and idle-exit: a metadata-only store must read as empty so a disposable list
|
|
139
|
+
* cache never blocks an upgrade restart (#435) or an idle one-off broker from
|
|
140
|
+
* exiting. Pure + exported for unit testing.
|
|
141
|
+
*/
|
|
142
|
+
export declare function realBundleCount(store: Map<string, StoredBundle>): number;
|
|
122
143
|
export declare function handleAgentRequest(store: Map<string, StoredBundle>, req: Request, now?: number): Response;
|
|
123
144
|
/**
|
|
124
145
|
* Run the broker in the foreground. Spawned detached by ensureAgentRunning via
|
|
@@ -140,6 +161,22 @@ export declare function agentGetSync(name: string): {
|
|
|
140
161
|
bundle: SecretsBundle;
|
|
141
162
|
env: Record<string, string>;
|
|
142
163
|
} | null;
|
|
164
|
+
/**
|
|
165
|
+
* Read the cached `secrets list` metadata snapshot for the given keychain
|
|
166
|
+
* name-set hash, or null on miss / no broker / off-darwin. Reuses the value
|
|
167
|
+
* fast-path socket read (agentGetSync) — no prompt, no wire change. The hash is
|
|
168
|
+
* the cache key: a changed name-set (bundle added/removed/renamed) yields a
|
|
169
|
+
* different key and therefore a clean miss, so the stale set is never served.
|
|
170
|
+
*/
|
|
171
|
+
export declare function agentGetMetaSync(nameSetHash: string): SecretsBundle[] | null;
|
|
172
|
+
/**
|
|
173
|
+
* Fire-and-forget: populate the broker with a freshly-read metadata snapshot so
|
|
174
|
+
* the next `secrets list` within the daily window renders without a prompt.
|
|
175
|
+
* Stored as an ordinary entry (placeholder bundle, snapshot in env) under the
|
|
176
|
+
* reserved META_CACHE_PREFIX key; the snapshot travels over stdin to the
|
|
177
|
+
* detached worker (never argv/disk), same as value caching. macOS only.
|
|
178
|
+
*/
|
|
179
|
+
export declare function agentAutoLoadMetaSync(nameSetHash: string, bundles: SecretsBundle[], ttlMs: number): void;
|
|
143
180
|
/** True unless `secrets.agent.auto` is explicitly disabled in agents.yaml. The
|
|
144
181
|
* broker is the mechanism that delivers the `daily` default policy (one Touch ID
|
|
145
182
|
* per ~24h), so auto-caching is ON by default; opt out with
|
|
@@ -171,7 +208,11 @@ export declare function agentLoad(name: string, bundle: SecretsBundle, env: Reco
|
|
|
171
208
|
/** Wipe one bundle (or all if name omitted) from the broker. Returns the count
|
|
172
209
|
* wiped, or 0 when no broker is running. */
|
|
173
210
|
export declare function agentLock(name?: string): Promise<number>;
|
|
174
|
-
/** List currently-unlocked bundles, or [] when no broker is running.
|
|
211
|
+
/** List currently-unlocked bundles, or [] when no broker is running. The
|
|
212
|
+
* internal `secrets list` metadata-cache entry is filtered out here as well as
|
|
213
|
+
* server-side: during a rollout a NEW client can talk to an OLD broker that
|
|
214
|
+
* predates the server-side exclusion, so this keeps the internal entry from
|
|
215
|
+
* surfacing in `agents secrets status` in that skew window. */
|
|
175
216
|
export declare function agentStatus(): Promise<AgentStatusEntry[]>;
|
|
176
217
|
/**
|
|
177
218
|
* Ensure a broker is running and reachable. Returns true once the socket answers
|