@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.
Files changed (110) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +28 -2
  3. package/dist/commands/commands.js +3 -3
  4. package/dist/commands/computer-actions.js +1 -0
  5. package/dist/commands/computer.d.ts +23 -0
  6. package/dist/commands/computer.js +45 -3
  7. package/dist/commands/cost.js +2 -2
  8. package/dist/commands/doctor.d.ts +10 -0
  9. package/dist/commands/doctor.js +51 -2
  10. package/dist/commands/exec.js +56 -1
  11. package/dist/commands/hooks.js +3 -3
  12. package/dist/commands/import.js +1 -1
  13. package/dist/commands/inspect.js +13 -17
  14. package/dist/commands/mcp.js +3 -3
  15. package/dist/commands/permissions.js +3 -3
  16. package/dist/commands/rules.js +3 -3
  17. package/dist/commands/secrets-migrate.js +23 -11
  18. package/dist/commands/secrets.d.ts +20 -0
  19. package/dist/commands/secrets.js +53 -1
  20. package/dist/commands/sessions.js +18 -1
  21. package/dist/commands/skills.js +3 -3
  22. package/dist/commands/status.d.ts +12 -0
  23. package/dist/commands/status.js +81 -0
  24. package/dist/commands/sync.js +2 -2
  25. package/dist/commands/teams.js +77 -18
  26. package/dist/commands/usage.js +2 -2
  27. package/dist/commands/utils.d.ts +8 -0
  28. package/dist/commands/utils.js +20 -0
  29. package/dist/commands/versions.js +4 -3
  30. package/dist/commands/view.d.ts +39 -0
  31. package/dist/commands/view.js +217 -84
  32. package/dist/commands/workflows.js +3 -3
  33. package/dist/index.js +16 -2
  34. package/dist/lib/acp/harnesses.d.ts +1 -1
  35. package/dist/lib/acp/harnesses.js +2 -2
  36. package/dist/lib/agent-spec/index.d.ts +18 -0
  37. package/dist/lib/agent-spec/index.js +35 -0
  38. package/dist/lib/agent-spec/primitives.d.ts +28 -0
  39. package/dist/lib/agent-spec/primitives.js +57 -0
  40. package/dist/lib/agent-spec/provider.d.ts +2 -0
  41. package/dist/lib/agent-spec/provider.js +9 -0
  42. package/dist/lib/agent-spec/resolve.d.ts +33 -0
  43. package/dist/lib/agent-spec/resolve.js +174 -0
  44. package/dist/lib/agent-spec/types.d.ts +57 -0
  45. package/dist/lib/agent-spec/types.js +18 -0
  46. package/dist/lib/agents.d.ts +12 -0
  47. package/dist/lib/agents.js +115 -32
  48. package/dist/lib/browser/chrome.js +20 -0
  49. package/dist/lib/browser/drivers/ssh.d.ts +19 -0
  50. package/dist/lib/browser/drivers/ssh.js +18 -3
  51. package/dist/lib/crabbox/cli.d.ts +98 -0
  52. package/dist/lib/crabbox/cli.js +218 -0
  53. package/dist/lib/crabbox/lease.d.ts +41 -0
  54. package/dist/lib/crabbox/lease.js +73 -0
  55. package/dist/lib/crabbox/runtimes.d.ts +57 -0
  56. package/dist/lib/crabbox/runtimes.js +109 -0
  57. package/dist/lib/doctor-diff.js +29 -2
  58. package/dist/lib/drift-sync.d.ts +43 -0
  59. package/dist/lib/drift-sync.js +179 -0
  60. package/dist/lib/exec.d.ts +15 -0
  61. package/dist/lib/exec.js +21 -11
  62. package/dist/lib/hosts/dispatch.d.ts +27 -10
  63. package/dist/lib/hosts/dispatch.js +55 -19
  64. package/dist/lib/hosts/option.d.ts +14 -0
  65. package/dist/lib/hosts/option.js +19 -0
  66. package/dist/lib/hosts/passthrough.d.ts +30 -0
  67. package/dist/lib/hosts/passthrough.js +141 -0
  68. package/dist/lib/hosts/remote-cmd.d.ts +36 -0
  69. package/dist/lib/hosts/remote-cmd.js +56 -0
  70. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  71. package/dist/lib/platform/winpath.d.ts +31 -2
  72. package/dist/lib/platform/winpath.js +133 -24
  73. package/dist/lib/pwsh.d.ts +11 -0
  74. package/dist/lib/pwsh.js +13 -0
  75. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  76. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  77. package/dist/lib/secrets/agent.d.ts +42 -1
  78. package/dist/lib/secrets/agent.js +89 -11
  79. package/dist/lib/secrets/bundles.js +61 -21
  80. package/dist/lib/secrets/filestore.js +31 -1
  81. package/dist/lib/secrets/index.d.ts +44 -1
  82. package/dist/lib/secrets/index.js +108 -10
  83. package/dist/lib/secrets/linux.d.ts +14 -0
  84. package/dist/lib/secrets/linux.js +21 -0
  85. package/dist/lib/secrets/windows.d.ts +74 -0
  86. package/dist/lib/secrets/windows.js +440 -0
  87. package/dist/lib/session/active.d.ts +8 -0
  88. package/dist/lib/session/active.js +18 -1
  89. package/dist/lib/session/provenance.d.ts +56 -0
  90. package/dist/lib/session/provenance.js +157 -0
  91. package/dist/lib/shims.d.ts +20 -0
  92. package/dist/lib/shims.js +53 -20
  93. package/dist/lib/ssh-exec.d.ts +22 -0
  94. package/dist/lib/ssh-exec.js +59 -2
  95. package/dist/lib/ssh-tunnel.d.ts +0 -5
  96. package/dist/lib/ssh-tunnel.js +65 -8
  97. package/dist/lib/startup/command-registry.d.ts +1 -0
  98. package/dist/lib/startup/command-registry.js +2 -0
  99. package/dist/lib/sync-status.d.ts +102 -0
  100. package/dist/lib/sync-status.js +135 -0
  101. package/dist/lib/teams/agents.d.ts +24 -0
  102. package/dist/lib/teams/agents.js +30 -1
  103. package/dist/lib/types.d.ts +20 -1
  104. package/dist/lib/usage.d.ts +30 -0
  105. package/dist/lib/usage.js +159 -2
  106. package/dist/lib/versions.d.ts +2 -4
  107. package/dist/lib/versions.js +7 -20
  108. package/package.json +2 -1
  109. package/dist/lib/agent-spec.d.ts +0 -36
  110. 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
+ }
@@ -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
- * `dir` is passed via an env var so it is never interpolated into the script
12
- * text.
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 via the .NET environment
5
- * API (which writes the registry AND broadcasts WM_SETTINGCHANGE the correct
6
- * analog of editing a shell rc file: no `setx` truncation, no manual step).
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
- * `dir` is passed via an env var so it is never interpolated into the script
20
- * text.
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 out = execFileSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], {
38
- encoding: 'utf-8',
39
- env: { ...process.env, AGENTS_WINPATH_DIR: dir },
40
- stdio: ['ignore', 'pipe', 'pipe'],
41
- }).trim();
42
- return { success: true, alreadyPresent: out.includes('present') };
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;
@@ -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
+ }
@@ -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