@phnx-labs/agents-cli 1.20.33 → 1.20.35
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 +29 -0
- package/README.md +32 -2
- package/dist/commands/computer-actions.d.ts +15 -0
- package/dist/commands/computer-actions.js +120 -22
- package/dist/commands/computer.d.ts +23 -0
- package/dist/commands/computer.js +45 -3
- package/dist/commands/cost.js +17 -12
- package/dist/commands/doctor.d.ts +11 -0
- package/dist/commands/doctor.js +108 -14
- package/dist/commands/events.d.ts +16 -0
- package/dist/commands/events.js +173 -0
- package/dist/commands/exec.js +6 -1
- package/dist/commands/hosts.js +28 -24
- package/dist/commands/import.js +1 -1
- package/dist/commands/inspect.d.ts +2 -0
- package/dist/commands/inspect.js +54 -7
- package/dist/commands/logs.d.ts +17 -0
- package/dist/commands/logs.js +139 -0
- package/dist/commands/models.d.ts +3 -0
- package/dist/commands/models.js +27 -8
- package/dist/commands/permissions.js +9 -2
- package/dist/commands/repo.d.ts +34 -0
- package/dist/commands/repo.js +243 -65
- package/dist/commands/resource-view.d.ts +20 -0
- package/dist/commands/resource-view.js +90 -28
- 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 +93 -17
- package/dist/commands/sessions-resume.d.ts +2 -0
- package/dist/commands/sessions-resume.js +209 -0
- package/dist/commands/sessions-tail.d.ts +10 -0
- package/dist/commands/sessions-tail.js +11 -0
- package/dist/commands/sessions.d.ts +73 -1
- package/dist/commands/sessions.js +273 -53
- 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 +42 -0
- package/dist/commands/view.js +213 -83
- package/dist/commands/wallet.d.ts +6 -0
- package/dist/commands/wallet.js +22 -5
- package/dist/index.js +61 -32
- 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 +145 -41
- package/dist/lib/browser/cdp.js +5 -1
- package/dist/lib/browser/chrome.js +20 -0
- package/dist/lib/browser/drivers/ssh.d.ts +27 -0
- package/dist/lib/browser/drivers/ssh.js +94 -19
- package/dist/lib/browser/ipc.d.ts +3 -0
- package/dist/lib/browser/ipc.js +13 -9
- package/dist/lib/browser/service.js +71 -15
- package/dist/lib/daemon.d.ts +35 -0
- package/dist/lib/daemon.js +33 -5
- package/dist/lib/devices/connect.d.ts +3 -2
- package/dist/lib/devices/connect.js +5 -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/events.d.ts +9 -2
- package/dist/lib/events.js +70 -11
- package/dist/lib/exec.d.ts +15 -0
- package/dist/lib/exec.js +43 -11
- package/dist/lib/hooks.js +8 -2
- package/dist/lib/hosts/dispatch.js +6 -7
- package/dist/lib/hosts/logs.d.ts +16 -0
- package/dist/lib/hosts/logs.js +45 -0
- package/dist/lib/hosts/progress.d.ts +66 -0
- package/dist/lib/hosts/progress.js +125 -17
- package/dist/lib/hosts/ready.d.ts +23 -2
- package/dist/lib/hosts/ready.js +35 -13
- package/dist/lib/hosts/reconcile.d.ts +53 -0
- package/dist/lib/hosts/reconcile.js +81 -0
- package/dist/lib/hosts/tasks.d.ts +8 -0
- package/dist/lib/hosts/tasks.js +14 -0
- package/dist/lib/permissions.d.ts +4 -0
- package/dist/lib/permissions.js +35 -0
- package/dist/lib/picker.d.ts +24 -0
- package/dist/lib/picker.js +124 -0
- package/dist/lib/platform/winpath.d.ts +31 -2
- package/dist/lib/platform/winpath.js +133 -24
- package/dist/lib/plugin-marketplace.d.ts +30 -0
- package/dist/lib/plugin-marketplace.js +215 -2
- package/dist/lib/plugins.d.ts +5 -0
- package/dist/lib/plugins.js +45 -4
- 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/session/active.d.ts +7 -0
- package/dist/lib/session/active.js +11 -3
- package/dist/lib/session/discover.js +94 -0
- package/dist/lib/session/parse.d.ts +6 -0
- package/dist/lib/session/parse.js +283 -2
- package/dist/lib/session/pid-registry.d.ts +21 -0
- package/dist/lib/session/pid-registry.js +80 -0
- package/dist/lib/session/remote-active.d.ts +26 -0
- package/dist/lib/session/remote-active.js +141 -0
- package/dist/lib/session/remote.js +2 -6
- package/dist/lib/session/render.js +1 -1
- package/dist/lib/session/sync/config.d.ts +8 -0
- package/dist/lib/session/sync/config.js +11 -2
- package/dist/lib/session/types.d.ts +1 -1
- package/dist/lib/session/types.js +1 -1
- package/dist/lib/shims.d.ts +30 -2
- package/dist/lib/shims.js +107 -50
- package/dist/lib/ssh-exec.d.ts +26 -3
- package/dist/lib/ssh-exec.js +45 -3
- package/dist/lib/ssh-tunnel.d.ts +24 -5
- package/dist/lib/ssh-tunnel.js +60 -62
- package/dist/lib/staleness/writers/hooks.js +1 -1
- package/dist/lib/startup/command-registry.d.ts +3 -0
- package/dist/lib/startup/command-registry.js +6 -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/teams/registry.js +25 -9
- package/dist/lib/terminal/backends/ghostty.d.ts +6 -0
- package/dist/lib/terminal/backends/ghostty.js +69 -0
- package/dist/lib/terminal/backends/index.d.ts +18 -0
- package/dist/lib/terminal/backends/index.js +29 -0
- package/dist/lib/terminal/backends/iterm.d.ts +6 -0
- package/dist/lib/terminal/backends/iterm.js +62 -0
- package/dist/lib/terminal/backends/tmux.d.ts +14 -0
- package/dist/lib/terminal/backends/tmux.js +23 -0
- package/dist/lib/terminal/engine.d.ts +39 -0
- package/dist/lib/terminal/engine.js +54 -0
- package/dist/lib/terminal/index.d.ts +14 -0
- package/dist/lib/terminal/index.js +5 -0
- package/dist/lib/terminal/policy.d.ts +11 -0
- package/dist/lib/terminal/policy.js +11 -0
- package/dist/lib/terminal/quote.d.ts +11 -0
- package/dist/lib/terminal/quote.js +13 -0
- package/dist/lib/terminal/shell.d.ts +6 -0
- package/dist/lib/terminal/shell.js +23 -0
- package/dist/lib/terminal/transport.d.ts +15 -0
- package/dist/lib/terminal/transport.js +40 -0
- package/dist/lib/terminal/types.d.ts +59 -0
- package/dist/lib/terminal/types.js +13 -0
- package/dist/lib/types.d.ts +27 -1
- package/dist/lib/usage.d.ts +30 -0
- package/dist/lib/usage.js +159 -2
- package/dist/lib/versions.d.ts +7 -3
- package/dist/lib/versions.js +10 -5
- package/dist/lib/whats-new.d.ts +9 -0
- package/dist/lib/whats-new.js +35 -0
- package/package.json +1 -1
package/dist/lib/plugins.js
CHANGED
|
@@ -18,7 +18,7 @@ import { listInstalledVersions, getVersionHomePath } from './versions.js';
|
|
|
18
18
|
import { AGENTS, agentConfigDirName } from './agents.js';
|
|
19
19
|
import { capableAgents, isCapable } from './capabilities.js';
|
|
20
20
|
import { shouldInstallCommandAsSkill, installCommandSkillToVersion } from './command-skills.js';
|
|
21
|
-
import { copyPluginToMarketplace, syncMarketplaceManifest, registerMarketplace, unregisterMarketplace, addPluginToSettings, removePluginFromSettings, removePluginFromMarketplace, marketplaceIsEmpty, removeEmptyMarketplaceDir, isInstalledInMarketplace, marketplaceRoot, discoverMarketplaces, marketplaceNameFor, MARKETPLACE_NAME, PROJECT_MARKETPLACE_NAME, SYSTEM_MARKETPLACE_NAME, } from './plugin-marketplace.js';
|
|
21
|
+
import { copyPluginToMarketplace, syncMarketplaceManifest, registerMarketplace, unregisterMarketplace, addPluginToSettings, removePluginFromSettings, removePluginFromMarketplace, registerDroidInstalledPlugin, unregisterDroidInstalledPlugin, isDroidPluginInstalled, registerCopilotInstalledPlugin, unregisterCopilotInstalledPlugin, marketplaceIsEmpty, removeEmptyMarketplaceDir, isInstalledInMarketplace, marketplaceRoot, discoverMarketplaces, marketplaceNameFor, MARKETPLACE_NAME, PROJECT_MARKETPLACE_NAME, SYSTEM_MARKETPLACE_NAME, } from './plugin-marketplace.js';
|
|
22
22
|
const PLUGIN_MANIFEST_DIR = '.claude-plugin';
|
|
23
23
|
const PLUGIN_MANIFEST_FILE = 'plugin.json';
|
|
24
24
|
const USER_CONFIG_FILE = '.user-config.json';
|
|
@@ -416,7 +416,7 @@ export function marketplaceSpecForName(name, cwd = process.cwd()) {
|
|
|
416
416
|
* carries, not just the user one.
|
|
417
417
|
*/
|
|
418
418
|
function listVersionMarketplaceNames(agent, versionHome) {
|
|
419
|
-
const dir = path.join(versionHome,
|
|
419
|
+
const dir = path.join(versionHome, agentConfigDirName(agent), 'plugins', 'marketplaces');
|
|
420
420
|
if (!fs.existsSync(dir))
|
|
421
421
|
return [];
|
|
422
422
|
try {
|
|
@@ -444,6 +444,11 @@ function listVersionMarketplaceNames(agent, versionHome) {
|
|
|
444
444
|
* MCP servers, bin/, settings.json, and permissions once the plugin lives at the
|
|
445
445
|
* native install path and is marked enabled — see
|
|
446
446
|
* https://code.claude.com/docs/en/plugins.
|
|
447
|
+
*
|
|
448
|
+
* Droid (Factory CLI) reuses this same marketplace layout but additionally
|
|
449
|
+
* requires an installed_plugins.json registry entry and a "local"-source
|
|
450
|
+
* known_marketplaces.json entry before `droid plugin list` will see the plugin
|
|
451
|
+
* (steps handled by registerMarketplace's droid branch + step 5c below).
|
|
447
452
|
*/
|
|
448
453
|
export function syncPluginToVersion(plugin, agent, versionHome, options = {}) {
|
|
449
454
|
const result = {
|
|
@@ -491,9 +496,25 @@ export function syncPluginToVersion(plugin, agent, versionHome, options = {}) {
|
|
|
491
496
|
// .mcp.json, settings.json, permissions/) are only auto-enabled when the
|
|
492
497
|
// caller explicitly opts in. addPluginToSettings does no gating — that moved
|
|
493
498
|
// here, where plugin capabilities are inspected.
|
|
494
|
-
|
|
499
|
+
const enablePlugin = options.allowExecSurfaces === true || !hasPluginExecSurfaces(inspectPluginCapabilities(plugin.root));
|
|
500
|
+
if (enablePlugin) {
|
|
495
501
|
addPluginToSettings(plugin.name, marketplaceName, agent, versionHome);
|
|
496
502
|
}
|
|
503
|
+
// 5c. Droid diverges from Claude's marketplace+enabledPlugins model: it only
|
|
504
|
+
// "sees" a plugin that also has an entry in installed_plugins.json (with
|
|
505
|
+
// the marketplace registered as source "local"). Register the install so
|
|
506
|
+
// `droid plugin list` shows it — Active when enabled, Inactive otherwise.
|
|
507
|
+
if (agent === 'droid') {
|
|
508
|
+
registerDroidInstalledPlugin(plugin.name, marketplaceName, installDir, plugin.manifest.version, agent, versionHome);
|
|
509
|
+
}
|
|
510
|
+
// 5d. Copilot, like Droid, only "sees" a plugin that also has an entry in its
|
|
511
|
+
// auto-managed config.json#installedPlugins (registerMarketplace already
|
|
512
|
+
// wrote settings.json#extraKnownMarketplaces, and addPluginToSettings the
|
|
513
|
+
// enabledPlugins flag). cache_path points at the marketplace copy; the
|
|
514
|
+
// `enabled` flag mirrors the exec-surface trust gate above.
|
|
515
|
+
if (agent === 'copilot') {
|
|
516
|
+
registerCopilotInstalledPlugin(plugin.name, marketplaceName, installDir, plugin.manifest.version, enablePlugin, agent, versionHome);
|
|
517
|
+
}
|
|
497
518
|
// 5b. Convert plugin commands/ to skills for agents that dropped command support
|
|
498
519
|
// (Codex >= 0.117.0). Skill name is prefixed with plugin name to avoid
|
|
499
520
|
// collision with standalone command skills.
|
|
@@ -718,7 +739,15 @@ function migrateLegacyFlatLayout(plugin, agent, versionHome) {
|
|
|
718
739
|
export function isPluginSynced(plugin, agent, versionHome) {
|
|
719
740
|
if (!isCapable(agent, 'plugins'))
|
|
720
741
|
return false;
|
|
721
|
-
|
|
742
|
+
const spec = marketplaceSpecForName(plugin.marketplace);
|
|
743
|
+
if (!isInstalledInMarketplace(plugin.name, spec, agent, versionHome))
|
|
744
|
+
return false;
|
|
745
|
+
// Droid additionally requires its installed_plugins.json registry entry —
|
|
746
|
+
// without it the marketplace copy is invisible to `droid plugin list`.
|
|
747
|
+
if (agent === 'droid') {
|
|
748
|
+
return isDroidPluginInstalled(plugin.name, marketplaceNameFor(spec), agent, versionHome);
|
|
749
|
+
}
|
|
750
|
+
return true;
|
|
722
751
|
}
|
|
723
752
|
// ─── Removal ─────────────────────────────────────────────────────────────────
|
|
724
753
|
/**
|
|
@@ -746,6 +775,12 @@ export function removePluginFromVersion(pluginName, pluginRoot, agent, versionHo
|
|
|
746
775
|
removedAny = true;
|
|
747
776
|
}
|
|
748
777
|
removePluginFromSettings(pluginName, name, agent, versionHome);
|
|
778
|
+
if (agent === 'droid') {
|
|
779
|
+
unregisterDroidInstalledPlugin(pluginName, name, agent, versionHome);
|
|
780
|
+
}
|
|
781
|
+
if (agent === 'copilot') {
|
|
782
|
+
unregisterCopilotInstalledPlugin(pluginName, name, agent, versionHome);
|
|
783
|
+
}
|
|
749
784
|
// Refresh marketplace.json so it reflects what's left under plugins/.
|
|
750
785
|
syncMarketplaceManifest(spec, agent, versionHome);
|
|
751
786
|
// If we just removed the last plugin, drop the marketplace dir and the
|
|
@@ -930,6 +965,12 @@ export function cleanOrphanedPluginSkills(agent, versionHome, activePluginNames,
|
|
|
930
965
|
fs.mkdirSync(trashDir, { recursive: true, mode: 0o700 });
|
|
931
966
|
fs.renameSync(path.join(mktPluginsDir, entry.name), trashDest);
|
|
932
967
|
removePluginFromSettings(entry.name, name, agent, versionHome);
|
|
968
|
+
if (agent === 'droid') {
|
|
969
|
+
unregisterDroidInstalledPlugin(entry.name, name, agent, versionHome);
|
|
970
|
+
}
|
|
971
|
+
if (agent === 'copilot') {
|
|
972
|
+
unregisterCopilotInstalledPlugin(entry.name, name, agent, versionHome);
|
|
973
|
+
}
|
|
933
974
|
removed.push(entry.name);
|
|
934
975
|
trashedHere = true;
|
|
935
976
|
}
|
|
@@ -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
|
|
@@ -36,6 +36,19 @@ import { getCliVersion, getCliVersionFresh } from '../version.js';
|
|
|
36
36
|
const PROTOCOL_VERSION = 1;
|
|
37
37
|
/** Default lifetime of an unlocked bundle when `--ttl` is not given. */
|
|
38
38
|
export const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000; // 24h
|
|
39
|
+
/**
|
|
40
|
+
* Reserved store-key prefix for the `secrets list` metadata snapshot cache.
|
|
41
|
+
* The broker holds the resolved bundle-metadata array (names/policy/timestamps,
|
|
42
|
+
* NO resolved secret values beyond the literals already in metadata) keyed by a
|
|
43
|
+
* hash of the current keychain bundle name-set, so the second and later
|
|
44
|
+
* `secrets list` within the daily window read metadata without a Touch ID
|
|
45
|
+
* prompt. Keyed by the name-set hash so adding/removing/renaming a bundle
|
|
46
|
+
* changes the key and misses the cache automatically — no active invalidation.
|
|
47
|
+
* The '!' sentinel can never collide with a real bundle name
|
|
48
|
+
* (BUNDLE_NAME_PATTERN requires an alphanumeric first char) and is safe as
|
|
49
|
+
* spawnSync argv (unlike a NUL byte); `status` hides these entries.
|
|
50
|
+
*/
|
|
51
|
+
export const META_CACHE_PREFIX = '!meta:';
|
|
39
52
|
/** After the store goes empty (all bundles locked or expired) for this long,
|
|
40
53
|
* the broker exits so no idle process lingers holding a socket. */
|
|
41
54
|
const IDLE_EXIT_MS = 5 * 60 * 1000; // 5m
|
|
@@ -233,6 +246,20 @@ export async function uninstallSecretsAgentService() {
|
|
|
233
246
|
* unit-testable with a controlled `now`, without a socket or a spawned process.
|
|
234
247
|
* Mutates `store` in place; returns the wire response.
|
|
235
248
|
*/
|
|
249
|
+
/**
|
|
250
|
+
* Count of real unlocked bundles in the store, excluding the internal
|
|
251
|
+
* `secrets list` metadata cache. Used to decide broker "warmth" for self-heal
|
|
252
|
+
* and idle-exit: a metadata-only store must read as empty so a disposable list
|
|
253
|
+
* cache never blocks an upgrade restart (#435) or an idle one-off broker from
|
|
254
|
+
* exiting. Pure + exported for unit testing.
|
|
255
|
+
*/
|
|
256
|
+
export function realBundleCount(store) {
|
|
257
|
+
let n = 0;
|
|
258
|
+
for (const name of store.keys())
|
|
259
|
+
if (!name.startsWith(META_CACHE_PREFIX))
|
|
260
|
+
n++;
|
|
261
|
+
return n;
|
|
262
|
+
}
|
|
236
263
|
export function handleAgentRequest(store, req, now = Date.now()) {
|
|
237
264
|
switch (req.cmd) {
|
|
238
265
|
case 'ping':
|
|
@@ -266,6 +293,8 @@ export function handleAgentRequest(store, req, now = Date.now()) {
|
|
|
266
293
|
for (const [name, e] of store) {
|
|
267
294
|
if (now >= e.expiresAt)
|
|
268
295
|
continue;
|
|
296
|
+
if (name.startsWith(META_CACHE_PREFIX))
|
|
297
|
+
continue; // internal list cache, not a user bundle
|
|
269
298
|
entries.push({ name, expiresAt: e.expiresAt, keyCount: Object.keys(e.env).length });
|
|
270
299
|
}
|
|
271
300
|
return { ok: true, cmd: 'status', entries };
|
|
@@ -321,22 +350,28 @@ export async function runSecretsAgent(opts = {}) {
|
|
|
321
350
|
// an in-place upgrade has landed and self-heal onto it. getCliVersion caches
|
|
322
351
|
// this value for the process lifetime; getCliVersionFresh re-reads on disk.
|
|
323
352
|
const runningVersion = getCliVersion();
|
|
353
|
+
// "Warmth" for self-heal / idle-exit counts only real unlocked bundles, NOT
|
|
354
|
+
// the internal `secrets list` metadata cache (#524). Otherwise a 24h-TTL list
|
|
355
|
+
// cache would keep the store non-empty and (a) block the persistent broker
|
|
356
|
+
// from self-healing onto a freshly-installed version for up to a day (#435's
|
|
357
|
+
// gate is size===0), and (b) stop a one-off broker from ever idle-exiting. The
|
|
358
|
+
// metadata cache is a disposable list snapshot — wiping it on upgrade/idle
|
|
359
|
+
// costs at most one extra prompt on the next `secrets list`.
|
|
324
360
|
const sweep = () => {
|
|
325
361
|
const now = Date.now();
|
|
326
362
|
for (const [name, e] of store)
|
|
327
363
|
if (now >= e.expiresAt)
|
|
328
364
|
store.delete(name);
|
|
329
|
-
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
//
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
shouldSelfHealForUpgrade(persistent, store.size, runningVersion, getCliVersionFresh())) {
|
|
365
|
+
const live = realBundleCount(store);
|
|
366
|
+
// Self-heal onto a newer in-place install — but ONLY while no real unlocks
|
|
367
|
+
// are held, so we never wipe live unlocks and force a re-prompt (#435). A
|
|
368
|
+
// metadata-only store still self-heals (the list cache is disposable).
|
|
369
|
+
if (live === 0 &&
|
|
370
|
+
shouldSelfHealForUpgrade(persistent, live, runningVersion, getCliVersionFresh())) {
|
|
336
371
|
shutdown(0); // KeepAlive relaunches on the new code
|
|
337
372
|
return;
|
|
338
373
|
}
|
|
339
|
-
if (
|
|
374
|
+
if (live === 0) {
|
|
340
375
|
if (!persistent && now - emptySince >= IDLE_EXIT_MS)
|
|
341
376
|
shutdown(0);
|
|
342
377
|
}
|
|
@@ -346,7 +381,7 @@ export async function runSecretsAgent(opts = {}) {
|
|
|
346
381
|
};
|
|
347
382
|
const handle = (req) => {
|
|
348
383
|
const resp = handleAgentRequest(store, req);
|
|
349
|
-
if (store
|
|
384
|
+
if (realBundleCount(store) > 0)
|
|
350
385
|
emptySince = Date.now();
|
|
351
386
|
return resp;
|
|
352
387
|
};
|
|
@@ -529,6 +564,44 @@ export function agentGetSync(name) {
|
|
|
529
564
|
return null;
|
|
530
565
|
}
|
|
531
566
|
}
|
|
567
|
+
// Key inside the cached entry's env that holds the JSON metadata snapshot.
|
|
568
|
+
const META_SNAPSHOT_KEY = '__snapshot__';
|
|
569
|
+
/**
|
|
570
|
+
* Read the cached `secrets list` metadata snapshot for the given keychain
|
|
571
|
+
* name-set hash, or null on miss / no broker / off-darwin. Reuses the value
|
|
572
|
+
* fast-path socket read (agentGetSync) — no prompt, no wire change. The hash is
|
|
573
|
+
* the cache key: a changed name-set (bundle added/removed/renamed) yields a
|
|
574
|
+
* different key and therefore a clean miss, so the stale set is never served.
|
|
575
|
+
*/
|
|
576
|
+
export function agentGetMetaSync(nameSetHash) {
|
|
577
|
+
if (!onDarwin())
|
|
578
|
+
return null;
|
|
579
|
+
const hit = agentGetSync(META_CACHE_PREFIX + nameSetHash);
|
|
580
|
+
const raw = hit?.env?.[META_SNAPSHOT_KEY];
|
|
581
|
+
if (!raw)
|
|
582
|
+
return null;
|
|
583
|
+
try {
|
|
584
|
+
const parsed = JSON.parse(raw);
|
|
585
|
+
return Array.isArray(parsed) ? parsed : null;
|
|
586
|
+
}
|
|
587
|
+
catch {
|
|
588
|
+
return null;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* Fire-and-forget: populate the broker with a freshly-read metadata snapshot so
|
|
593
|
+
* the next `secrets list` within the daily window renders without a prompt.
|
|
594
|
+
* Stored as an ordinary entry (placeholder bundle, snapshot in env) under the
|
|
595
|
+
* reserved META_CACHE_PREFIX key; the snapshot travels over stdin to the
|
|
596
|
+
* detached worker (never argv/disk), same as value caching. macOS only.
|
|
597
|
+
*/
|
|
598
|
+
export function agentAutoLoadMetaSync(nameSetHash, bundles, ttlMs) {
|
|
599
|
+
if (!onDarwin())
|
|
600
|
+
return;
|
|
601
|
+
const key = META_CACHE_PREFIX + nameSetHash;
|
|
602
|
+
const placeholder = { name: key, vars: {} };
|
|
603
|
+
agentAutoLoadSync(key, placeholder, { [META_SNAPSHOT_KEY]: JSON.stringify(bundles) }, ttlMs);
|
|
604
|
+
}
|
|
532
605
|
/** True unless `secrets.agent.auto` is explicitly disabled in agents.yaml. The
|
|
533
606
|
* broker is the mechanism that delivers the `daily` default policy (one Touch ID
|
|
534
607
|
* per ~24h), so auto-caching is ON by default; opt out with
|
|
@@ -606,10 +679,15 @@ export async function agentLock(name) {
|
|
|
606
679
|
const r = await request({ cmd: 'lock', name });
|
|
607
680
|
return r?.ok === true && r.cmd === 'lock' ? r.wiped : 0;
|
|
608
681
|
}
|
|
609
|
-
/** List currently-unlocked bundles, or [] when no broker is running.
|
|
682
|
+
/** List currently-unlocked bundles, or [] when no broker is running. The
|
|
683
|
+
* internal `secrets list` metadata-cache entry is filtered out here as well as
|
|
684
|
+
* server-side: during a rollout a NEW client can talk to an OLD broker that
|
|
685
|
+
* predates the server-side exclusion, so this keeps the internal entry from
|
|
686
|
+
* surfacing in `agents secrets status` in that skew window. */
|
|
610
687
|
export async function agentStatus() {
|
|
611
688
|
const r = await request({ cmd: 'status' });
|
|
612
|
-
|
|
689
|
+
const entries = r?.ok === true && r.cmd === 'status' ? r.entries : [];
|
|
690
|
+
return entries.filter((e) => !e.name.startsWith(META_CACHE_PREFIX));
|
|
613
691
|
}
|
|
614
692
|
/** Ping result: whether a broker is reachable + speaking our protocol, and the
|
|
615
693
|
* version of the code it's running (for staleness detection). */
|
|
@@ -21,11 +21,12 @@ import * as fs from 'fs';
|
|
|
21
21
|
import * as os from 'os';
|
|
22
22
|
import * as path from 'path';
|
|
23
23
|
import * as yaml from 'yaml';
|
|
24
|
-
import { deleteKeychainToken, getKeychainToken, getKeychainTokens, hasKeychainToken, keychainUsesFileFallback, listKeychainItems, parseBundleValue, resolveRef, secretsKeychainItem, setKeychainToken, } from './index.js';
|
|
24
|
+
import { deleteKeychainToken, getKeychainToken, getKeychainTokens, hasKeychainToken, isKeychainBackendOverridden, keychainUsesFileFallback, listKeychainItems, parseBundleValue, resolveRef, secretsKeychainItem, setKeychainToken, } from './index.js';
|
|
25
25
|
import { fileStore } from './filestore.js';
|
|
26
26
|
import { emit } from '../events.js';
|
|
27
27
|
import { readMeta } from '../state.js';
|
|
28
|
-
import { agentGetSync, agentAutoLoadSync, secretsAgentAutoEnabled, DEFAULT_TTL_MS } from './agent.js';
|
|
28
|
+
import { agentGetSync, agentAutoLoadSync, agentGetMetaSync, agentAutoLoadMetaSync, secretsAgentAutoEnabled, DEFAULT_TTL_MS } from './agent.js';
|
|
29
|
+
import { createHash } from 'node:crypto';
|
|
29
30
|
const keychainStore = {
|
|
30
31
|
has: hasKeychainToken,
|
|
31
32
|
get: getKeychainToken,
|
|
@@ -398,15 +399,45 @@ export function listBundles() {
|
|
|
398
399
|
.map((s) => s.slice(BUNDLE_META_PREFIX.length))
|
|
399
400
|
.filter((n) => BUNDLE_NAME_PATTERN.test(n));
|
|
400
401
|
if (keychainNames.length > 0) {
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
402
|
+
// Daily-policy fast-path (macOS). Bundle metadata items are biometry-gated,
|
|
403
|
+
// so the getKeychainTokens batch below pops Touch ID on every `secrets
|
|
404
|
+
// list` — the broker/`daily` mechanism only ever covered value reads, not
|
|
405
|
+
// this listing. Serve a broker-cached metadata snapshot when one is held,
|
|
406
|
+
// so only the first list per ~24h prompts. The cache key is a hash of the
|
|
407
|
+
// current keychain name-set (enumerated silently above): add / remove /
|
|
408
|
+
// rename a bundle and the key changes, so the stale snapshot is never
|
|
409
|
+
// served — no active invalidation needed. Values are never cached here;
|
|
410
|
+
// this is metadata only.
|
|
411
|
+
const useAgent = process.env.AGENTS_SECRETS_NO_AGENT !== '1' &&
|
|
412
|
+
!isKeychainBackendOverridden() &&
|
|
413
|
+
secretsAgentAutoEnabled();
|
|
414
|
+
const nameSetHash = createHash('sha256')
|
|
415
|
+
.update([...keychainNames].sort().join('\n'))
|
|
416
|
+
.digest('hex')
|
|
417
|
+
.slice(0, 32);
|
|
418
|
+
const cached = useAgent ? agentGetMetaSync(nameSetHash) : null;
|
|
419
|
+
if (cached) {
|
|
420
|
+
for (const bundle of cached)
|
|
408
421
|
out.push(bundle);
|
|
409
422
|
}
|
|
423
|
+
else {
|
|
424
|
+
const fetched = getKeychainTokens(keychainNames.map(bundleMetaItem));
|
|
425
|
+
const keychainBundles = [];
|
|
426
|
+
for (const name of keychainNames) {
|
|
427
|
+
const json = fetched.get(bundleMetaItem(name));
|
|
428
|
+
if (json === undefined)
|
|
429
|
+
continue;
|
|
430
|
+
const bundle = parseBundleMeta(name, json, 'keychain');
|
|
431
|
+
if (bundle)
|
|
432
|
+
keychainBundles.push(bundle);
|
|
433
|
+
}
|
|
434
|
+
for (const bundle of keychainBundles)
|
|
435
|
+
out.push(bundle);
|
|
436
|
+
// Populate the broker for the rest of the daily window (fire-and-forget).
|
|
437
|
+
if (useAgent && keychainBundles.length > 0) {
|
|
438
|
+
agentAutoLoadMetaSync(nameSetHash, keychainBundles, DEFAULT_TTL_MS);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
410
441
|
}
|
|
411
442
|
}
|
|
412
443
|
// File-backed bundles live in the encrypted-file store. Enumeration is a
|
|
@@ -20,11 +20,12 @@
|
|
|
20
20
|
* item and its keychain twin carry identical names:
|
|
21
21
|
* `agents-cli.bundles.<name>` and `agents-cli.secrets.<bundle>.<key>`.
|
|
22
22
|
*/
|
|
23
|
-
import { execSync } from 'child_process';
|
|
23
|
+
import { execSync, spawnSync } from 'child_process';
|
|
24
24
|
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'crypto';
|
|
25
25
|
import * as fs from 'fs';
|
|
26
26
|
import * as os from 'os';
|
|
27
27
|
import * as path from 'path';
|
|
28
|
+
import { encodePwshBase64 } from '../pwsh.js';
|
|
28
29
|
// ---------- file store location ----------
|
|
29
30
|
let fileDirOverride = null;
|
|
30
31
|
let cachedPassphrase = null;
|
|
@@ -36,7 +37,36 @@ function ensureFileDir() {
|
|
|
36
37
|
fs.mkdirSync(fileDir(), { recursive: true, mode: 0o700 });
|
|
37
38
|
}
|
|
38
39
|
// ---------- passphrase ----------
|
|
40
|
+
/**
|
|
41
|
+
* Windows has no `/dev/tty` and no POSIX `stty`, so the interactive prompt runs
|
|
42
|
+
* through PowerShell's `Read-Host -AsSecureString` (which never echoes). The
|
|
43
|
+
* secure string is marshaled back out and written to stdout, which we capture.
|
|
44
|
+
* If PowerShell cannot run at all, fail with an actionable error rather than
|
|
45
|
+
* letting `fs.openSync('/dev/tty')` throw a raw ENOENT. Reached only on the rare
|
|
46
|
+
* interactive-Windows file-fallback path — the headless service-account case
|
|
47
|
+
* (no TTY) auto-provisions a machine-local key and never gets here.
|
|
48
|
+
*/
|
|
49
|
+
function readPassphraseFromTtyWindows() {
|
|
50
|
+
const script = `
|
|
51
|
+
$ErrorActionPreference = 'Stop'
|
|
52
|
+
$sec = Read-Host -AsSecureString -Prompt 'Enter AGENTS_SECRETS_PASSPHRASE'
|
|
53
|
+
$ptr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($sec)
|
|
54
|
+
try { [Console]::Out.Write([Runtime.InteropServices.Marshal]::PtrToStringBSTR($ptr)) }
|
|
55
|
+
finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($ptr) }
|
|
56
|
+
`;
|
|
57
|
+
// Not -NonInteractive: that flag would suppress the Read-Host prompt itself.
|
|
58
|
+
const res = spawnSync('powershell.exe', ['-NoProfile', '-EncodedCommand', encodePwshBase64(script)], {
|
|
59
|
+
stdio: ['inherit', 'pipe', 'inherit'],
|
|
60
|
+
});
|
|
61
|
+
if (res.error || res.status !== 0) {
|
|
62
|
+
throw new Error('Could not prompt for a passphrase on Windows. Set AGENTS_SECRETS_PASSPHRASE ' +
|
|
63
|
+
'to decrypt the file-backed secret store.');
|
|
64
|
+
}
|
|
65
|
+
return (res.stdout?.toString() ?? '').replace(/\r?\n$/, '');
|
|
66
|
+
}
|
|
39
67
|
function readPassphraseFromTty() {
|
|
68
|
+
if (process.platform === 'win32')
|
|
69
|
+
return readPassphraseFromTtyWindows();
|
|
40
70
|
const fd = fs.openSync('/dev/tty', 'r+');
|
|
41
71
|
let echoDisabled = false;
|
|
42
72
|
try {
|
|
@@ -12,7 +12,10 @@
|
|
|
12
12
|
* Linux: libsecret (GNOME Keyring) via the `secret-tool` CLI. No biometry —
|
|
13
13
|
* items are unlocked when the keyring is open.
|
|
14
14
|
*
|
|
15
|
-
* Windows:
|
|
15
|
+
* Windows: Windows Credential Manager (CRED_TYPE_GENERIC,
|
|
16
|
+
* CRED_PERSIST_LOCAL_MACHINE) via a PowerShell P/Invoke shim, with the same
|
|
17
|
+
* AES-256-GCM encrypted-file fallback used on Linux when the credential store
|
|
18
|
+
* is unreachable (no logon session / no powershell.exe). No biometry.
|
|
16
19
|
*
|
|
17
20
|
* Items are device-local: the biometry access control requires the OS to
|
|
18
21
|
* treat them as bound to this device, so cross-machine propagation goes
|
|
@@ -42,6 +45,19 @@ export declare function parseBundleValue(raw: BundleValue): {
|
|
|
42
45
|
};
|
|
43
46
|
/** Serialize a secret ref back to its `provider:value` string form. */
|
|
44
47
|
export declare function serializeRef(ref: SecretRef): string;
|
|
48
|
+
/**
|
|
49
|
+
* Guard a secret value before it is written to the current platform's primary
|
|
50
|
+
* backend.
|
|
51
|
+
*
|
|
52
|
+
* A value is empty on every platform → always rejected. Embedded newlines are
|
|
53
|
+
* rejected ONLY on darwin: the macOS batch read path (`get-batch`, see
|
|
54
|
+
* getKeychainTokens) is newline-delimited, so a value with a newline would
|
|
55
|
+
* corrupt record framing on read. Linux (secret-tool), Windows (Credential
|
|
56
|
+
* Manager stores the raw UTF-8 blob and emits base64), and the encrypted-file
|
|
57
|
+
* fallback all store raw bytes and round-trip multiline values (PEM / SSH keys)
|
|
58
|
+
* faithfully, so they accept newlines. `platform` is injectable for tests.
|
|
59
|
+
*/
|
|
60
|
+
export declare function assertValueStorable(value: string, platform?: NodeJS.Platform): void;
|
|
45
61
|
/** Build the keychain item name for a profile provider token. */
|
|
46
62
|
export declare function profileKeychainItem(provider: string): string;
|
|
47
63
|
/** Build the keychain item name for a secrets-bundle key. */
|
|
@@ -61,6 +77,11 @@ export interface KeychainBackend {
|
|
|
61
77
|
}
|
|
62
78
|
/** Install a custom keychain backend (test only). Returns the previous backend so callers can restore. */
|
|
63
79
|
export declare function setKeychainBackendForTest(b: KeychainBackend | null): KeychainBackend | null;
|
|
80
|
+
/** True when a test backend is installed (real keychain / biometry bypassed).
|
|
81
|
+
* Callers that gate on the live secrets-agent broker use this to stay hermetic —
|
|
82
|
+
* with an in-memory backend there is no real keychain to dedup, so the broker
|
|
83
|
+
* fast-path must not engage. Always false in production (`backend` is null). */
|
|
84
|
+
export declare function isKeychainBackendOverridden(): boolean;
|
|
64
85
|
/** Check if a keychain/keyring item exists. Never prompts for biometry. */
|
|
65
86
|
export declare function hasKeychainToken(item: string): boolean;
|
|
66
87
|
/**
|
|
@@ -99,6 +120,17 @@ export declare function deleteKeychainToken(item: string): boolean;
|
|
|
99
120
|
export declare function keychainUsesFileFallback(): boolean;
|
|
100
121
|
/** Enumerate keychain/keyring item names starting with the given prefix. */
|
|
101
122
|
export declare function listKeychainItems(prefix: string): string[];
|
|
123
|
+
/**
|
|
124
|
+
* Enumerate ONLY legacy file-based-keychain item names with the given prefix —
|
|
125
|
+
* the items that still carry a pre-migration (trusted-app) ACL and pop a
|
|
126
|
+
* separate auth sheet on read. Items already in the data-protection keychain are
|
|
127
|
+
* excluded (they need no migration). Silent (attributes only, never decrypts).
|
|
128
|
+
*
|
|
129
|
+
* macOS only: on Linux / the test backend there is no separate legacy keychain,
|
|
130
|
+
* so this returns []. Used by `agents secrets migrate-acl` to rewrite only the
|
|
131
|
+
* stragglers instead of every item (which would be a Touch ID storm).
|
|
132
|
+
*/
|
|
133
|
+
export declare function listLegacyKeychainItems(prefix: string): string[];
|
|
102
134
|
/**
|
|
103
135
|
* One-time upgrade for a keychain item that was written by a previous helper
|
|
104
136
|
* generation with a trusted-app ACL. The helper reads the legacy item
|