@phnx-labs/agents-cli 1.20.18 → 1.20.20
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 +10 -0
- package/dist/commands/secrets.js +137 -25
- package/dist/commands/versions.js +7 -1
- package/dist/lib/secrets/agent.d.ts +37 -6
- package/dist/lib/secrets/agent.js +197 -63
- package/dist/lib/secrets/bundles.d.ts +37 -7
- package/dist/lib/secrets/bundles.js +226 -80
- package/dist/lib/secrets/filestore.d.ts +82 -0
- package/dist/lib/secrets/filestore.js +295 -0
- package/dist/lib/secrets/linux.d.ts +6 -24
- package/dist/lib/secrets/linux.js +22 -265
- package/dist/lib/versions.d.ts +20 -0
- package/dist/lib/versions.js +48 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
**`agents secrets start`: persistent secrets-agent service (fixes the broker under heavy load)**
|
|
6
|
+
|
|
7
|
+
- On a heavily-loaded machine (many concurrent agents, high load average) the on-demand broker — a full CLI cold-start — couldn't get scheduled enough CPU to finish booting and bind its socket, so `unlock`/auto-cache silently failed and reads kept prompting. New `agents secrets start` installs the broker as a **launchd user service** (`RunAtLoad` + `KeepAlive`, `ProcessType: Interactive` for foreground scheduling priority): it starts once and stays up for the whole login session, so every read just connects — the cold start happens once (and launchd retries until it wins), never per read. `agents secrets stop` removes it; `agents secrets status` shows whether it's installed.
|
|
8
|
+
- `unlock` and the auto-cache worker now install/kickstart this service automatically via `ensureAgentRunning`, falling back to the old one-off detached spawn only if the service path is unavailable. So the persistent broker is set up on first use with no extra step.
|
|
9
|
+
- macOS only. Security model unchanged: in-memory only, per-bundle TTL, wiped on screen-lock/sleep.
|
|
10
|
+
|
|
11
|
+
**Fix: secrets-agent auto-cache now survives a slow broker cold-start under load**
|
|
12
|
+
|
|
13
|
+
- `secrets.agent.auto` (auto-cache on first read of a `session`-tier bundle) used a fire-and-forget inline loader that gave up connecting to the broker after 3s. But the broker it spawns is itself a full CLI cold-starting; under heavy load (many concurrent agents) that can exceed 3s, so the loader quit before the broker bound and the cache silently never populated — every read kept prompting. The auto-load now runs through a detached `secrets _agent-load` worker that reuses the robust `ensureAgentRunning` path (spawn-then-ping, 20s budget) and loads synchronously, so it reliably populates even when the broker is slow to start. Manual `agents secrets unlock` was always reliable and is unchanged. (secret values still travel over stdin, never argv.)
|
|
14
|
+
|
|
5
15
|
**`agents secrets unlock`: a secrets-agent that ends Touch ID prompt spam (macOS)**
|
|
6
16
|
|
|
7
17
|
- macOS pops a Touch ID prompt **per bundle, per process** — the biometry assertion is process-local and macOS refuses to cache `kSecAccessControl`+biometry items, so running several agents at once (`agents teams`, parallel `agents run --secrets`) re-prompts once per process. New `agents secrets unlock <bundle>` reads the bundle once (one prompt) and holds the resolved env in a local broker; every later resolution — `agents run`, teammates, browser profiles, the routines daemon — is served from memory over a user-only Unix socket (`~/.agents/.cache/helpers/secrets-agent/`, `0700`) with no prompt. `agents secrets lock` wipes it; `agents secrets status` shows what's held and when it locks. The hold also ends on TTL expiry (default 24h, `--ttl`) and on screen-lock / sleep.
|
package/dist/commands/secrets.js
CHANGED
|
@@ -8,10 +8,10 @@
|
|
|
8
8
|
import chalk from 'chalk';
|
|
9
9
|
import * as fs from 'fs';
|
|
10
10
|
import { spawnSync } from 'child_process';
|
|
11
|
-
import { bundleExists, bundleTier, deleteBundle, describeBundle, keychainItemsForBundle, keychainRef, listBundles, migrateLegacyBundles, parseDotenv, readAndResolveBundleEnv, readBundle, renameBundle, rotateBundleSecret, validateBundleName, validateEnvKey, validateExpiresFutureDated, validateSecretType, writeBundle, } from '../lib/secrets/bundles.js';
|
|
12
|
-
import {
|
|
11
|
+
import { bundleExists, bundleItemStore, bundleTier, deleteBundle, describeBundle, keychainItemsForBundle, keychainRef, listBundles, migrateLegacyBundles, parseDotenv, readAndResolveBundleEnv, readBundle, renameBundle, rotateBundleSecret, validateBundleName, validateEnvKey, validateExpiresFutureDated, validateSecretType, writeBundle, } from '../lib/secrets/bundles.js';
|
|
12
|
+
import { getKeychainToken, getKeychainTokens, hasKeychainToken, secretsKeychainItem, setKeychainToken, } from '../lib/secrets/index.js';
|
|
13
13
|
import { assertOpAvailable, createPasswordItem, deleteItemByTitle, extractSecrets, itemExistsByTitle, listItems, listVaults, } from '../lib/onepassword.js';
|
|
14
|
-
import { DEFAULT_TTL_MS, agentLoad, agentLock, agentStatus, ensureAgentRunning, runSecretsAgent, } from '../lib/secrets/agent.js';
|
|
14
|
+
import { DEFAULT_TTL_MS, agentLoad, agentLock, agentStatus, ensureAgentRunning, installSecretsAgentService, runAgentLoadFromStdin, runSecretsAgent, secretsAgentServiceInstalled, uninstallSecretsAgentService, } from '../lib/secrets/agent.js';
|
|
15
15
|
import { parseDuration } from '../lib/hooks/cache.js';
|
|
16
16
|
import { registerCommandGroups, setHelpSections } from '../lib/help.js';
|
|
17
17
|
import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
|
|
@@ -235,7 +235,11 @@ function renderBundleRow(b) {
|
|
|
235
235
|
`${padVisible(created, 9)} ` +
|
|
236
236
|
`${padVisible(updated, 9)} ` +
|
|
237
237
|
`${padVisible(used, 7)}`;
|
|
238
|
-
|
|
238
|
+
// Mark file-backed bundles so `list` distinguishes them from keychain ones.
|
|
239
|
+
const tag = b.backend === 'file' ? chalk.magenta('[file] ') : '';
|
|
240
|
+
const desc = b.description ? chalk.gray(safePrint(b.description)) : '';
|
|
241
|
+
const trailer = `${tag}${desc}`.trimEnd();
|
|
242
|
+
return trailer ? `${head} ${trailer}` : head.trimEnd();
|
|
239
243
|
}
|
|
240
244
|
/** Colorize a variable source kind (literal, keychain, env, file, exec). */
|
|
241
245
|
function kindLabel(kind) {
|
|
@@ -412,7 +416,7 @@ export function registerSecretsCommands(program) {
|
|
|
412
416
|
registerCommandGroups(cmd, [
|
|
413
417
|
{ title: 'Bundle commands', names: ['list', 'view', 'create', 'rename', 'describe', 'delete'] },
|
|
414
418
|
{ title: 'Secret commands', names: ['add', 'rotate', 'remove', 'import', 'export'] },
|
|
415
|
-
{ title: 'Agent commands', names: ['unlock', 'lock', 'status', 'tier'] },
|
|
419
|
+
{ title: 'Agent commands', names: ['start', 'stop', 'unlock', 'lock', 'status', 'tier'] },
|
|
416
420
|
{ title: 'Raw item commands', names: ['get', 'set'] },
|
|
417
421
|
{ title: 'Sync commands', names: ['push', 'pull', 'remote-list'] },
|
|
418
422
|
{ title: 'Utilities', names: ['exec', 'generate', 'migrate-acl'] },
|
|
@@ -449,6 +453,8 @@ export function registerSecretsCommands(program) {
|
|
|
449
453
|
console.log(chalk.gray(safePrint(bundle.description)));
|
|
450
454
|
if (bundle.allow_exec)
|
|
451
455
|
console.log(chalk.yellow('allow_exec: true'));
|
|
456
|
+
if (bundle.backend === 'file')
|
|
457
|
+
console.log(chalk.gray('backend: file (passphrase-encrypted; reads need AGENTS_SECRETS_PASSPHRASE, no Touch ID)'));
|
|
452
458
|
if (bundleTier(bundle) === 'session')
|
|
453
459
|
console.log(chalk.gray('tier: session (secrets-agent eligible)'));
|
|
454
460
|
if (bundle.created_at)
|
|
@@ -573,12 +579,14 @@ export function registerSecretsCommands(program) {
|
|
|
573
579
|
.option('--description <text>', 'Free-form description')
|
|
574
580
|
.option('--allow-exec', 'Allow exec: refs in this bundle (off by default)')
|
|
575
581
|
.option('--tier <tier>', 'secrets-agent tier: biometry (default) or session', 'biometry')
|
|
582
|
+
.option('--backend <backend>', 'storage backend: keychain (default) or file (passphrase-encrypted, headless-readable)', 'keychain')
|
|
576
583
|
.option('--force', 'Overwrite an existing bundle')
|
|
577
584
|
.action(async (name, opts) => {
|
|
578
585
|
try {
|
|
579
586
|
const resolvedName = name ?? (await promptBundleName());
|
|
580
587
|
validateBundleName(resolvedName);
|
|
581
588
|
const tier = parseTierOpt(opts.tier);
|
|
589
|
+
const backend = parseBackendOpt(opts.backend);
|
|
582
590
|
if (bundleExists(resolvedName) && !opts.force) {
|
|
583
591
|
console.error(chalk.red(`Bundle '${resolvedName}' already exists. Use --force to overwrite.`));
|
|
584
592
|
process.exit(1);
|
|
@@ -587,11 +595,16 @@ export function registerSecretsCommands(program) {
|
|
|
587
595
|
name: resolvedName,
|
|
588
596
|
description: opts.description,
|
|
589
597
|
allow_exec: opts.allowExec,
|
|
598
|
+
backend: backend === 'file' ? 'file' : undefined,
|
|
590
599
|
tier,
|
|
591
600
|
vars: {},
|
|
592
601
|
};
|
|
593
602
|
writeBundle(bundle);
|
|
594
|
-
|
|
603
|
+
const tags = [tier === 'session' ? 'tier: session' : null, backend === 'file' ? 'backend: file' : null].filter(Boolean);
|
|
604
|
+
console.log(chalk.green(`Bundle '${resolvedName}' created${tags.length ? ` (${tags.join(', ')})` : ''}.`));
|
|
605
|
+
if (backend === 'file') {
|
|
606
|
+
console.log(chalk.gray('File-backed: items are AES-256-GCM encrypted under AGENTS_SECRETS_PASSPHRASE (no Touch ID).'));
|
|
607
|
+
}
|
|
595
608
|
console.log(chalk.gray(`Try: agents secrets add ${resolvedName} MY_KEY`));
|
|
596
609
|
}
|
|
597
610
|
catch (err) {
|
|
@@ -711,7 +724,7 @@ export function registerSecretsCommands(program) {
|
|
|
711
724
|
console.log(chalk.green(`${resolvedBundleName}.${resolvedKey} = <literal>`));
|
|
712
725
|
return;
|
|
713
726
|
}
|
|
714
|
-
// Default path: keychain
|
|
727
|
+
// Default path: stored in the bundle's backend (keychain or file).
|
|
715
728
|
let secretValue;
|
|
716
729
|
if (opts.valueStdin) {
|
|
717
730
|
secretValue = readStdinSync();
|
|
@@ -722,11 +735,12 @@ export function registerSecretsCommands(program) {
|
|
|
722
735
|
secretValue = await promptForSecret(`Enter value for ${resolvedBundleName}.${resolvedKey}`);
|
|
723
736
|
}
|
|
724
737
|
const item = secretsKeychainItem(resolvedBundleName, resolvedKey);
|
|
725
|
-
|
|
738
|
+
bundleItemStore(bundle.backend).set(item, secretValue);
|
|
726
739
|
bundle.vars[resolvedKey] = keychainRef(resolvedKey);
|
|
727
740
|
applyMeta();
|
|
728
741
|
writeBundle(bundle);
|
|
729
|
-
|
|
742
|
+
const where = bundle.backend === 'file' ? 'encrypted file store' : 'keychain';
|
|
743
|
+
console.log(chalk.green(`${resolvedBundleName}.${resolvedKey} stored in ${where} (${item}).`));
|
|
730
744
|
}
|
|
731
745
|
catch (err) {
|
|
732
746
|
if (isPromptCancelled(err))
|
|
@@ -831,9 +845,10 @@ Examples:
|
|
|
831
845
|
writeBundle(bundle);
|
|
832
846
|
if (willPurge) {
|
|
833
847
|
const item = secretsKeychainItem(resolvedBundleName, raw.slice('keychain:'.length));
|
|
834
|
-
const removed =
|
|
848
|
+
const removed = bundleItemStore(bundle.backend).delete(item);
|
|
835
849
|
if (removed) {
|
|
836
|
-
|
|
850
|
+
const where = bundle.backend === 'file' ? 'encrypted file item' : 'keychain item';
|
|
851
|
+
console.log(chalk.green(`Removed ${resolvedBundleName}.${resolvedKey} and purged ${where}.`));
|
|
837
852
|
return;
|
|
838
853
|
}
|
|
839
854
|
}
|
|
@@ -875,8 +890,9 @@ Examples:
|
|
|
875
890
|
}
|
|
876
891
|
}
|
|
877
892
|
if (!opts.keepSecrets) {
|
|
893
|
+
const store = bundleItemStore(bundle.backend);
|
|
878
894
|
for (const { item } of keychainItemsForBundle(bundle)) {
|
|
879
|
-
|
|
895
|
+
store.delete(item);
|
|
880
896
|
}
|
|
881
897
|
}
|
|
882
898
|
const existed = deleteBundle(resolvedName);
|
|
@@ -929,11 +945,12 @@ Examples:
|
|
|
929
945
|
});
|
|
930
946
|
cmd
|
|
931
947
|
.command('import [bundle]')
|
|
932
|
-
.description('Import keys from a .env file or a 1Password vault into a bundle.
|
|
948
|
+
.description('Import keys from a .env file or a 1Password vault into a bundle. The bundle is created if it does not exist. Values are stored in the bundle\'s backend (keychain by default).')
|
|
933
949
|
.option('--from <path>', 'Path to a .env file')
|
|
934
950
|
.option('--from-1password', 'Import secrets from a 1Password vault (requires the op CLI)')
|
|
935
951
|
.option('--vault <name>', '1Password vault name (used with --from-1password)')
|
|
936
952
|
.option('--all-plaintext', 'Store every imported value as a literal in the bundle metadata (skip keychain item creation)')
|
|
953
|
+
.option('--backend <backend>', 'When creating the bundle: keychain (default) or file (passphrase-encrypted, headless-readable)', 'keychain')
|
|
937
954
|
.option('--force', 'Overwrite an existing key in the bundle')
|
|
938
955
|
.action(async (bundleName, opts) => {
|
|
939
956
|
try {
|
|
@@ -944,7 +961,27 @@ Examples:
|
|
|
944
961
|
throw new Error('--from and --from-1password are mutually exclusive.');
|
|
945
962
|
}
|
|
946
963
|
const resolvedBundleName = bundleName ?? (await pickBundleName('import into'));
|
|
947
|
-
const
|
|
964
|
+
const requestedBackend = parseBackendOpt(opts.backend);
|
|
965
|
+
// Read the bundle if it exists (inheriting its backend); otherwise
|
|
966
|
+
// create it with the requested backend so a single `import --backend
|
|
967
|
+
// file` works (this is what `export --to-ssh --remote-backend file`
|
|
968
|
+
// drives on the remote).
|
|
969
|
+
let bundle;
|
|
970
|
+
if (bundleExists(resolvedBundleName)) {
|
|
971
|
+
bundle = readBundle(resolvedBundleName);
|
|
972
|
+
if (requestedBackend === 'file' && bundle.backend !== 'file') {
|
|
973
|
+
throw new Error(`Bundle '${resolvedBundleName}' already exists with a keychain backend; ` +
|
|
974
|
+
`--backend file cannot change it. Delete it first to recreate as file-backed.`);
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
else {
|
|
978
|
+
bundle = {
|
|
979
|
+
name: resolvedBundleName,
|
|
980
|
+
backend: requestedBackend === 'file' ? 'file' : undefined,
|
|
981
|
+
vars: {},
|
|
982
|
+
};
|
|
983
|
+
}
|
|
984
|
+
const store = bundleItemStore(bundle.backend);
|
|
948
985
|
let added = 0;
|
|
949
986
|
let skipped = 0;
|
|
950
987
|
if (opts.from1password) {
|
|
@@ -962,7 +999,7 @@ Examples:
|
|
|
962
999
|
}
|
|
963
1000
|
else {
|
|
964
1001
|
const item = secretsKeychainItem(resolvedBundleName, envKey);
|
|
965
|
-
|
|
1002
|
+
store.set(item, value);
|
|
966
1003
|
bundle.vars[envKey] = keychainRef(envKey);
|
|
967
1004
|
}
|
|
968
1005
|
added++;
|
|
@@ -986,7 +1023,7 @@ Examples:
|
|
|
986
1023
|
}
|
|
987
1024
|
else {
|
|
988
1025
|
const item = secretsKeychainItem(resolvedBundleName, key);
|
|
989
|
-
|
|
1026
|
+
store.set(item, value);
|
|
990
1027
|
bundle.vars[key] = keychainRef(key);
|
|
991
1028
|
}
|
|
992
1029
|
added++;
|
|
@@ -1010,6 +1047,7 @@ Examples:
|
|
|
1010
1047
|
.option('--vault <name>', '1Password vault name (used with --to-1password)')
|
|
1011
1048
|
.option('--to-ssh', 'Push the bundle to remote machine(s) over SSH via their native agents-cli import')
|
|
1012
1049
|
.option('--host <target...>', 'SSH target(s) for --to-ssh: host alias or user@host (repeatable)')
|
|
1050
|
+
.option('--remote-backend <backend>', 'Backend for the bundle on the remote: keychain (default) or file (passphrase-encrypted, headless-readable). file forwards AGENTS_SECRETS_PASSPHRASE over stdin.', 'keychain')
|
|
1013
1051
|
.option('--force', 'Overwrite existing keys/items on the target (used with --to-1password and --to-ssh)')
|
|
1014
1052
|
.action(async (bundleName, opts) => {
|
|
1015
1053
|
try {
|
|
@@ -1022,22 +1060,51 @@ Examples:
|
|
|
1022
1060
|
}
|
|
1023
1061
|
for (const h of hosts)
|
|
1024
1062
|
assertValidSshTarget(h);
|
|
1063
|
+
const remoteBackend = parseBackendOpt(opts.remoteBackend);
|
|
1064
|
+
// For a file-backed remote bundle the remote must encrypt at rest with
|
|
1065
|
+
// a passphrase. We forward the LOCAL AGENTS_SECRETS_PASSPHRASE — the
|
|
1066
|
+
// operator unlocks it once on this (trusted, biometry-gated) machine —
|
|
1067
|
+
// and ship it as the FIRST stdin line so it never lands in argv / `ps`
|
|
1068
|
+
// / the remote shell history. The remote `read -r` consumes that line;
|
|
1069
|
+
// `agents secrets import --from /dev/stdin` reads the .env remainder.
|
|
1070
|
+
let remotePassphrase = '';
|
|
1071
|
+
if (remoteBackend === 'file') {
|
|
1072
|
+
remotePassphrase = process.env.AGENTS_SECRETS_PASSPHRASE ?? '';
|
|
1073
|
+
if (!remotePassphrase) {
|
|
1074
|
+
throw new Error('--remote-backend file needs AGENTS_SECRETS_PASSPHRASE set locally to encrypt the ' +
|
|
1075
|
+
'bundle at rest on the remote. Set it for this command, then unlock it the same way per run.');
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1025
1078
|
const { env } = readAndResolveBundleEnv(resolvedBundleName, { caller: `ssh export` });
|
|
1026
1079
|
const dotenv = bundleEnvToDotenv(env);
|
|
1027
1080
|
const keyCount = Object.keys(env).length;
|
|
1028
|
-
// Drive the remote's own `agents secrets` CLI so values land in its
|
|
1029
|
-
// backend
|
|
1030
|
-
//
|
|
1031
|
-
//
|
|
1032
|
-
// by a remote shell.
|
|
1081
|
+
// Drive the remote's own `agents secrets` CLI so values land in its
|
|
1082
|
+
// chosen backend. `bash -lc` so the login PATH resolves `agents`; the
|
|
1083
|
+
// .env (and, for file, the passphrase) flow over ssh stdin and are
|
|
1084
|
+
// never parsed by a remote shell.
|
|
1033
1085
|
const force = opts.force ? ' --force' : '';
|
|
1034
|
-
const
|
|
1035
|
-
|
|
1086
|
+
const backendFlag = remoteBackend === 'file' ? ' --backend file' : '';
|
|
1087
|
+
let remoteAgents;
|
|
1088
|
+
let input;
|
|
1089
|
+
if (remoteBackend === 'file') {
|
|
1090
|
+
// import --backend file auto-creates the file-backed bundle; no
|
|
1091
|
+
// separate `create` needed.
|
|
1092
|
+
remoteAgents =
|
|
1093
|
+
`IFS= read -r AGENTS_SECRETS_PASSPHRASE; export AGENTS_SECRETS_PASSPHRASE; ` +
|
|
1094
|
+
`agents secrets import ${shellQuote(resolvedBundleName)} --from /dev/stdin${backendFlag}${force}`;
|
|
1095
|
+
input = `${remotePassphrase}\n${dotenv}`;
|
|
1096
|
+
}
|
|
1097
|
+
else {
|
|
1098
|
+
remoteAgents =
|
|
1099
|
+
`agents secrets create ${shellQuote(resolvedBundleName)} >/dev/null 2>&1 || true; ` +
|
|
1100
|
+
`agents secrets import ${shellQuote(resolvedBundleName)} --from /dev/stdin${force}`;
|
|
1101
|
+
input = dotenv;
|
|
1102
|
+
}
|
|
1036
1103
|
const remoteCmd = `bash -lc ${shellQuote(remoteAgents)}`;
|
|
1037
1104
|
let failures = 0;
|
|
1038
1105
|
for (const host of hosts) {
|
|
1039
1106
|
const res = spawnSync('ssh', ['-o', 'BatchMode=yes', host, remoteCmd], {
|
|
1040
|
-
input
|
|
1107
|
+
input,
|
|
1041
1108
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1042
1109
|
encoding: 'utf-8',
|
|
1043
1110
|
});
|
|
@@ -1296,6 +1363,10 @@ Examples:
|
|
|
1296
1363
|
console.log(chalk.gray('secrets-agent is macOS-only.'));
|
|
1297
1364
|
return;
|
|
1298
1365
|
}
|
|
1366
|
+
console.log(chalk.gray('service: ') +
|
|
1367
|
+
(secretsAgentServiceInstalled()
|
|
1368
|
+
? chalk.green('installed (persistent)')
|
|
1369
|
+
: chalk.yellow('not installed — run `agents secrets start` for a persistent broker')));
|
|
1299
1370
|
const entries = await agentStatus();
|
|
1300
1371
|
if (entries.length === 0) {
|
|
1301
1372
|
console.log(chalk.gray('No bundles unlocked. The secrets-agent is idle or not running.'));
|
|
@@ -1330,11 +1401,44 @@ Examples:
|
|
|
1330
1401
|
process.exit(1);
|
|
1331
1402
|
}
|
|
1332
1403
|
});
|
|
1404
|
+
cmd
|
|
1405
|
+
.command('start')
|
|
1406
|
+
.description('Install + start the secrets-agent as a persistent background service (macOS). Survives heavy load; reads connect instantly.')
|
|
1407
|
+
.action(async () => {
|
|
1408
|
+
if (process.platform !== 'darwin') {
|
|
1409
|
+
console.error(chalk.red('secrets-agent service is macOS-only.'));
|
|
1410
|
+
process.exit(1);
|
|
1411
|
+
}
|
|
1412
|
+
process.stdout.write(chalk.gray('Installing launchd service…\n'));
|
|
1413
|
+
if (await installSecretsAgentService()) {
|
|
1414
|
+
console.log(chalk.green('secrets-agent service running.') + chalk.gray(' It stays up across the session; unlock/auto-cache now connect instantly.'));
|
|
1415
|
+
}
|
|
1416
|
+
else {
|
|
1417
|
+
console.error(chalk.red('Service installed but did not become reachable in time (machine may be heavily loaded — launchd will keep retrying).'));
|
|
1418
|
+
process.exit(1);
|
|
1419
|
+
}
|
|
1420
|
+
});
|
|
1421
|
+
cmd
|
|
1422
|
+
.command('stop')
|
|
1423
|
+
.description('Stop + remove the persistent secrets-agent service and wipe what it held.')
|
|
1424
|
+
.action(async () => {
|
|
1425
|
+
if (process.platform !== 'darwin')
|
|
1426
|
+
return;
|
|
1427
|
+
await uninstallSecretsAgentService();
|
|
1428
|
+
console.log(chalk.green('secrets-agent service stopped and removed.'));
|
|
1429
|
+
});
|
|
1333
1430
|
cmd
|
|
1334
1431
|
.command('_agent-run', { hidden: true })
|
|
1335
1432
|
.description('Run the secrets-agent broker in the foreground (internal)')
|
|
1433
|
+
.option('--service', 'run as a persistent launchd service (never idle-exit)')
|
|
1434
|
+
.action(async (opts) => {
|
|
1435
|
+
await runSecretsAgent({ service: Boolean(opts.service) });
|
|
1436
|
+
});
|
|
1437
|
+
cmd
|
|
1438
|
+
.command('_agent-load', { hidden: true })
|
|
1439
|
+
.description('Detached auto-cache worker: load a bundle from stdin into the broker (internal)')
|
|
1336
1440
|
.action(async () => {
|
|
1337
|
-
await
|
|
1441
|
+
await runAgentLoadFromStdin();
|
|
1338
1442
|
});
|
|
1339
1443
|
registerSecretsSyncCommands(cmd);
|
|
1340
1444
|
registerSecretsMigrateAclCommand(cmd);
|
|
@@ -1353,6 +1457,14 @@ function parseTierOpt(raw) {
|
|
|
1353
1457
|
console.error(chalk.red(`Invalid --tier '${raw}'. Use 'biometry' or 'session'.`));
|
|
1354
1458
|
process.exit(1);
|
|
1355
1459
|
}
|
|
1460
|
+
/** Validate a --backend value, exiting with a clear message on a bad one. */
|
|
1461
|
+
function parseBackendOpt(raw) {
|
|
1462
|
+
const v = (raw ?? 'keychain').toLowerCase();
|
|
1463
|
+
if (v === 'keychain' || v === 'file')
|
|
1464
|
+
return v;
|
|
1465
|
+
console.error(chalk.red(`Invalid --backend '${raw}'. Use 'keychain' or 'file'.`));
|
|
1466
|
+
process.exit(1);
|
|
1467
|
+
}
|
|
1356
1468
|
/** Human-readable "locks in 3 hours" / "locks in 5 minutes" from an epoch-ms expiry. */
|
|
1357
1469
|
function humanRemaining(expiresAt) {
|
|
1358
1470
|
const ms = expiresAt - Date.now();
|
|
@@ -105,7 +105,13 @@ async function versionPruneAction(specs, options, commandName) {
|
|
|
105
105
|
}
|
|
106
106
|
const { agent, version } = parsed;
|
|
107
107
|
const agentConfig = AGENTS[agent];
|
|
108
|
-
|
|
108
|
+
// Script-installed agents (droid, grok) can have a *literal* `latest`
|
|
109
|
+
// version dir on disk when the post-install version probe failed. An
|
|
110
|
+
// explicit `<agent>@latest` should remove that dir directly rather than
|
|
111
|
+
// routing to the interactive picker (which can't run non-interactively),
|
|
112
|
+
// so treat an installed literal `latest` as a concrete pinned version.
|
|
113
|
+
const isLiteralLatestInstalled = version === 'latest' && spec.includes('@') && isVersionInstalled(agent, 'latest');
|
|
114
|
+
if (!isLiteralLatestInstalled && (version === 'latest' || version === 'oldest' || !spec.includes('@'))) {
|
|
109
115
|
const versions = listInstalledVersions(agent);
|
|
110
116
|
if (versions.length === 0) {
|
|
111
117
|
console.log(chalk.gray(`No versions of ${agentLabel(agentConfig.id)} installed`));
|
|
@@ -37,6 +37,17 @@ export interface AgentStatusEntry {
|
|
|
37
37
|
expiresAt: number;
|
|
38
38
|
keyCount: number;
|
|
39
39
|
}
|
|
40
|
+
/** True if the launchd plist for the persistent broker is installed. */
|
|
41
|
+
export declare function secretsAgentServiceInstalled(): boolean;
|
|
42
|
+
/**
|
|
43
|
+
* Install + start the persistent broker as a launchd user service (idempotent).
|
|
44
|
+
* Writes the plist, bootstraps it into the GUI domain, and waits for the socket.
|
|
45
|
+
* `ProcessType: Interactive` asks launchd to schedule it at foreground priority
|
|
46
|
+
* so it can boot even when the machine is loaded. Returns true once reachable.
|
|
47
|
+
*/
|
|
48
|
+
export declare function installSecretsAgentService(timeoutMs?: number): Promise<boolean>;
|
|
49
|
+
/** Stop + remove the persistent broker service, and wipe whatever it held. */
|
|
50
|
+
export declare function uninstallSecretsAgentService(): Promise<void>;
|
|
40
51
|
export type Request = {
|
|
41
52
|
cmd: 'ping';
|
|
42
53
|
} | {
|
|
@@ -95,7 +106,9 @@ export declare function handleAgentRequest(store: Map<string, StoredBundle>, req
|
|
|
95
106
|
* `agents secrets _agent-run`. Holds the store in memory, serves the socket,
|
|
96
107
|
* sweeps expired entries, wipes on screen-lock/sleep, and self-exits when idle.
|
|
97
108
|
*/
|
|
98
|
-
export declare function runSecretsAgent(
|
|
109
|
+
export declare function runSecretsAgent(opts?: {
|
|
110
|
+
service?: boolean;
|
|
111
|
+
}): Promise<void>;
|
|
99
112
|
/** True if a broker socket exists at all. Cheap; gates the sync read so the
|
|
100
113
|
* never-unlocked path stays a single stat. */
|
|
101
114
|
export declare function agentSocketExists(): boolean;
|
|
@@ -115,10 +128,23 @@ export declare function secretsAgentAutoEnabled(): boolean;
|
|
|
115
128
|
* Fire-and-forget: populate the broker with a freshly-resolved bundle so the
|
|
116
129
|
* NEXT process reads it without a prompt. Used by the auto-cache path after a
|
|
117
130
|
* real keychain read of a `session`-tier bundle. Adds no latency to the caller
|
|
118
|
-
* — it spawns
|
|
119
|
-
*
|
|
131
|
+
* — it spawns a detached `secrets _agent-load` worker (passing the resolved env
|
|
132
|
+
* over stdin, never argv) and returns immediately.
|
|
133
|
+
*
|
|
134
|
+
* The worker reuses the robust `ensureAgentRunning` path (spawn-then-ping with a
|
|
135
|
+
* generous budget) rather than a tight inline retry loop: under heavy load the
|
|
136
|
+
* broker is itself a cold-starting full CLI and can take several seconds to bind
|
|
137
|
+
* the socket, so a short fixed budget would give up before it's ready and the
|
|
138
|
+
* cache would silently never populate. Best-effort; never throws. macOS only.
|
|
120
139
|
*/
|
|
121
140
|
export declare function agentAutoLoadSync(name: string, bundle: SecretsBundle, env: Record<string, string>, ttlMs: number): void;
|
|
141
|
+
/**
|
|
142
|
+
* Body of the hidden `secrets _agent-load` worker. Reads one `{name, bundle,
|
|
143
|
+
* env, ttlMs}` payload from stdin, ensures the broker is up (robust, generous
|
|
144
|
+
* budget), and loads the bundle into it. Detached from the originating read, so
|
|
145
|
+
* its latency is invisible — which is why it can afford a long ensure budget.
|
|
146
|
+
*/
|
|
147
|
+
export declare function runAgentLoadFromStdin(): Promise<void>;
|
|
122
148
|
/** Store a resolved bundle in the broker. Returns false on transport failure. */
|
|
123
149
|
export declare function agentLoad(name: string, bundle: SecretsBundle, env: Record<string, string>, ttlMs: number): Promise<boolean>;
|
|
124
150
|
/** Wipe one bundle (or all if name omitted) from the broker. Returns the count
|
|
@@ -127,8 +153,13 @@ export declare function agentLock(name?: string): Promise<number>;
|
|
|
127
153
|
/** List currently-unlocked bundles, or [] when no broker is running. */
|
|
128
154
|
export declare function agentStatus(): Promise<AgentStatusEntry[]>;
|
|
129
155
|
/**
|
|
130
|
-
* Ensure a broker is running and reachable
|
|
131
|
-
*
|
|
132
|
-
*
|
|
156
|
+
* Ensure a broker is running and reachable. Returns true once the socket answers
|
|
157
|
+
* a ping. macOS only.
|
|
158
|
+
*
|
|
159
|
+
* Prefers the persistent launchd service: if it isn't installed we install it
|
|
160
|
+
* (which makes the broker survive for the whole login session, so subsequent
|
|
161
|
+
* reads never cold-start); if it's installed but unreachable we kickstart it.
|
|
162
|
+
* Only when the service path can't be used do we fall back to a one-off detached
|
|
163
|
+
* broker — that's the model that gets starved under heavy load, so it's last.
|
|
133
164
|
*/
|
|
134
165
|
export declare function ensureAgentRunning(timeoutMs?: number): Promise<boolean>;
|