@phnx-labs/agents-cli 1.20.33 → 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/computer.d.ts +23 -0
- package/dist/commands/computer.js +45 -3
- package/dist/commands/doctor.d.ts +10 -0
- package/dist/commands/doctor.js +49 -0
- package/dist/commands/import.js +1 -1
- package/dist/commands/rules.js +1 -1
- 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/status.d.ts +12 -0
- package/dist/commands/status.js +81 -0
- package/dist/commands/teams.js +70 -6
- package/dist/commands/versions.js +2 -1
- package/dist/commands/view.d.ts +39 -0
- package/dist/commands/view.js +194 -75
- package/dist/index.js +4 -2
- package/dist/lib/acp/harnesses.d.ts +1 -1
- package/dist/lib/acp/harnesses.js +2 -2
- 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/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/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 +40 -9
- package/dist/lib/secrets/filestore.js +31 -1
- package/dist/lib/secrets/index.d.ts +33 -1
- package/dist/lib/secrets/index.js +90 -9
- package/dist/lib/secrets/windows.d.ts +74 -0
- package/dist/lib/secrets/windows.js +440 -0
- package/dist/lib/shims.d.ts +20 -0
- package/dist/lib/shims.js +53 -20
- 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/package.json +1 -1
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive drift-sync flow — the single "we detected drift, want to fix it?"
|
|
3
|
+
* action, shared by `agents status`, `agents doctor`, and the menu-bar "NEEDS
|
|
4
|
+
* SYNC" row.
|
|
5
|
+
*
|
|
6
|
+
* It composes existing pieces, re-implementing nothing:
|
|
7
|
+
* - computeSyncStatus() — the unified detection engine (sync-status.ts)
|
|
8
|
+
* - pullRepo() — fast-forward the `.system` repo (git.ts)
|
|
9
|
+
* - promptAgentVersionSelection() — the "which agent types / versions?" picker
|
|
10
|
+
* - heal({ mode: 'full' }) — the reconcile engine `doctor --fix` uses
|
|
11
|
+
*
|
|
12
|
+
* Combined flow (one confirmation): if `.system` is behind AND resources drifted,
|
|
13
|
+
* a single "Sync all detected" both pulls `.system` and reconciles the chosen
|
|
14
|
+
* version homes. The security posture is preserved — the `.system` pull only ever
|
|
15
|
+
* happens on an explicit user choice here, never silently (see auto-pull-worker.ts
|
|
16
|
+
* for why system auto-pull is off by default).
|
|
17
|
+
*/
|
|
18
|
+
import chalk from 'chalk';
|
|
19
|
+
import { select, confirm } from '@inquirer/prompts';
|
|
20
|
+
import { AGENTS } from './agents.js';
|
|
21
|
+
import { pullRepo } from './git.js';
|
|
22
|
+
import { heal } from './heal.js';
|
|
23
|
+
import { promptAgentVersionSelection } from './versions.js';
|
|
24
|
+
import { isInteractiveTerminal, isPromptCancelled } from '../commands/utils.js';
|
|
25
|
+
import { computeSyncStatus, } from './sync-status.js';
|
|
26
|
+
const agentName = (id) => AGENTS[id]?.name ?? id;
|
|
27
|
+
/** "claude@2.1.170 2 drifted · 1 missing" for one version. */
|
|
28
|
+
function versionLine(v) {
|
|
29
|
+
const bits = [];
|
|
30
|
+
if (v.counts.drifted)
|
|
31
|
+
bits.push(`${v.counts.drifted} drifted`);
|
|
32
|
+
if (v.counts.missing)
|
|
33
|
+
bits.push(`${v.counts.missing} missing`);
|
|
34
|
+
const label = `${agentName(v.agent)}@${v.version}`;
|
|
35
|
+
return ` ${label.padEnd(28)} ${chalk.yellow(bits.join(' · '))}`;
|
|
36
|
+
}
|
|
37
|
+
/** Render the drift summary (system freshness + each version owed a sync). */
|
|
38
|
+
function renderSummary(status, needing) {
|
|
39
|
+
console.log(chalk.bold('\nSync status'));
|
|
40
|
+
if (status.system.behind > 0) {
|
|
41
|
+
console.log(` ${'.system repo'.padEnd(28)} ${chalk.yellow(`${status.system.behind} commit${status.system.behind === 1 ? '' : 's'} behind`)} ${chalk.gray('— pull recommended')}`);
|
|
42
|
+
}
|
|
43
|
+
for (const v of needing)
|
|
44
|
+
console.log(versionLine(v));
|
|
45
|
+
if (status.totals.orphan > 0) {
|
|
46
|
+
console.log(chalk.gray(` (${status.totals.orphan} orphan${status.totals.orphan === 1 ? '' : 's'} — run \`agents prune cleanup\`)`));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** Fast-forward the `.system` repo. Returns whether it actually moved. */
|
|
50
|
+
async function pullSystem(status) {
|
|
51
|
+
if (status.system.behind <= 0)
|
|
52
|
+
return false;
|
|
53
|
+
const res = await pullRepo(status.system.dir);
|
|
54
|
+
if (res.success) {
|
|
55
|
+
console.log(chalk.green(`Pulled .system (+${status.system.behind}).`));
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
console.log(chalk.red(`Could not pull .system: ${res.error ?? 'unknown error'}`));
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
/** Reconcile a set of versions grouped by agent via the shared heal engine. */
|
|
62
|
+
async function healVersions(versionsByAgent, cwd) {
|
|
63
|
+
const out = [];
|
|
64
|
+
for (const [agent, versions] of versionsByAgent) {
|
|
65
|
+
if (versions.length === 0)
|
|
66
|
+
continue;
|
|
67
|
+
const res = await heal({ mode: 'full', cwd, agent, versions });
|
|
68
|
+
out.push(...res.versions);
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
/** Report which agents received what after a heal. */
|
|
73
|
+
function reportHealed(healed) {
|
|
74
|
+
const touched = healed.filter((v) => v.healed.length > 0);
|
|
75
|
+
if (touched.length === 0) {
|
|
76
|
+
console.log(chalk.gray('Nothing to reconcile — homes already matched sources.'));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const total = touched.reduce((n, v) => n + v.healed.length, 0);
|
|
80
|
+
const agents = [...new Set(touched.map((v) => agentName(v.agent)))].join(', ');
|
|
81
|
+
console.log(chalk.green(`Synced ${total} resource${total === 1 ? '' : 's'} to ${agents}.`));
|
|
82
|
+
}
|
|
83
|
+
function groupNeeding(needing) {
|
|
84
|
+
const m = new Map();
|
|
85
|
+
for (const v of needing) {
|
|
86
|
+
const list = m.get(v.agent) ?? [];
|
|
87
|
+
list.push(v.version);
|
|
88
|
+
m.set(v.agent, list);
|
|
89
|
+
}
|
|
90
|
+
return m;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The unified "drift detected — sync now?" flow. Returns a structured result so
|
|
94
|
+
* callers (menu-bar, doctor) can report without re-scanning.
|
|
95
|
+
*/
|
|
96
|
+
export async function promptDriftSync(opts = {}) {
|
|
97
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
98
|
+
const status = opts.status ?? (await computeSyncStatus({ cwd }));
|
|
99
|
+
const needing = status.agents.filter((a) => a.needsSync);
|
|
100
|
+
const systemBehind = status.system.behind;
|
|
101
|
+
const base = {
|
|
102
|
+
systemBehindBefore: systemBehind,
|
|
103
|
+
systemPulled: false,
|
|
104
|
+
healed: [],
|
|
105
|
+
cancelled: false,
|
|
106
|
+
nothingToDo: false,
|
|
107
|
+
};
|
|
108
|
+
if (systemBehind <= 0 && needing.length === 0) {
|
|
109
|
+
console.log(chalk.green('Everything is in sync.'));
|
|
110
|
+
return { ...base, nothingToDo: true };
|
|
111
|
+
}
|
|
112
|
+
if (!opts.quiet)
|
|
113
|
+
renderSummary(status, needing);
|
|
114
|
+
// Non-interactive OR explicit --yes: reconcile everything detected.
|
|
115
|
+
if (opts.yes || !isInteractiveTerminal()) {
|
|
116
|
+
if (!opts.yes) {
|
|
117
|
+
// Non-TTY without --yes: report, don't act, don't throw.
|
|
118
|
+
console.log(chalk.gray('\nRun `agents status --yes` to sync, or `agents status` in a terminal to choose.'));
|
|
119
|
+
return base;
|
|
120
|
+
}
|
|
121
|
+
const systemPulled = await pullSystem(status);
|
|
122
|
+
const healed = await healVersions(groupNeeding(needing), cwd);
|
|
123
|
+
reportHealed(healed);
|
|
124
|
+
return { ...base, systemPulled, healed };
|
|
125
|
+
}
|
|
126
|
+
// Interactive gate.
|
|
127
|
+
let choice;
|
|
128
|
+
try {
|
|
129
|
+
choice = await select({
|
|
130
|
+
message: 'Sync now?',
|
|
131
|
+
choices: [
|
|
132
|
+
{ name: 'Sync all detected', value: 'all' },
|
|
133
|
+
{ name: 'Choose agents & resources', value: 'choose' },
|
|
134
|
+
{ name: 'No', value: 'no' },
|
|
135
|
+
],
|
|
136
|
+
default: 'all',
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
catch (err) {
|
|
140
|
+
if (isPromptCancelled(err))
|
|
141
|
+
return { ...base, cancelled: true };
|
|
142
|
+
throw err;
|
|
143
|
+
}
|
|
144
|
+
if (choice === 'no')
|
|
145
|
+
return { ...base, cancelled: true };
|
|
146
|
+
if (choice === 'all') {
|
|
147
|
+
const systemPulled = await pullSystem(status);
|
|
148
|
+
const healed = await healVersions(groupNeeding(needing), cwd);
|
|
149
|
+
reportHealed(healed);
|
|
150
|
+
return { ...base, systemPulled, healed };
|
|
151
|
+
}
|
|
152
|
+
// choice === 'choose': optional .system pull, then per-agent/version selection.
|
|
153
|
+
let systemPulled = false;
|
|
154
|
+
if (systemBehind > 0) {
|
|
155
|
+
try {
|
|
156
|
+
const pull = await confirm({ message: `Pull .system (${systemBehind} behind) first?`, default: true });
|
|
157
|
+
if (pull)
|
|
158
|
+
systemPulled = await pullSystem(status);
|
|
159
|
+
}
|
|
160
|
+
catch (err) {
|
|
161
|
+
if (!isPromptCancelled(err))
|
|
162
|
+
throw err;
|
|
163
|
+
return { ...base, systemPulled, cancelled: true };
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const needingAgents = [...new Set(needing.map((a) => a.agent))];
|
|
167
|
+
let selection;
|
|
168
|
+
try {
|
|
169
|
+
selection = await promptAgentVersionSelection(needingAgents);
|
|
170
|
+
}
|
|
171
|
+
catch (err) {
|
|
172
|
+
if (isPromptCancelled(err))
|
|
173
|
+
return { ...base, systemPulled, cancelled: true };
|
|
174
|
+
throw err;
|
|
175
|
+
}
|
|
176
|
+
const healed = await healVersions(selection.versionSelections, cwd);
|
|
177
|
+
reportHealed(healed);
|
|
178
|
+
return { ...base, systemPulled, healed };
|
|
179
|
+
}
|
package/dist/lib/exec.d.ts
CHANGED
|
@@ -189,6 +189,21 @@ export declare function nativeResume(agent: AgentId): boolean;
|
|
|
189
189
|
export declare function buildExecCommand(options: ExecOptions): string[];
|
|
190
190
|
/** Spawn an agent and return its exit code. Convenience wrapper over spawnAgent. */
|
|
191
191
|
export declare function execAgent(options: ExecOptions): Promise<number>;
|
|
192
|
+
/**
|
|
193
|
+
* Resolve how to spawn a shim target for a platform. Pure — testable on any host.
|
|
194
|
+
*
|
|
195
|
+
* POSIX always execs the binary directly (no shell). On Windows a bare
|
|
196
|
+
* (non-absolute) name or a `.cmd` companion goes through the shell so cmd.exe
|
|
197
|
+
* resolves it via PATHEXT — the common, `.cmd`-present path; an absolute `.cmd`
|
|
198
|
+
* or extensionless path is exec'd through the shell / directly. npm always ships
|
|
199
|
+
* a `<cmd>.cmd` companion on Windows, so the runnable target `execShimPassthrough`
|
|
200
|
+
* hands us is the `.cmd` (never a bare `.ps1`).
|
|
201
|
+
*/
|
|
202
|
+
export declare function resolveShimSpawn(platform: NodeJS.Platform, binary: string, extraArgs: string[]): {
|
|
203
|
+
command: string;
|
|
204
|
+
args: string[];
|
|
205
|
+
shell: boolean;
|
|
206
|
+
};
|
|
192
207
|
/**
|
|
193
208
|
* Transparent passthrough exec for generated shims — the node-side delegate that
|
|
194
209
|
* Windows `.cmd` shims call. Resolves the active version (explicit pin, else
|
package/dist/lib/exec.js
CHANGED
|
@@ -374,15 +374,6 @@ export const AGENT_COMMANDS = {
|
|
|
374
374
|
edit: [],
|
|
375
375
|
},
|
|
376
376
|
},
|
|
377
|
-
roo: {
|
|
378
|
-
base: ['roo'],
|
|
379
|
-
promptFlag: 'positional',
|
|
380
|
-
modeFlags: {
|
|
381
|
-
plan: ['--mode', 'architect'],
|
|
382
|
-
edit: ['--mode', 'code'],
|
|
383
|
-
},
|
|
384
|
-
modelFlag: '--model',
|
|
385
|
-
},
|
|
386
377
|
// TODO: --output-format json is documented but currently broken upstream
|
|
387
378
|
// ("flags provided but not defined: -output-format"). Track resolution at
|
|
388
379
|
// https://github.com/google-antigravity/antigravity-cli/issues/7 before
|
|
@@ -665,6 +656,25 @@ export async function execAgent(options) {
|
|
|
665
656
|
const { exitCode } = await spawnAgent(options);
|
|
666
657
|
return exitCode;
|
|
667
658
|
}
|
|
659
|
+
/**
|
|
660
|
+
* Resolve how to spawn a shim target for a platform. Pure — testable on any host.
|
|
661
|
+
*
|
|
662
|
+
* POSIX always execs the binary directly (no shell). On Windows a bare
|
|
663
|
+
* (non-absolute) name or a `.cmd` companion goes through the shell so cmd.exe
|
|
664
|
+
* resolves it via PATHEXT — the common, `.cmd`-present path; an absolute `.cmd`
|
|
665
|
+
* or extensionless path is exec'd through the shell / directly. npm always ships
|
|
666
|
+
* a `<cmd>.cmd` companion on Windows, so the runnable target `execShimPassthrough`
|
|
667
|
+
* hands us is the `.cmd` (never a bare `.ps1`).
|
|
668
|
+
*/
|
|
669
|
+
export function resolveShimSpawn(platform, binary, extraArgs) {
|
|
670
|
+
if (platform === 'win32') {
|
|
671
|
+
// Use win32 path semantics regardless of the host running this (the platform
|
|
672
|
+
// is the parameter, not process.platform) so `C:\...` reads as absolute.
|
|
673
|
+
const useShell = !path.win32.isAbsolute(binary) || binary.endsWith('.cmd');
|
|
674
|
+
return { command: binary, args: extraArgs, shell: useShell };
|
|
675
|
+
}
|
|
676
|
+
return { command: binary, args: extraArgs, shell: false };
|
|
677
|
+
}
|
|
668
678
|
/**
|
|
669
679
|
* Transparent passthrough exec for generated shims — the node-side delegate that
|
|
670
680
|
* Windows `.cmd` shims call. Resolves the active version (explicit pin, else
|
|
@@ -691,9 +701,9 @@ export async function execShimPassthrough(agent, rawArgs, cwd, pinnedVersion) {
|
|
|
691
701
|
// mode/effort are required by ExecOptions but unused by buildExecEnv (which only
|
|
692
702
|
// derives the per-version config-dir env); pass the agent's default to satisfy the type.
|
|
693
703
|
const env = buildExecEnv({ agent, version, cwd, mode: defaultModeFor(agent), effort: 'auto' });
|
|
694
|
-
const
|
|
704
|
+
const { command, args, shell } = resolveShimSpawn(process.platform, binary, [...launchArgs, ...rawArgs]);
|
|
695
705
|
return new Promise((resolve) => {
|
|
696
|
-
const child = spawn(
|
|
706
|
+
const child = spawn(command, args, { cwd, stdio: 'inherit', env, shell });
|
|
697
707
|
child.on('exit', (code, signal) => resolve(code ?? (signal ? 1 : 0)));
|
|
698
708
|
child.on('error', (err) => {
|
|
699
709
|
process.stderr.write(`agents: failed to launch ${agent}: ${err.message}\n`);
|
|
@@ -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
|