@phnx-labs/agents-cli 1.20.18 → 1.20.19
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 +4 -0
- package/dist/commands/secrets.js +104 -23
- package/dist/lib/secrets/agent.d.ts +15 -2
- package/dist/lib/secrets/agent.js +51 -52
- 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/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
**Fix: secrets-agent auto-cache now survives a slow broker cold-start under load**
|
|
6
|
+
|
|
7
|
+
- `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.)
|
|
8
|
+
|
|
5
9
|
**`agents secrets unlock`: a secrets-agent that ends Touch ID prompt spam (macOS)**
|
|
6
10
|
|
|
7
11
|
- 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, runAgentLoadFromStdin, runSecretsAgent, } 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) {
|
|
@@ -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
|
});
|
|
@@ -1336,6 +1403,12 @@ Examples:
|
|
|
1336
1403
|
.action(async () => {
|
|
1337
1404
|
await runSecretsAgent();
|
|
1338
1405
|
});
|
|
1406
|
+
cmd
|
|
1407
|
+
.command('_agent-load', { hidden: true })
|
|
1408
|
+
.description('Detached auto-cache worker: load a bundle from stdin into the broker (internal)')
|
|
1409
|
+
.action(async () => {
|
|
1410
|
+
await runAgentLoadFromStdin();
|
|
1411
|
+
});
|
|
1339
1412
|
registerSecretsSyncCommands(cmd);
|
|
1340
1413
|
registerSecretsMigrateAclCommand(cmd);
|
|
1341
1414
|
}
|
|
@@ -1353,6 +1426,14 @@ function parseTierOpt(raw) {
|
|
|
1353
1426
|
console.error(chalk.red(`Invalid --tier '${raw}'. Use 'biometry' or 'session'.`));
|
|
1354
1427
|
process.exit(1);
|
|
1355
1428
|
}
|
|
1429
|
+
/** Validate a --backend value, exiting with a clear message on a bad one. */
|
|
1430
|
+
function parseBackendOpt(raw) {
|
|
1431
|
+
const v = (raw ?? 'keychain').toLowerCase();
|
|
1432
|
+
if (v === 'keychain' || v === 'file')
|
|
1433
|
+
return v;
|
|
1434
|
+
console.error(chalk.red(`Invalid --backend '${raw}'. Use 'keychain' or 'file'.`));
|
|
1435
|
+
process.exit(1);
|
|
1436
|
+
}
|
|
1356
1437
|
/** Human-readable "locks in 3 hours" / "locks in 5 minutes" from an epoch-ms expiry. */
|
|
1357
1438
|
function humanRemaining(expiresAt) {
|
|
1358
1439
|
const ms = expiresAt - Date.now();
|
|
@@ -115,10 +115,23 @@ export declare function secretsAgentAutoEnabled(): boolean;
|
|
|
115
115
|
* Fire-and-forget: populate the broker with a freshly-resolved bundle so the
|
|
116
116
|
* NEXT process reads it without a prompt. Used by the auto-cache path after a
|
|
117
117
|
* real keychain read of a `session`-tier bundle. Adds no latency to the caller
|
|
118
|
-
* — it spawns
|
|
119
|
-
*
|
|
118
|
+
* — it spawns a detached `secrets _agent-load` worker (passing the resolved env
|
|
119
|
+
* over stdin, never argv) and returns immediately.
|
|
120
|
+
*
|
|
121
|
+
* The worker reuses the robust `ensureAgentRunning` path (spawn-then-ping with a
|
|
122
|
+
* generous budget) rather than a tight inline retry loop: under heavy load the
|
|
123
|
+
* broker is itself a cold-starting full CLI and can take several seconds to bind
|
|
124
|
+
* the socket, so a short fixed budget would give up before it's ready and the
|
|
125
|
+
* cache would silently never populate. Best-effort; never throws. macOS only.
|
|
120
126
|
*/
|
|
121
127
|
export declare function agentAutoLoadSync(name: string, bundle: SecretsBundle, env: Record<string, string>, ttlMs: number): void;
|
|
128
|
+
/**
|
|
129
|
+
* Body of the hidden `secrets _agent-load` worker. Reads one `{name, bundle,
|
|
130
|
+
* env, ttlMs}` payload from stdin, ensures the broker is up (robust, generous
|
|
131
|
+
* budget), and loads the bundle into it. Detached from the originating read, so
|
|
132
|
+
* its latency is invisible — which is why it can afford a long ensure budget.
|
|
133
|
+
*/
|
|
134
|
+
export declare function runAgentLoadFromStdin(): Promise<void>;
|
|
122
135
|
/** Store a resolved bundle in the broker. Returns false on transport failure. */
|
|
123
136
|
export declare function agentLoad(name: string, bundle: SecretsBundle, env: Record<string, string>, ttlMs: number): Promise<boolean>;
|
|
124
137
|
/** Wipe one bundle (or all if name omitted) from the broker. Returns the count
|
|
@@ -61,24 +61,27 @@ function pidPath() {
|
|
|
61
61
|
return path.join(agentDir(), 'agent.pid');
|
|
62
62
|
}
|
|
63
63
|
/**
|
|
64
|
-
* Argv for re-invoking THIS cli
|
|
65
|
-
* spawns its own
|
|
66
|
-
* through `process.execPath` (the node binary) with the JS entrypoint as the
|
|
67
|
-
* first arg — the entrypoint isn't reliably executable in dev builds (invoked
|
|
68
|
-
*
|
|
64
|
+
* Argv for re-invoking THIS cli with a hidden subcommand, so a side-by-side dev
|
|
65
|
+
* build spawns its own helpers rather than the registry-installed one. We always
|
|
66
|
+
* go through `process.execPath` (the node binary) with the JS entrypoint as the
|
|
67
|
+
* first arg — the entrypoint isn't reliably executable in dev builds (invoked as
|
|
68
|
+
* `node dist/index.js`, no +x), so spawning it directly EACCES'd.
|
|
69
69
|
*/
|
|
70
|
-
function
|
|
70
|
+
function cliSpawn(sub) {
|
|
71
71
|
const argv1 = process.argv[1];
|
|
72
72
|
const entry = argv1 && fs.existsSync(argv1) ? argv1 : null;
|
|
73
73
|
if (entry)
|
|
74
|
-
return { cmd: process.execPath, args: [entry,
|
|
74
|
+
return { cmd: process.execPath, args: [entry, ...sub] };
|
|
75
75
|
// No resolvable entrypoint (unusual) — fall back to the PATH shim.
|
|
76
76
|
let bin = 'agents';
|
|
77
77
|
try {
|
|
78
78
|
bin = execFileSync('which', ['agents'], { encoding: 'utf-8' }).trim();
|
|
79
79
|
}
|
|
80
80
|
catch { /* default */ }
|
|
81
|
-
return { cmd: bin, args:
|
|
81
|
+
return { cmd: bin, args: sub };
|
|
82
|
+
}
|
|
83
|
+
function brokerSpawn() {
|
|
84
|
+
return cliSpawn(['secrets', '_agent-run']);
|
|
82
85
|
}
|
|
83
86
|
// ─── Broker server (runs in the detached `secrets _agent-run` process) ───────
|
|
84
87
|
/**
|
|
@@ -371,64 +374,60 @@ export function secretsAgentAutoEnabled() {
|
|
|
371
374
|
return false;
|
|
372
375
|
}
|
|
373
376
|
}
|
|
374
|
-
/**
|
|
375
|
-
* Inline node program that loads one bundle into the broker, started detached
|
|
376
|
-
* from the hot path. Reads the JSON payload from stdin (so secret values never
|
|
377
|
-
* appear in argv / `ps`), retries the socket for a few seconds to absorb a
|
|
378
|
-
* cold-started agent, sends the load, and exits. argv after -e: [execPath, <socket>].
|
|
379
|
-
*/
|
|
380
|
-
const DETACHED_LOAD_PROGRAM = `
|
|
381
|
-
const net = require('net');
|
|
382
|
-
const sock = process.argv[1];
|
|
383
|
-
let input = '';
|
|
384
|
-
process.stdin.setEncoding('utf-8');
|
|
385
|
-
process.stdin.on('data', (d) => { input += d; });
|
|
386
|
-
process.stdin.on('end', () => {
|
|
387
|
-
let payload; try { payload = JSON.parse(input); } catch (e) { process.exit(1); }
|
|
388
|
-
let attempts = 0;
|
|
389
|
-
const tryConnect = () => {
|
|
390
|
-
const c = net.createConnection(sock);
|
|
391
|
-
c.on('connect', () => {
|
|
392
|
-
c.write(JSON.stringify({ cmd: 'load', name: payload.name, bundle: payload.bundle, env: payload.env, ttlMs: payload.ttlMs }) + '\\n');
|
|
393
|
-
});
|
|
394
|
-
c.setEncoding('utf-8');
|
|
395
|
-
c.on('data', () => { try { c.destroy(); } catch (e) {} process.exit(0); });
|
|
396
|
-
c.on('error', () => {
|
|
397
|
-
try { c.destroy(); } catch (e) {}
|
|
398
|
-
if (++attempts >= 30) process.exit(1);
|
|
399
|
-
setTimeout(tryConnect, 100);
|
|
400
|
-
});
|
|
401
|
-
};
|
|
402
|
-
tryConnect();
|
|
403
|
-
});
|
|
404
|
-
`;
|
|
405
377
|
/**
|
|
406
378
|
* Fire-and-forget: populate the broker with a freshly-resolved bundle so the
|
|
407
379
|
* NEXT process reads it without a prompt. Used by the auto-cache path after a
|
|
408
380
|
* real keychain read of a `session`-tier bundle. Adds no latency to the caller
|
|
409
|
-
* — it spawns
|
|
410
|
-
*
|
|
381
|
+
* — it spawns a detached `secrets _agent-load` worker (passing the resolved env
|
|
382
|
+
* over stdin, never argv) and returns immediately.
|
|
383
|
+
*
|
|
384
|
+
* The worker reuses the robust `ensureAgentRunning` path (spawn-then-ping with a
|
|
385
|
+
* generous budget) rather than a tight inline retry loop: under heavy load the
|
|
386
|
+
* broker is itself a cold-starting full CLI and can take several seconds to bind
|
|
387
|
+
* the socket, so a short fixed budget would give up before it's ready and the
|
|
388
|
+
* cache would silently never populate. Best-effort; never throws. macOS only.
|
|
411
389
|
*/
|
|
412
390
|
export function agentAutoLoadSync(name, bundle, env, ttlMs) {
|
|
413
391
|
if (!onDarwin())
|
|
414
392
|
return;
|
|
415
393
|
try {
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
stdio: ['pipe', 'ignore', 'ignore'],
|
|
422
|
-
detached: true,
|
|
423
|
-
});
|
|
424
|
-
loader.stdin?.write(JSON.stringify({ name, bundle, env, ttlMs }));
|
|
425
|
-
loader.stdin?.end();
|
|
426
|
-
loader.unref();
|
|
394
|
+
const { cmd, args } = cliSpawn(['secrets', '_agent-load']);
|
|
395
|
+
const worker = spawn(cmd, args, { stdio: ['pipe', 'ignore', 'ignore'], detached: true });
|
|
396
|
+
worker.stdin?.write(JSON.stringify({ name, bundle, env, ttlMs }));
|
|
397
|
+
worker.stdin?.end();
|
|
398
|
+
worker.unref();
|
|
427
399
|
}
|
|
428
400
|
catch {
|
|
429
401
|
// best-effort: the next read just pops Touch ID as it would today
|
|
430
402
|
}
|
|
431
403
|
}
|
|
404
|
+
/**
|
|
405
|
+
* Body of the hidden `secrets _agent-load` worker. Reads one `{name, bundle,
|
|
406
|
+
* env, ttlMs}` payload from stdin, ensures the broker is up (robust, generous
|
|
407
|
+
* budget), and loads the bundle into it. Detached from the originating read, so
|
|
408
|
+
* its latency is invisible — which is why it can afford a long ensure budget.
|
|
409
|
+
*/
|
|
410
|
+
export async function runAgentLoadFromStdin() {
|
|
411
|
+
if (!onDarwin())
|
|
412
|
+
return;
|
|
413
|
+
const chunks = [];
|
|
414
|
+
for await (const chunk of process.stdin)
|
|
415
|
+
chunks.push(chunk);
|
|
416
|
+
let payload;
|
|
417
|
+
try {
|
|
418
|
+
payload = JSON.parse(Buffer.concat(chunks).toString('utf-8'));
|
|
419
|
+
}
|
|
420
|
+
catch {
|
|
421
|
+
return; // malformed payload — nothing to load
|
|
422
|
+
}
|
|
423
|
+
if (!payload || !payload.name || !payload.bundle || !payload.env)
|
|
424
|
+
return;
|
|
425
|
+
// Generous budget: the broker is a cold-starting full CLI; under load it can
|
|
426
|
+
// take several seconds to bind. We're detached, so waiting costs nothing.
|
|
427
|
+
if (!(await ensureAgentRunning(20000)))
|
|
428
|
+
return;
|
|
429
|
+
await agentLoad(payload.name, payload.bundle, payload.env, payload.ttlMs ?? DEFAULT_TTL_MS);
|
|
430
|
+
}
|
|
432
431
|
/** Store a resolved bundle in the broker. Returns false on transport failure. */
|
|
433
432
|
export async function agentLoad(name, bundle, env, ttlMs) {
|
|
434
433
|
const r = await request({ cmd: 'load', name, bundle, env, ttlMs });
|
|
@@ -1,17 +1,32 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Secret bundles — named sets of
|
|
2
|
+
* Secret bundles — named sets of environment variables backed by a secret store.
|
|
3
3
|
*
|
|
4
|
-
* Bundle metadata (name, description, vars map) is stored
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
4
|
+
* Bundle metadata (name, description, vars map) is stored as a JSON blob under
|
|
5
|
+
* `agents-cli.bundles.<name>`; secret values live one per item under
|
|
6
|
+
* `agents-cli.secrets.<bundle>.<key>`. Two backends carry those items:
|
|
7
|
+
*
|
|
8
|
+
* - `keychain` (default): the macOS Keychain (device-local, Touch ID / device
|
|
9
|
+
* passcode gated) or Linux libsecret — see src/lib/secrets/index.ts.
|
|
10
|
+
* - `file`: an AES-256-GCM encrypted-file store keyed by a passphrase
|
|
11
|
+
* (src/lib/secrets/filestore.ts). Opt-in, for headless / remote runs where
|
|
12
|
+
* no biometry prompt can be satisfied (e.g. a release on a remote Mac over
|
|
13
|
+
* SSH). The item-name scheme is identical, so the only difference is where
|
|
14
|
+
* bytes land. A file-backed bundle is discovered by the presence of its
|
|
15
|
+
* metadata item in the file store.
|
|
10
16
|
*
|
|
11
17
|
* Cross-machine sync is handled by src/lib/secrets/sync.ts via an explicit
|
|
12
18
|
* encrypted export/import flow; the bundle layer is sync-agnostic.
|
|
13
19
|
*/
|
|
14
20
|
import { type BundleValue, type SecretRef } from './index.js';
|
|
21
|
+
/** Which store carries a bundle's items. */
|
|
22
|
+
export type SecretsBackend = 'keychain' | 'file';
|
|
23
|
+
/**
|
|
24
|
+
* Discover a bundle's backend by location: a file-backed bundle's metadata
|
|
25
|
+
* item exists in the encrypted-file store. This is a plain file-existence
|
|
26
|
+
* check — no passphrase, no Touch ID — so it sidesteps the chicken-and-egg of
|
|
27
|
+
* "read metadata to learn where metadata lives." Absent ⇒ keychain.
|
|
28
|
+
*/
|
|
29
|
+
export declare function bundleBackend(name: string): SecretsBackend;
|
|
15
30
|
/** Allowed values for a secret's `type` metadata field. */
|
|
16
31
|
export declare const SECRET_TYPES: readonly ["api-key", "token", "password", "url", "database-url", "ssh-key", "certificate", "webhook", "note"];
|
|
17
32
|
export type SecretType = typeof SECRET_TYPES[number];
|
|
@@ -38,6 +53,8 @@ export interface SecretsBundle {
|
|
|
38
53
|
name: string;
|
|
39
54
|
description?: string;
|
|
40
55
|
allow_exec?: boolean;
|
|
56
|
+
/** Which store carries this bundle's items. Absent ⇒ `keychain` (the default). */
|
|
57
|
+
backend?: SecretsBackend;
|
|
41
58
|
/** Secrets-agent interaction tier. Absent ⇒ `biometry` (the safe default). */
|
|
42
59
|
tier?: SecretsTier;
|
|
43
60
|
/** ISO 8601 UTC timestamp. Set once on the first writeBundle() for a bundle. */
|
|
@@ -156,6 +173,19 @@ export interface RenameOptions {
|
|
|
156
173
|
* a safe no-op for the source items.
|
|
157
174
|
*/
|
|
158
175
|
export declare function renameBundle(oldName: string, newName: string, opts?: RenameOptions): void;
|
|
176
|
+
/**
|
|
177
|
+
* The store (keychain or encrypted file) that carries a bundle's items. The
|
|
178
|
+
* CLI uses this to read/write/delete per-key items (built with
|
|
179
|
+
* secretsKeychainItem) in the same store as the bundle's metadata, for `add` /
|
|
180
|
+
* `import` / `remove` / `delete`. Pass the bundle's resolved backend
|
|
181
|
+
* (`bundle.backend ?? 'keychain'`).
|
|
182
|
+
*/
|
|
183
|
+
export declare function bundleItemStore(backend: SecretsBackend | undefined): {
|
|
184
|
+
set(item: string, value: string): void;
|
|
185
|
+
delete(item: string): boolean;
|
|
186
|
+
get(item: string): string;
|
|
187
|
+
has(item: string): boolean;
|
|
188
|
+
};
|
|
159
189
|
export declare function keychainItemsForBundle(bundle: SecretsBundle): Array<{
|
|
160
190
|
key: string;
|
|
161
191
|
item: string;
|