@phnx-labs/agents-cli 1.20.17 → 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 +19 -0
- package/README.md +1 -1
- package/dist/commands/budget.d.ts +14 -0
- package/dist/commands/budget.js +137 -0
- package/dist/commands/cost.d.ts +12 -0
- package/dist/commands/cost.js +139 -0
- package/dist/commands/exec.d.ts +20 -0
- package/dist/commands/exec.js +382 -5
- package/dist/commands/secrets.d.ts +15 -0
- package/dist/commands/secrets.js +343 -16
- package/dist/commands/sessions.js +4 -0
- package/dist/index.js +4 -0
- package/dist/lib/budget/config.d.ts +9 -0
- package/dist/lib/budget/config.js +115 -0
- package/dist/lib/budget/enforce.d.ts +94 -0
- package/dist/lib/budget/enforce.js +151 -0
- package/dist/lib/budget/ledger.d.ts +61 -0
- package/dist/lib/budget/ledger.js +107 -0
- package/dist/lib/budget/preflight.d.ts +110 -0
- package/dist/lib/budget/preflight.js +200 -0
- package/dist/lib/checkpoint.d.ts +54 -0
- package/dist/lib/checkpoint.js +56 -0
- package/dist/lib/cloud/rush.js +18 -0
- package/dist/lib/exec.d.ts +36 -0
- package/dist/lib/exec.js +192 -4
- package/dist/lib/git.d.ts +18 -0
- package/dist/lib/git.js +67 -4
- package/dist/lib/loop.d.ts +145 -0
- package/dist/lib/loop.js +330 -0
- package/dist/lib/mcp.d.ts +7 -0
- package/dist/lib/mcp.js +24 -0
- package/dist/lib/models.d.ts +11 -0
- package/dist/lib/models.js +21 -0
- package/dist/lib/plugins.js +5 -2
- package/dist/lib/pricing/cost.d.ts +46 -0
- package/dist/lib/pricing/cost.js +71 -0
- package/dist/lib/pricing/index.d.ts +8 -0
- package/dist/lib/pricing/index.js +8 -0
- package/dist/lib/pricing/prices.json +138 -0
- package/dist/lib/pricing/table.d.ts +17 -0
- package/dist/lib/pricing/table.js +73 -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 +147 -0
- package/dist/lib/secrets/agent.js +500 -0
- package/dist/lib/secrets/bundles.d.ts +58 -7
- package/dist/lib/secrets/bundles.js +264 -75
- 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/session/db.d.ts +40 -0
- package/dist/lib/session/db.js +84 -2
- package/dist/lib/session/discover.d.ts +2 -0
- package/dist/lib/session/discover.js +126 -2
- package/dist/lib/session/render.d.ts +2 -0
- package/dist/lib/session/render.js +1 -1
- package/dist/lib/session/types.d.ts +4 -0
- package/dist/lib/teams/agents.d.ts +32 -0
- package/dist/lib/teams/agents.js +66 -3
- package/dist/lib/teams/api.js +20 -0
- package/dist/lib/teams/parsers.js +16 -4
- package/dist/lib/types.d.ts +48 -0
- package/dist/lib/workflows.d.ts +56 -0
- package/dist/lib/workflows.js +72 -5
- package/package.json +2 -1
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The secrets-agent: a local broker that holds resolved bundle env in memory
|
|
3
|
+
* after a single Touch ID unlock, so concurrent agent processes don't each pop
|
|
4
|
+
* their own prompt.
|
|
5
|
+
*
|
|
6
|
+
* Why this exists: every secret item carries a biometry access control, and
|
|
7
|
+
* macOS refuses to cache that across processes — N concurrent `agents run`
|
|
8
|
+
* spawns = N Touch ID prompts (see src/lib/secrets/bundles.ts). The Swift
|
|
9
|
+
* helper's LAContext only deduplicates reads *within one process*. This broker
|
|
10
|
+
* is the ssh-agent answer: `agents secrets unlock <bundle>` decrypts the bundle
|
|
11
|
+
* once (one prompt), ships the resolved env here, and every later read returns
|
|
12
|
+
* from memory over a user-only Unix socket — no prompt.
|
|
13
|
+
*
|
|
14
|
+
* Security model (deliberate): while a bundle is unlocked, any same-user
|
|
15
|
+
* process that can reach the socket reads it silently. That's strictly the same
|
|
16
|
+
* trust boundary the keychain already concedes (docs/secrets.md: the ACL is
|
|
17
|
+
* user-presence, not code-identity — any same-user process can pop the prompt
|
|
18
|
+
* and read), minus the visible prompt. We bound it with: explicit per-bundle
|
|
19
|
+
* opt-in (nothing is held unless you `unlock` it), an absolute TTL, auto-lock
|
|
20
|
+
* on screen-lock / sleep, and `agents secrets lock`. Nothing ever touches disk.
|
|
21
|
+
*
|
|
22
|
+
* macOS only: Linux libsecret has no biometry prompt, so there's nothing to
|
|
23
|
+
* deduplicate — every entry point here no-ops off darwin.
|
|
24
|
+
*/
|
|
25
|
+
import * as net from 'net';
|
|
26
|
+
import * as fs from 'fs';
|
|
27
|
+
import * as path from 'path';
|
|
28
|
+
import { spawn, spawnSync, execFileSync } from 'child_process';
|
|
29
|
+
import { getHelpersDir, readMeta } from '../state.js';
|
|
30
|
+
import { isAlive } from '../platform/process.js';
|
|
31
|
+
import { getKeychainHelperPath } from './install-helper.js';
|
|
32
|
+
/** Bumped when the wire protocol changes; a client that pings a mismatched
|
|
33
|
+
* server kills and respawns it rather than talking a stale dialect. */
|
|
34
|
+
const PROTOCOL_VERSION = 1;
|
|
35
|
+
/** Default lifetime of an unlocked bundle when `--ttl` is not given. */
|
|
36
|
+
export const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000; // 24h
|
|
37
|
+
/** After the store goes empty (all bundles locked or expired) for this long,
|
|
38
|
+
* the broker exits so no idle process lingers holding a socket. */
|
|
39
|
+
const IDLE_EXIT_MS = 5 * 60 * 1000; // 5m
|
|
40
|
+
/** How often the broker sweeps expired entries. */
|
|
41
|
+
const SWEEP_INTERVAL_MS = 30 * 1000;
|
|
42
|
+
function onDarwin() {
|
|
43
|
+
return process.platform === 'darwin';
|
|
44
|
+
}
|
|
45
|
+
/** Broker runtime dir under the regenerable cache, locked to the user (0700).
|
|
46
|
+
* AGENTS_SECRETS_AGENT_DIR overrides the location — a test seam so the suite can
|
|
47
|
+
* run a real broker on a temp socket without touching the user's real dir. */
|
|
48
|
+
function agentDir() {
|
|
49
|
+
const dir = process.env.AGENTS_SECRETS_AGENT_DIR || path.join(getHelpersDir(), 'secrets-agent');
|
|
50
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
51
|
+
try {
|
|
52
|
+
fs.chmodSync(dir, 0o700);
|
|
53
|
+
}
|
|
54
|
+
catch { /* best effort */ }
|
|
55
|
+
return dir;
|
|
56
|
+
}
|
|
57
|
+
function socketPath() {
|
|
58
|
+
return path.join(agentDir(), 'agent.sock');
|
|
59
|
+
}
|
|
60
|
+
function pidPath() {
|
|
61
|
+
return path.join(agentDir(), 'agent.pid');
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
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
|
+
*/
|
|
70
|
+
function cliSpawn(sub) {
|
|
71
|
+
const argv1 = process.argv[1];
|
|
72
|
+
const entry = argv1 && fs.existsSync(argv1) ? argv1 : null;
|
|
73
|
+
if (entry)
|
|
74
|
+
return { cmd: process.execPath, args: [entry, ...sub] };
|
|
75
|
+
// No resolvable entrypoint (unusual) — fall back to the PATH shim.
|
|
76
|
+
let bin = 'agents';
|
|
77
|
+
try {
|
|
78
|
+
bin = execFileSync('which', ['agents'], { encoding: 'utf-8' }).trim();
|
|
79
|
+
}
|
|
80
|
+
catch { /* default */ }
|
|
81
|
+
return { cmd: bin, args: sub };
|
|
82
|
+
}
|
|
83
|
+
function brokerSpawn() {
|
|
84
|
+
return cliSpawn(['secrets', '_agent-run']);
|
|
85
|
+
}
|
|
86
|
+
// ─── Broker server (runs in the detached `secrets _agent-run` process) ───────
|
|
87
|
+
/**
|
|
88
|
+
* Pure request handler over the in-memory store. Extracted so the store
|
|
89
|
+
* semantics (lazy expiry on get/status, lock-one vs lock-all, load TTL) are
|
|
90
|
+
* unit-testable with a controlled `now`, without a socket or a spawned process.
|
|
91
|
+
* Mutates `store` in place; returns the wire response.
|
|
92
|
+
*/
|
|
93
|
+
export function handleAgentRequest(store, req, now = Date.now()) {
|
|
94
|
+
switch (req.cmd) {
|
|
95
|
+
case 'ping':
|
|
96
|
+
return { ok: true, cmd: 'ping', version: PROTOCOL_VERSION };
|
|
97
|
+
case 'get': {
|
|
98
|
+
const e = store.get(req.name);
|
|
99
|
+
if (!e || now >= e.expiresAt) {
|
|
100
|
+
if (e)
|
|
101
|
+
store.delete(req.name); // drop expired on read
|
|
102
|
+
return { ok: true, cmd: 'get', hit: false };
|
|
103
|
+
}
|
|
104
|
+
return { ok: true, cmd: 'get', hit: true, bundle: e.bundle, env: e.env };
|
|
105
|
+
}
|
|
106
|
+
case 'load':
|
|
107
|
+
store.set(req.name, { bundle: req.bundle, env: req.env, expiresAt: now + req.ttlMs });
|
|
108
|
+
return { ok: true, cmd: 'load' };
|
|
109
|
+
case 'lock': {
|
|
110
|
+
if (req.name) {
|
|
111
|
+
return { ok: true, cmd: 'lock', wiped: store.delete(req.name) ? 1 : 0 };
|
|
112
|
+
}
|
|
113
|
+
const wiped = store.size;
|
|
114
|
+
store.clear();
|
|
115
|
+
return { ok: true, cmd: 'lock', wiped };
|
|
116
|
+
}
|
|
117
|
+
case 'status': {
|
|
118
|
+
const entries = [];
|
|
119
|
+
for (const [name, e] of store) {
|
|
120
|
+
if (now >= e.expiresAt)
|
|
121
|
+
continue;
|
|
122
|
+
entries.push({ name, expiresAt: e.expiresAt, keyCount: Object.keys(e.env).length });
|
|
123
|
+
}
|
|
124
|
+
return { ok: true, cmd: 'status', entries };
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Run the broker in the foreground. Spawned detached by ensureAgentRunning via
|
|
130
|
+
* `agents secrets _agent-run`. Holds the store in memory, serves the socket,
|
|
131
|
+
* sweeps expired entries, wipes on screen-lock/sleep, and self-exits when idle.
|
|
132
|
+
*/
|
|
133
|
+
export async function runSecretsAgent() {
|
|
134
|
+
if (!onDarwin())
|
|
135
|
+
return; // nothing to broker without biometry prompts
|
|
136
|
+
// Single-instance guard: O_EXCL pid file. If a live broker already holds it,
|
|
137
|
+
// exit quietly — the existing one keeps serving.
|
|
138
|
+
const pidFile = pidPath();
|
|
139
|
+
try {
|
|
140
|
+
const fd = fs.openSync(pidFile, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY);
|
|
141
|
+
fs.writeSync(fd, String(process.pid));
|
|
142
|
+
fs.closeSync(fd);
|
|
143
|
+
}
|
|
144
|
+
catch (err) {
|
|
145
|
+
if (err?.code === 'EEXIST') {
|
|
146
|
+
const holder = parseInt(fs.readFileSync(pidFile, 'utf-8').trim(), 10);
|
|
147
|
+
if (!isNaN(holder) && isAlive(holder))
|
|
148
|
+
return; // another broker is live
|
|
149
|
+
// Stale pid — reclaim it.
|
|
150
|
+
try {
|
|
151
|
+
fs.unlinkSync(pidFile);
|
|
152
|
+
}
|
|
153
|
+
catch { /* race; fall through */ }
|
|
154
|
+
fs.writeFileSync(pidFile, String(process.pid));
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
throw err;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const store = new Map();
|
|
161
|
+
// emptySince tracks the last moment the store held something; the sweep exits
|
|
162
|
+
// the process once it's been empty for IDLE_EXIT_MS so no idle broker lingers.
|
|
163
|
+
let emptySince = Date.now();
|
|
164
|
+
const sock = socketPath();
|
|
165
|
+
try {
|
|
166
|
+
fs.unlinkSync(sock);
|
|
167
|
+
}
|
|
168
|
+
catch { /* no stale socket */ }
|
|
169
|
+
const sweep = () => {
|
|
170
|
+
const now = Date.now();
|
|
171
|
+
for (const [name, e] of store)
|
|
172
|
+
if (now >= e.expiresAt)
|
|
173
|
+
store.delete(name);
|
|
174
|
+
if (store.size === 0) {
|
|
175
|
+
if (now - emptySince >= IDLE_EXIT_MS)
|
|
176
|
+
shutdown(0);
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
emptySince = now;
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
const handle = (req) => {
|
|
183
|
+
const resp = handleAgentRequest(store, req);
|
|
184
|
+
if (store.size > 0)
|
|
185
|
+
emptySince = Date.now();
|
|
186
|
+
return resp;
|
|
187
|
+
};
|
|
188
|
+
const server = net.createServer((conn) => {
|
|
189
|
+
conn.setEncoding('utf-8');
|
|
190
|
+
let buf = '';
|
|
191
|
+
conn.on('data', (chunk) => {
|
|
192
|
+
buf += chunk;
|
|
193
|
+
let nl;
|
|
194
|
+
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
195
|
+
const line = buf.slice(0, nl);
|
|
196
|
+
buf = buf.slice(nl + 1);
|
|
197
|
+
if (!line.trim())
|
|
198
|
+
continue;
|
|
199
|
+
let resp;
|
|
200
|
+
try {
|
|
201
|
+
resp = handle(JSON.parse(line));
|
|
202
|
+
}
|
|
203
|
+
catch (err) {
|
|
204
|
+
resp = { ok: false, error: err.message };
|
|
205
|
+
}
|
|
206
|
+
conn.write(JSON.stringify(resp) + '\n');
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
conn.on('error', () => { });
|
|
210
|
+
});
|
|
211
|
+
let watcher = null;
|
|
212
|
+
let sweepTimer = null;
|
|
213
|
+
let shuttingDown = false;
|
|
214
|
+
const shutdown = (code) => {
|
|
215
|
+
if (shuttingDown)
|
|
216
|
+
return;
|
|
217
|
+
shuttingDown = true;
|
|
218
|
+
store.clear();
|
|
219
|
+
if (sweepTimer)
|
|
220
|
+
clearInterval(sweepTimer);
|
|
221
|
+
try {
|
|
222
|
+
watcher?.kill();
|
|
223
|
+
}
|
|
224
|
+
catch { /* already gone */ }
|
|
225
|
+
try {
|
|
226
|
+
server.close();
|
|
227
|
+
}
|
|
228
|
+
catch { /* not listening */ }
|
|
229
|
+
try {
|
|
230
|
+
fs.unlinkSync(sock);
|
|
231
|
+
}
|
|
232
|
+
catch { /* gone */ }
|
|
233
|
+
try {
|
|
234
|
+
if (parseInt(fs.readFileSync(pidFile, 'utf-8').trim(), 10) === process.pid)
|
|
235
|
+
fs.unlinkSync(pidFile);
|
|
236
|
+
}
|
|
237
|
+
catch { /* gone */ }
|
|
238
|
+
process.exit(code);
|
|
239
|
+
};
|
|
240
|
+
process.on('SIGTERM', () => shutdown(0));
|
|
241
|
+
process.on('SIGINT', () => shutdown(0));
|
|
242
|
+
await new Promise((resolve, reject) => {
|
|
243
|
+
server.once('error', reject);
|
|
244
|
+
server.listen(sock, () => {
|
|
245
|
+
try {
|
|
246
|
+
fs.chmodSync(sock, 0o600);
|
|
247
|
+
}
|
|
248
|
+
catch { /* dir 0700 already gates it */ }
|
|
249
|
+
resolve();
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
sweepTimer = setInterval(sweep, SWEEP_INTERVAL_MS);
|
|
253
|
+
// Auto-lock on screen-lock / sleep. The signed helper emits LOCK / SLEEP
|
|
254
|
+
// lines; on any of them we wipe everything. If the installed helper predates
|
|
255
|
+
// watch-lock (exits non-zero immediately), we fall back to TTL-only and log
|
|
256
|
+
// nothing — the unlock already warned when lock_on_sleep couldn't be armed.
|
|
257
|
+
try {
|
|
258
|
+
watcher = spawn(getKeychainHelperPath(), ['watch-lock'], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
259
|
+
watcher.stdout?.setEncoding('utf-8');
|
|
260
|
+
watcher.stdout?.on('data', (chunk) => {
|
|
261
|
+
if (/\b(LOCK|SLEEP)\b/.test(chunk)) {
|
|
262
|
+
store.clear();
|
|
263
|
+
emptySince = Date.now();
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
watcher.on('error', () => { watcher = null; });
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
watcher = null;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
// ─── Client ──────────────────────────────────────────────────────────────────
|
|
273
|
+
/** Open the socket, send one request, resolve the one response. Async path —
|
|
274
|
+
* used by the unlock/lock/status commands, which already run in async actions. */
|
|
275
|
+
function request(req, timeoutMs = 2000) {
|
|
276
|
+
return new Promise((resolve) => {
|
|
277
|
+
const conn = net.createConnection(socketPath());
|
|
278
|
+
let buf = '';
|
|
279
|
+
let done = false;
|
|
280
|
+
const finish = (r) => {
|
|
281
|
+
if (done)
|
|
282
|
+
return;
|
|
283
|
+
done = true;
|
|
284
|
+
clearTimeout(timer);
|
|
285
|
+
try {
|
|
286
|
+
conn.destroy();
|
|
287
|
+
}
|
|
288
|
+
catch { /* already closed */ }
|
|
289
|
+
resolve(r);
|
|
290
|
+
};
|
|
291
|
+
const timer = setTimeout(() => finish(null), timeoutMs);
|
|
292
|
+
conn.on('error', () => finish(null));
|
|
293
|
+
conn.on('connect', () => conn.write(JSON.stringify(req) + '\n'));
|
|
294
|
+
conn.setEncoding('utf-8');
|
|
295
|
+
conn.on('data', (chunk) => {
|
|
296
|
+
buf += chunk;
|
|
297
|
+
const nl = buf.indexOf('\n');
|
|
298
|
+
if (nl < 0)
|
|
299
|
+
return;
|
|
300
|
+
try {
|
|
301
|
+
finish(JSON.parse(buf.slice(0, nl)));
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
finish(null);
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
/** True if a broker socket exists at all. Cheap; gates the sync read so the
|
|
310
|
+
* never-unlocked path stays a single stat. */
|
|
311
|
+
export function agentSocketExists() {
|
|
312
|
+
return onDarwin() && fs.existsSync(socketPath());
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Inline node program for the synchronous read fast-path. `readAndResolveBundleEnv`
|
|
316
|
+
* is synchronous and called synchronously everywhere, so we can't await a socket
|
|
317
|
+
* round-trip — but spawning the full CLI to do it would load every command. This
|
|
318
|
+
* minimal `node -e` client connects, asks for one bundle, prints the resolved
|
|
319
|
+
* {bundle, env} as JSON, and exits 0 (hit) / 3 (miss or agent down). argv after
|
|
320
|
+
* -e: [execPath, <socket>, <name>].
|
|
321
|
+
*/
|
|
322
|
+
const SYNC_GET_PROGRAM = `
|
|
323
|
+
const net = require('net');
|
|
324
|
+
const sock = process.argv[1], name = process.argv[2];
|
|
325
|
+
const c = net.createConnection(sock);
|
|
326
|
+
let buf = '';
|
|
327
|
+
const miss = () => { try { c.destroy(); } catch (e) {} process.exit(3); };
|
|
328
|
+
const timer = setTimeout(miss, 2000);
|
|
329
|
+
c.on('error', miss);
|
|
330
|
+
c.on('connect', () => c.write(JSON.stringify({ cmd: 'get', name }) + '\\n'));
|
|
331
|
+
c.setEncoding('utf-8');
|
|
332
|
+
c.on('data', (d) => {
|
|
333
|
+
buf += d;
|
|
334
|
+
const nl = buf.indexOf('\\n');
|
|
335
|
+
if (nl < 0) return;
|
|
336
|
+
clearTimeout(timer);
|
|
337
|
+
let r; try { r = JSON.parse(buf.slice(0, nl)); } catch (e) { return miss(); }
|
|
338
|
+
try { c.destroy(); } catch (e) {}
|
|
339
|
+
if (r && r.ok && r.hit) { process.stdout.write(JSON.stringify({ bundle: r.bundle, env: r.env })); process.exit(0); }
|
|
340
|
+
process.exit(3);
|
|
341
|
+
});
|
|
342
|
+
`;
|
|
343
|
+
/**
|
|
344
|
+
* Synchronous read for the hot path. Returns the cached resolved bundle, or
|
|
345
|
+
* null if the agent isn't running / doesn't hold this bundle / anything fails
|
|
346
|
+
* (soft — caller falls through to the real keychain). macOS only.
|
|
347
|
+
*/
|
|
348
|
+
export function agentGetSync(name) {
|
|
349
|
+
if (!agentSocketExists())
|
|
350
|
+
return null;
|
|
351
|
+
const r = spawnSync(process.execPath, ['-e', SYNC_GET_PROGRAM, socketPath(), name], {
|
|
352
|
+
encoding: 'utf-8',
|
|
353
|
+
timeout: 3000,
|
|
354
|
+
});
|
|
355
|
+
if (r.status !== 0 || !r.stdout)
|
|
356
|
+
return null;
|
|
357
|
+
try {
|
|
358
|
+
const o = JSON.parse(r.stdout);
|
|
359
|
+
if (!o || typeof o !== 'object' || !o.env)
|
|
360
|
+
return null;
|
|
361
|
+
return { bundle: o.bundle, env: o.env };
|
|
362
|
+
}
|
|
363
|
+
catch {
|
|
364
|
+
return null;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
/** True when `secrets.agent.auto` is enabled in agents.yaml. Best-effort; a
|
|
368
|
+
* missing/unreadable meta reads as off. */
|
|
369
|
+
export function secretsAgentAutoEnabled() {
|
|
370
|
+
try {
|
|
371
|
+
return readMeta().secrets?.agent?.auto === true;
|
|
372
|
+
}
|
|
373
|
+
catch {
|
|
374
|
+
return false;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* Fire-and-forget: populate the broker with a freshly-resolved bundle so the
|
|
379
|
+
* NEXT process reads it without a prompt. Used by the auto-cache path after a
|
|
380
|
+
* real keychain read of a `session`-tier bundle. Adds no latency to the caller
|
|
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.
|
|
389
|
+
*/
|
|
390
|
+
export function agentAutoLoadSync(name, bundle, env, ttlMs) {
|
|
391
|
+
if (!onDarwin())
|
|
392
|
+
return;
|
|
393
|
+
try {
|
|
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();
|
|
399
|
+
}
|
|
400
|
+
catch {
|
|
401
|
+
// best-effort: the next read just pops Touch ID as it would today
|
|
402
|
+
}
|
|
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
|
+
}
|
|
431
|
+
/** Store a resolved bundle in the broker. Returns false on transport failure. */
|
|
432
|
+
export async function agentLoad(name, bundle, env, ttlMs) {
|
|
433
|
+
const r = await request({ cmd: 'load', name, bundle, env, ttlMs });
|
|
434
|
+
return r?.ok === true && r.cmd === 'load';
|
|
435
|
+
}
|
|
436
|
+
/** Wipe one bundle (or all if name omitted) from the broker. Returns the count
|
|
437
|
+
* wiped, or 0 when no broker is running. */
|
|
438
|
+
export async function agentLock(name) {
|
|
439
|
+
const r = await request({ cmd: 'lock', name });
|
|
440
|
+
return r?.ok === true && r.cmd === 'lock' ? r.wiped : 0;
|
|
441
|
+
}
|
|
442
|
+
/** List currently-unlocked bundles, or [] when no broker is running. */
|
|
443
|
+
export async function agentStatus() {
|
|
444
|
+
const r = await request({ cmd: 'status' });
|
|
445
|
+
return r?.ok === true && r.cmd === 'status' ? r.entries : [];
|
|
446
|
+
}
|
|
447
|
+
/** Is a broker live and speaking our protocol version? */
|
|
448
|
+
async function agentPing() {
|
|
449
|
+
if (!agentSocketExists())
|
|
450
|
+
return false;
|
|
451
|
+
const r = await request({ cmd: 'ping' });
|
|
452
|
+
return r?.ok === true && r.cmd === 'ping' && r.version === PROTOCOL_VERSION;
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Ensure a broker is running and reachable, spawning one detached if not.
|
|
456
|
+
* Returns true once the socket answers a ping. On protocol-version skew, kills
|
|
457
|
+
* the stale broker and respawns. macOS only.
|
|
458
|
+
*/
|
|
459
|
+
export async function ensureAgentRunning(timeoutMs = 5000) {
|
|
460
|
+
if (!onDarwin())
|
|
461
|
+
return false;
|
|
462
|
+
if (await agentPing())
|
|
463
|
+
return true;
|
|
464
|
+
// Socket exists but ping failed → stale/old broker. Kill it before respawn.
|
|
465
|
+
const stalePid = (() => {
|
|
466
|
+
try {
|
|
467
|
+
return parseInt(fs.readFileSync(pidPath(), 'utf-8').trim(), 10);
|
|
468
|
+
}
|
|
469
|
+
catch {
|
|
470
|
+
return NaN;
|
|
471
|
+
}
|
|
472
|
+
})();
|
|
473
|
+
if (!isNaN(stalePid) && isAlive(stalePid)) {
|
|
474
|
+
try {
|
|
475
|
+
process.kill(stalePid, 'SIGTERM');
|
|
476
|
+
}
|
|
477
|
+
catch { /* already dead */ }
|
|
478
|
+
}
|
|
479
|
+
try {
|
|
480
|
+
fs.unlinkSync(socketPath());
|
|
481
|
+
}
|
|
482
|
+
catch { /* gone */ }
|
|
483
|
+
try {
|
|
484
|
+
fs.unlinkSync(pidPath());
|
|
485
|
+
}
|
|
486
|
+
catch { /* gone */ }
|
|
487
|
+
const { cmd, args } = brokerSpawn();
|
|
488
|
+
const child = spawn(cmd, args, {
|
|
489
|
+
stdio: 'ignore',
|
|
490
|
+
detached: true,
|
|
491
|
+
});
|
|
492
|
+
child.unref();
|
|
493
|
+
const deadline = Date.now() + timeoutMs;
|
|
494
|
+
while (Date.now() < deadline) {
|
|
495
|
+
if (await agentPing())
|
|
496
|
+
return true;
|
|
497
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
498
|
+
}
|
|
499
|
+
return false;
|
|
500
|
+
}
|
|
@@ -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];
|
|
@@ -23,11 +38,25 @@ export interface VarMeta {
|
|
|
23
38
|
/** Singular freeform note. */
|
|
24
39
|
note?: string;
|
|
25
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* How a bundle interacts with the macOS secrets-agent:
|
|
43
|
+
* - `biometry` (default): only an explicit `agents secrets unlock` populates the
|
|
44
|
+
* agent; every other read pops Touch ID. Use for high-value bundles you want
|
|
45
|
+
* to confirm each session.
|
|
46
|
+
* - `session`: eligible for the agent — `unlock`, and (when `secrets.agent.auto`
|
|
47
|
+
* is enabled) the first real keychain read auto-loads it so concurrent runs
|
|
48
|
+
* read it silently.
|
|
49
|
+
*/
|
|
50
|
+
export type SecretsTier = 'biometry' | 'session';
|
|
26
51
|
/** A named set of environment variable definitions backed by various secret providers. */
|
|
27
52
|
export interface SecretsBundle {
|
|
28
53
|
name: string;
|
|
29
54
|
description?: string;
|
|
30
55
|
allow_exec?: boolean;
|
|
56
|
+
/** Which store carries this bundle's items. Absent ⇒ `keychain` (the default). */
|
|
57
|
+
backend?: SecretsBackend;
|
|
58
|
+
/** Secrets-agent interaction tier. Absent ⇒ `biometry` (the safe default). */
|
|
59
|
+
tier?: SecretsTier;
|
|
31
60
|
/** ISO 8601 UTC timestamp. Set once on the first writeBundle() for a bundle. */
|
|
32
61
|
created_at?: string;
|
|
33
62
|
/** ISO 8601 UTC timestamp. Refreshed on every writeBundle(). */
|
|
@@ -61,6 +90,8 @@ export declare function validateSecretType(t: string): asserts t is SecretType;
|
|
|
61
90
|
export declare function validateExpiresFutureDated(iso: string): void;
|
|
62
91
|
export declare function bundleExists(name: string): boolean;
|
|
63
92
|
export declare function readBundle(name: string): SecretsBundle;
|
|
93
|
+
/** The effective tier of a bundle (absent ⇒ `biometry`). */
|
|
94
|
+
export declare function bundleTier(bundle: SecretsBundle): SecretsTier;
|
|
64
95
|
export declare function writeBundle(bundle: SecretsBundle): void;
|
|
65
96
|
export declare function deleteBundle(name: string): boolean;
|
|
66
97
|
export declare function listBundles(): SecretsBundle[];
|
|
@@ -80,6 +111,13 @@ export interface ResolveBundleOptions {
|
|
|
80
111
|
* about to read the bundle.
|
|
81
112
|
*/
|
|
82
113
|
caller?: string;
|
|
114
|
+
/**
|
|
115
|
+
* Skip the secrets-agent fast-path and read straight from the keychain
|
|
116
|
+
* (popping Touch ID). Set by callers that must NOT serve a cached snapshot —
|
|
117
|
+
* `unlock` (which populates the agent in the first place) and any flow that
|
|
118
|
+
* needs live values. Also honored via AGENTS_SECRETS_NO_AGENT=1.
|
|
119
|
+
*/
|
|
120
|
+
noAgent?: boolean;
|
|
83
121
|
}
|
|
84
122
|
export declare function resolveBundleEnv(bundle: SecretsBundle, _opts?: ResolveBundleOptions): Record<string, string>;
|
|
85
123
|
/**
|
|
@@ -135,6 +173,19 @@ export interface RenameOptions {
|
|
|
135
173
|
* a safe no-op for the source items.
|
|
136
174
|
*/
|
|
137
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
|
+
};
|
|
138
189
|
export declare function keychainItemsForBundle(bundle: SecretsBundle): Array<{
|
|
139
190
|
key: string;
|
|
140
191
|
item: string;
|