@phnx-labs/agents-cli 1.20.16 → 1.20.18
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 +250 -4
- package/dist/commands/sessions.js +4 -0
- package/dist/commands/sync.d.ts +10 -3
- package/dist/commands/sync.js +72 -9
- 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/hooks.js +12 -0
- 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/plugin-marketplace.js +16 -6
- 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 +134 -0
- package/dist/lib/secrets/agent.js +501 -0
- package/dist/lib/secrets/bundles.d.ts +21 -0
- package/dist/lib/secrets/bundles.js +43 -0
- package/dist/lib/secrets/drivers/rush.d.ts +14 -0
- package/dist/lib/secrets/drivers/rush.js +84 -0
- package/dist/lib/secrets/linux.js +88 -10
- package/dist/lib/secrets/sync-backend.d.ts +48 -0
- package/dist/lib/secrets/sync-backend.js +13 -0
- package/dist/lib/secrets/sync.d.ts +15 -23
- package/dist/lib/secrets/sync.js +31 -66
- 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/sync-umbrella.d.ts +76 -0
- package/dist/lib/sync-umbrella.js +125 -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,501 @@
|
|
|
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 to run the broker, so a side-by-side dev build
|
|
65
|
+
* spawns its own broker rather than the registry-installed one. We always go
|
|
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
|
+
* as `node dist/index.js`, no +x), so spawning it directly EACCES'd.
|
|
69
|
+
*/
|
|
70
|
+
function brokerSpawn() {
|
|
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, 'secrets', '_agent-run'] };
|
|
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: ['secrets', '_agent-run'] };
|
|
82
|
+
}
|
|
83
|
+
// ─── Broker server (runs in the detached `secrets _agent-run` process) ───────
|
|
84
|
+
/**
|
|
85
|
+
* Pure request handler over the in-memory store. Extracted so the store
|
|
86
|
+
* semantics (lazy expiry on get/status, lock-one vs lock-all, load TTL) are
|
|
87
|
+
* unit-testable with a controlled `now`, without a socket or a spawned process.
|
|
88
|
+
* Mutates `store` in place; returns the wire response.
|
|
89
|
+
*/
|
|
90
|
+
export function handleAgentRequest(store, req, now = Date.now()) {
|
|
91
|
+
switch (req.cmd) {
|
|
92
|
+
case 'ping':
|
|
93
|
+
return { ok: true, cmd: 'ping', version: PROTOCOL_VERSION };
|
|
94
|
+
case 'get': {
|
|
95
|
+
const e = store.get(req.name);
|
|
96
|
+
if (!e || now >= e.expiresAt) {
|
|
97
|
+
if (e)
|
|
98
|
+
store.delete(req.name); // drop expired on read
|
|
99
|
+
return { ok: true, cmd: 'get', hit: false };
|
|
100
|
+
}
|
|
101
|
+
return { ok: true, cmd: 'get', hit: true, bundle: e.bundle, env: e.env };
|
|
102
|
+
}
|
|
103
|
+
case 'load':
|
|
104
|
+
store.set(req.name, { bundle: req.bundle, env: req.env, expiresAt: now + req.ttlMs });
|
|
105
|
+
return { ok: true, cmd: 'load' };
|
|
106
|
+
case 'lock': {
|
|
107
|
+
if (req.name) {
|
|
108
|
+
return { ok: true, cmd: 'lock', wiped: store.delete(req.name) ? 1 : 0 };
|
|
109
|
+
}
|
|
110
|
+
const wiped = store.size;
|
|
111
|
+
store.clear();
|
|
112
|
+
return { ok: true, cmd: 'lock', wiped };
|
|
113
|
+
}
|
|
114
|
+
case 'status': {
|
|
115
|
+
const entries = [];
|
|
116
|
+
for (const [name, e] of store) {
|
|
117
|
+
if (now >= e.expiresAt)
|
|
118
|
+
continue;
|
|
119
|
+
entries.push({ name, expiresAt: e.expiresAt, keyCount: Object.keys(e.env).length });
|
|
120
|
+
}
|
|
121
|
+
return { ok: true, cmd: 'status', entries };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Run the broker in the foreground. Spawned detached by ensureAgentRunning via
|
|
127
|
+
* `agents secrets _agent-run`. Holds the store in memory, serves the socket,
|
|
128
|
+
* sweeps expired entries, wipes on screen-lock/sleep, and self-exits when idle.
|
|
129
|
+
*/
|
|
130
|
+
export async function runSecretsAgent() {
|
|
131
|
+
if (!onDarwin())
|
|
132
|
+
return; // nothing to broker without biometry prompts
|
|
133
|
+
// Single-instance guard: O_EXCL pid file. If a live broker already holds it,
|
|
134
|
+
// exit quietly — the existing one keeps serving.
|
|
135
|
+
const pidFile = pidPath();
|
|
136
|
+
try {
|
|
137
|
+
const fd = fs.openSync(pidFile, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY);
|
|
138
|
+
fs.writeSync(fd, String(process.pid));
|
|
139
|
+
fs.closeSync(fd);
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
if (err?.code === 'EEXIST') {
|
|
143
|
+
const holder = parseInt(fs.readFileSync(pidFile, 'utf-8').trim(), 10);
|
|
144
|
+
if (!isNaN(holder) && isAlive(holder))
|
|
145
|
+
return; // another broker is live
|
|
146
|
+
// Stale pid — reclaim it.
|
|
147
|
+
try {
|
|
148
|
+
fs.unlinkSync(pidFile);
|
|
149
|
+
}
|
|
150
|
+
catch { /* race; fall through */ }
|
|
151
|
+
fs.writeFileSync(pidFile, String(process.pid));
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
throw err;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const store = new Map();
|
|
158
|
+
// emptySince tracks the last moment the store held something; the sweep exits
|
|
159
|
+
// the process once it's been empty for IDLE_EXIT_MS so no idle broker lingers.
|
|
160
|
+
let emptySince = Date.now();
|
|
161
|
+
const sock = socketPath();
|
|
162
|
+
try {
|
|
163
|
+
fs.unlinkSync(sock);
|
|
164
|
+
}
|
|
165
|
+
catch { /* no stale socket */ }
|
|
166
|
+
const sweep = () => {
|
|
167
|
+
const now = Date.now();
|
|
168
|
+
for (const [name, e] of store)
|
|
169
|
+
if (now >= e.expiresAt)
|
|
170
|
+
store.delete(name);
|
|
171
|
+
if (store.size === 0) {
|
|
172
|
+
if (now - emptySince >= IDLE_EXIT_MS)
|
|
173
|
+
shutdown(0);
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
emptySince = now;
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
const handle = (req) => {
|
|
180
|
+
const resp = handleAgentRequest(store, req);
|
|
181
|
+
if (store.size > 0)
|
|
182
|
+
emptySince = Date.now();
|
|
183
|
+
return resp;
|
|
184
|
+
};
|
|
185
|
+
const server = net.createServer((conn) => {
|
|
186
|
+
conn.setEncoding('utf-8');
|
|
187
|
+
let buf = '';
|
|
188
|
+
conn.on('data', (chunk) => {
|
|
189
|
+
buf += chunk;
|
|
190
|
+
let nl;
|
|
191
|
+
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
192
|
+
const line = buf.slice(0, nl);
|
|
193
|
+
buf = buf.slice(nl + 1);
|
|
194
|
+
if (!line.trim())
|
|
195
|
+
continue;
|
|
196
|
+
let resp;
|
|
197
|
+
try {
|
|
198
|
+
resp = handle(JSON.parse(line));
|
|
199
|
+
}
|
|
200
|
+
catch (err) {
|
|
201
|
+
resp = { ok: false, error: err.message };
|
|
202
|
+
}
|
|
203
|
+
conn.write(JSON.stringify(resp) + '\n');
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
conn.on('error', () => { });
|
|
207
|
+
});
|
|
208
|
+
let watcher = null;
|
|
209
|
+
let sweepTimer = null;
|
|
210
|
+
let shuttingDown = false;
|
|
211
|
+
const shutdown = (code) => {
|
|
212
|
+
if (shuttingDown)
|
|
213
|
+
return;
|
|
214
|
+
shuttingDown = true;
|
|
215
|
+
store.clear();
|
|
216
|
+
if (sweepTimer)
|
|
217
|
+
clearInterval(sweepTimer);
|
|
218
|
+
try {
|
|
219
|
+
watcher?.kill();
|
|
220
|
+
}
|
|
221
|
+
catch { /* already gone */ }
|
|
222
|
+
try {
|
|
223
|
+
server.close();
|
|
224
|
+
}
|
|
225
|
+
catch { /* not listening */ }
|
|
226
|
+
try {
|
|
227
|
+
fs.unlinkSync(sock);
|
|
228
|
+
}
|
|
229
|
+
catch { /* gone */ }
|
|
230
|
+
try {
|
|
231
|
+
if (parseInt(fs.readFileSync(pidFile, 'utf-8').trim(), 10) === process.pid)
|
|
232
|
+
fs.unlinkSync(pidFile);
|
|
233
|
+
}
|
|
234
|
+
catch { /* gone */ }
|
|
235
|
+
process.exit(code);
|
|
236
|
+
};
|
|
237
|
+
process.on('SIGTERM', () => shutdown(0));
|
|
238
|
+
process.on('SIGINT', () => shutdown(0));
|
|
239
|
+
await new Promise((resolve, reject) => {
|
|
240
|
+
server.once('error', reject);
|
|
241
|
+
server.listen(sock, () => {
|
|
242
|
+
try {
|
|
243
|
+
fs.chmodSync(sock, 0o600);
|
|
244
|
+
}
|
|
245
|
+
catch { /* dir 0700 already gates it */ }
|
|
246
|
+
resolve();
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
sweepTimer = setInterval(sweep, SWEEP_INTERVAL_MS);
|
|
250
|
+
// Auto-lock on screen-lock / sleep. The signed helper emits LOCK / SLEEP
|
|
251
|
+
// lines; on any of them we wipe everything. If the installed helper predates
|
|
252
|
+
// watch-lock (exits non-zero immediately), we fall back to TTL-only and log
|
|
253
|
+
// nothing — the unlock already warned when lock_on_sleep couldn't be armed.
|
|
254
|
+
try {
|
|
255
|
+
watcher = spawn(getKeychainHelperPath(), ['watch-lock'], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
256
|
+
watcher.stdout?.setEncoding('utf-8');
|
|
257
|
+
watcher.stdout?.on('data', (chunk) => {
|
|
258
|
+
if (/\b(LOCK|SLEEP)\b/.test(chunk)) {
|
|
259
|
+
store.clear();
|
|
260
|
+
emptySince = Date.now();
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
watcher.on('error', () => { watcher = null; });
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
watcher = null;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
// ─── Client ──────────────────────────────────────────────────────────────────
|
|
270
|
+
/** Open the socket, send one request, resolve the one response. Async path —
|
|
271
|
+
* used by the unlock/lock/status commands, which already run in async actions. */
|
|
272
|
+
function request(req, timeoutMs = 2000) {
|
|
273
|
+
return new Promise((resolve) => {
|
|
274
|
+
const conn = net.createConnection(socketPath());
|
|
275
|
+
let buf = '';
|
|
276
|
+
let done = false;
|
|
277
|
+
const finish = (r) => {
|
|
278
|
+
if (done)
|
|
279
|
+
return;
|
|
280
|
+
done = true;
|
|
281
|
+
clearTimeout(timer);
|
|
282
|
+
try {
|
|
283
|
+
conn.destroy();
|
|
284
|
+
}
|
|
285
|
+
catch { /* already closed */ }
|
|
286
|
+
resolve(r);
|
|
287
|
+
};
|
|
288
|
+
const timer = setTimeout(() => finish(null), timeoutMs);
|
|
289
|
+
conn.on('error', () => finish(null));
|
|
290
|
+
conn.on('connect', () => conn.write(JSON.stringify(req) + '\n'));
|
|
291
|
+
conn.setEncoding('utf-8');
|
|
292
|
+
conn.on('data', (chunk) => {
|
|
293
|
+
buf += chunk;
|
|
294
|
+
const nl = buf.indexOf('\n');
|
|
295
|
+
if (nl < 0)
|
|
296
|
+
return;
|
|
297
|
+
try {
|
|
298
|
+
finish(JSON.parse(buf.slice(0, nl)));
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
finish(null);
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
/** True if a broker socket exists at all. Cheap; gates the sync read so the
|
|
307
|
+
* never-unlocked path stays a single stat. */
|
|
308
|
+
export function agentSocketExists() {
|
|
309
|
+
return onDarwin() && fs.existsSync(socketPath());
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Inline node program for the synchronous read fast-path. `readAndResolveBundleEnv`
|
|
313
|
+
* is synchronous and called synchronously everywhere, so we can't await a socket
|
|
314
|
+
* round-trip — but spawning the full CLI to do it would load every command. This
|
|
315
|
+
* minimal `node -e` client connects, asks for one bundle, prints the resolved
|
|
316
|
+
* {bundle, env} as JSON, and exits 0 (hit) / 3 (miss or agent down). argv after
|
|
317
|
+
* -e: [execPath, <socket>, <name>].
|
|
318
|
+
*/
|
|
319
|
+
const SYNC_GET_PROGRAM = `
|
|
320
|
+
const net = require('net');
|
|
321
|
+
const sock = process.argv[1], name = process.argv[2];
|
|
322
|
+
const c = net.createConnection(sock);
|
|
323
|
+
let buf = '';
|
|
324
|
+
const miss = () => { try { c.destroy(); } catch (e) {} process.exit(3); };
|
|
325
|
+
const timer = setTimeout(miss, 2000);
|
|
326
|
+
c.on('error', miss);
|
|
327
|
+
c.on('connect', () => c.write(JSON.stringify({ cmd: 'get', name }) + '\\n'));
|
|
328
|
+
c.setEncoding('utf-8');
|
|
329
|
+
c.on('data', (d) => {
|
|
330
|
+
buf += d;
|
|
331
|
+
const nl = buf.indexOf('\\n');
|
|
332
|
+
if (nl < 0) return;
|
|
333
|
+
clearTimeout(timer);
|
|
334
|
+
let r; try { r = JSON.parse(buf.slice(0, nl)); } catch (e) { return miss(); }
|
|
335
|
+
try { c.destroy(); } catch (e) {}
|
|
336
|
+
if (r && r.ok && r.hit) { process.stdout.write(JSON.stringify({ bundle: r.bundle, env: r.env })); process.exit(0); }
|
|
337
|
+
process.exit(3);
|
|
338
|
+
});
|
|
339
|
+
`;
|
|
340
|
+
/**
|
|
341
|
+
* Synchronous read for the hot path. Returns the cached resolved bundle, or
|
|
342
|
+
* null if the agent isn't running / doesn't hold this bundle / anything fails
|
|
343
|
+
* (soft — caller falls through to the real keychain). macOS only.
|
|
344
|
+
*/
|
|
345
|
+
export function agentGetSync(name) {
|
|
346
|
+
if (!agentSocketExists())
|
|
347
|
+
return null;
|
|
348
|
+
const r = spawnSync(process.execPath, ['-e', SYNC_GET_PROGRAM, socketPath(), name], {
|
|
349
|
+
encoding: 'utf-8',
|
|
350
|
+
timeout: 3000,
|
|
351
|
+
});
|
|
352
|
+
if (r.status !== 0 || !r.stdout)
|
|
353
|
+
return null;
|
|
354
|
+
try {
|
|
355
|
+
const o = JSON.parse(r.stdout);
|
|
356
|
+
if (!o || typeof o !== 'object' || !o.env)
|
|
357
|
+
return null;
|
|
358
|
+
return { bundle: o.bundle, env: o.env };
|
|
359
|
+
}
|
|
360
|
+
catch {
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
/** True when `secrets.agent.auto` is enabled in agents.yaml. Best-effort; a
|
|
365
|
+
* missing/unreadable meta reads as off. */
|
|
366
|
+
export function secretsAgentAutoEnabled() {
|
|
367
|
+
try {
|
|
368
|
+
return readMeta().secrets?.agent?.auto === true;
|
|
369
|
+
}
|
|
370
|
+
catch {
|
|
371
|
+
return false;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
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
|
+
/**
|
|
406
|
+
* Fire-and-forget: populate the broker with a freshly-resolved bundle so the
|
|
407
|
+
* NEXT process reads it without a prompt. Used by the auto-cache path after a
|
|
408
|
+
* real keychain read of a `session`-tier bundle. Adds no latency to the caller
|
|
409
|
+
* — it spawns the agent (if needed) and a detached loader, both unref'd, then
|
|
410
|
+
* returns immediately. Entirely best-effort; never throws. macOS only.
|
|
411
|
+
*/
|
|
412
|
+
export function agentAutoLoadSync(name, bundle, env, ttlMs) {
|
|
413
|
+
if (!onDarwin())
|
|
414
|
+
return;
|
|
415
|
+
try {
|
|
416
|
+
if (!agentSocketExists()) {
|
|
417
|
+
const { cmd, args } = brokerSpawn();
|
|
418
|
+
spawn(cmd, args, { stdio: 'ignore', detached: true }).unref();
|
|
419
|
+
}
|
|
420
|
+
const loader = spawn(process.execPath, ['-e', DETACHED_LOAD_PROGRAM, socketPath()], {
|
|
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();
|
|
427
|
+
}
|
|
428
|
+
catch {
|
|
429
|
+
// best-effort: the next read just pops Touch ID as it would today
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
/** Store a resolved bundle in the broker. Returns false on transport failure. */
|
|
433
|
+
export async function agentLoad(name, bundle, env, ttlMs) {
|
|
434
|
+
const r = await request({ cmd: 'load', name, bundle, env, ttlMs });
|
|
435
|
+
return r?.ok === true && r.cmd === 'load';
|
|
436
|
+
}
|
|
437
|
+
/** Wipe one bundle (or all if name omitted) from the broker. Returns the count
|
|
438
|
+
* wiped, or 0 when no broker is running. */
|
|
439
|
+
export async function agentLock(name) {
|
|
440
|
+
const r = await request({ cmd: 'lock', name });
|
|
441
|
+
return r?.ok === true && r.cmd === 'lock' ? r.wiped : 0;
|
|
442
|
+
}
|
|
443
|
+
/** List currently-unlocked bundles, or [] when no broker is running. */
|
|
444
|
+
export async function agentStatus() {
|
|
445
|
+
const r = await request({ cmd: 'status' });
|
|
446
|
+
return r?.ok === true && r.cmd === 'status' ? r.entries : [];
|
|
447
|
+
}
|
|
448
|
+
/** Is a broker live and speaking our protocol version? */
|
|
449
|
+
async function agentPing() {
|
|
450
|
+
if (!agentSocketExists())
|
|
451
|
+
return false;
|
|
452
|
+
const r = await request({ cmd: 'ping' });
|
|
453
|
+
return r?.ok === true && r.cmd === 'ping' && r.version === PROTOCOL_VERSION;
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Ensure a broker is running and reachable, spawning one detached if not.
|
|
457
|
+
* Returns true once the socket answers a ping. On protocol-version skew, kills
|
|
458
|
+
* the stale broker and respawns. macOS only.
|
|
459
|
+
*/
|
|
460
|
+
export async function ensureAgentRunning(timeoutMs = 5000) {
|
|
461
|
+
if (!onDarwin())
|
|
462
|
+
return false;
|
|
463
|
+
if (await agentPing())
|
|
464
|
+
return true;
|
|
465
|
+
// Socket exists but ping failed → stale/old broker. Kill it before respawn.
|
|
466
|
+
const stalePid = (() => {
|
|
467
|
+
try {
|
|
468
|
+
return parseInt(fs.readFileSync(pidPath(), 'utf-8').trim(), 10);
|
|
469
|
+
}
|
|
470
|
+
catch {
|
|
471
|
+
return NaN;
|
|
472
|
+
}
|
|
473
|
+
})();
|
|
474
|
+
if (!isNaN(stalePid) && isAlive(stalePid)) {
|
|
475
|
+
try {
|
|
476
|
+
process.kill(stalePid, 'SIGTERM');
|
|
477
|
+
}
|
|
478
|
+
catch { /* already dead */ }
|
|
479
|
+
}
|
|
480
|
+
try {
|
|
481
|
+
fs.unlinkSync(socketPath());
|
|
482
|
+
}
|
|
483
|
+
catch { /* gone */ }
|
|
484
|
+
try {
|
|
485
|
+
fs.unlinkSync(pidPath());
|
|
486
|
+
}
|
|
487
|
+
catch { /* gone */ }
|
|
488
|
+
const { cmd, args } = brokerSpawn();
|
|
489
|
+
const child = spawn(cmd, args, {
|
|
490
|
+
stdio: 'ignore',
|
|
491
|
+
detached: true,
|
|
492
|
+
});
|
|
493
|
+
child.unref();
|
|
494
|
+
const deadline = Date.now() + timeoutMs;
|
|
495
|
+
while (Date.now() < deadline) {
|
|
496
|
+
if (await agentPing())
|
|
497
|
+
return true;
|
|
498
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
499
|
+
}
|
|
500
|
+
return false;
|
|
501
|
+
}
|
|
@@ -23,11 +23,23 @@ export interface VarMeta {
|
|
|
23
23
|
/** Singular freeform note. */
|
|
24
24
|
note?: string;
|
|
25
25
|
}
|
|
26
|
+
/**
|
|
27
|
+
* How a bundle interacts with the macOS secrets-agent:
|
|
28
|
+
* - `biometry` (default): only an explicit `agents secrets unlock` populates the
|
|
29
|
+
* agent; every other read pops Touch ID. Use for high-value bundles you want
|
|
30
|
+
* to confirm each session.
|
|
31
|
+
* - `session`: eligible for the agent — `unlock`, and (when `secrets.agent.auto`
|
|
32
|
+
* is enabled) the first real keychain read auto-loads it so concurrent runs
|
|
33
|
+
* read it silently.
|
|
34
|
+
*/
|
|
35
|
+
export type SecretsTier = 'biometry' | 'session';
|
|
26
36
|
/** A named set of environment variable definitions backed by various secret providers. */
|
|
27
37
|
export interface SecretsBundle {
|
|
28
38
|
name: string;
|
|
29
39
|
description?: string;
|
|
30
40
|
allow_exec?: boolean;
|
|
41
|
+
/** Secrets-agent interaction tier. Absent ⇒ `biometry` (the safe default). */
|
|
42
|
+
tier?: SecretsTier;
|
|
31
43
|
/** ISO 8601 UTC timestamp. Set once on the first writeBundle() for a bundle. */
|
|
32
44
|
created_at?: string;
|
|
33
45
|
/** ISO 8601 UTC timestamp. Refreshed on every writeBundle(). */
|
|
@@ -61,6 +73,8 @@ export declare function validateSecretType(t: string): asserts t is SecretType;
|
|
|
61
73
|
export declare function validateExpiresFutureDated(iso: string): void;
|
|
62
74
|
export declare function bundleExists(name: string): boolean;
|
|
63
75
|
export declare function readBundle(name: string): SecretsBundle;
|
|
76
|
+
/** The effective tier of a bundle (absent ⇒ `biometry`). */
|
|
77
|
+
export declare function bundleTier(bundle: SecretsBundle): SecretsTier;
|
|
64
78
|
export declare function writeBundle(bundle: SecretsBundle): void;
|
|
65
79
|
export declare function deleteBundle(name: string): boolean;
|
|
66
80
|
export declare function listBundles(): SecretsBundle[];
|
|
@@ -80,6 +94,13 @@ export interface ResolveBundleOptions {
|
|
|
80
94
|
* about to read the bundle.
|
|
81
95
|
*/
|
|
82
96
|
caller?: string;
|
|
97
|
+
/**
|
|
98
|
+
* Skip the secrets-agent fast-path and read straight from the keychain
|
|
99
|
+
* (popping Touch ID). Set by callers that must NOT serve a cached snapshot —
|
|
100
|
+
* `unlock` (which populates the agent in the first place) and any flow that
|
|
101
|
+
* needs live values. Also honored via AGENTS_SECRETS_NO_AGENT=1.
|
|
102
|
+
*/
|
|
103
|
+
noAgent?: boolean;
|
|
83
104
|
}
|
|
84
105
|
export declare function resolveBundleEnv(bundle: SecretsBundle, _opts?: ResolveBundleOptions): Record<string, string>;
|
|
85
106
|
/**
|
|
@@ -17,6 +17,7 @@ import * as path from 'path';
|
|
|
17
17
|
import * as yaml from 'yaml';
|
|
18
18
|
import { deleteKeychainToken, getKeychainToken, getKeychainTokens, hasKeychainToken, listKeychainItems, parseBundleValue, resolveRef, secretsKeychainItem, setKeychainToken, } from './index.js';
|
|
19
19
|
import { emit } from '../events.js';
|
|
20
|
+
import { agentGetSync, agentAutoLoadSync, secretsAgentAutoEnabled, DEFAULT_TTL_MS } from './agent.js';
|
|
20
21
|
/** Allowed values for a secret's `type` metadata field. */
|
|
21
22
|
export const SECRET_TYPES = [
|
|
22
23
|
'api-key',
|
|
@@ -142,6 +143,7 @@ export function readBundle(name) {
|
|
|
142
143
|
name,
|
|
143
144
|
description: parsed.description,
|
|
144
145
|
allow_exec: Boolean(parsed.allow_exec),
|
|
146
|
+
tier: parseTier(parsed.tier),
|
|
145
147
|
vars: parsed.vars && typeof parsed.vars === 'object' ? parsed.vars : {},
|
|
146
148
|
};
|
|
147
149
|
if (typeof parsed.created_at === 'string')
|
|
@@ -158,6 +160,14 @@ export function readBundle(name) {
|
|
|
158
160
|
}
|
|
159
161
|
return bundle;
|
|
160
162
|
}
|
|
163
|
+
/** Normalize a persisted `tier` value; anything but `session` ⇒ default tier. */
|
|
164
|
+
function parseTier(raw) {
|
|
165
|
+
return raw === 'session' ? 'session' : undefined;
|
|
166
|
+
}
|
|
167
|
+
/** The effective tier of a bundle (absent ⇒ `biometry`). */
|
|
168
|
+
export function bundleTier(bundle) {
|
|
169
|
+
return bundle.tier ?? 'biometry';
|
|
170
|
+
}
|
|
161
171
|
export function writeBundle(bundle) {
|
|
162
172
|
validateBundleName(bundle.name);
|
|
163
173
|
for (const key of Object.keys(bundle.vars)) {
|
|
@@ -191,6 +201,7 @@ export function writeBundle(bundle) {
|
|
|
191
201
|
const payload = {
|
|
192
202
|
description: bundle.description,
|
|
193
203
|
allow_exec: bundle.allow_exec ? true : undefined,
|
|
204
|
+
tier: bundle.tier === 'session' ? 'session' : undefined,
|
|
194
205
|
created_at: bundle.created_at,
|
|
195
206
|
updated_at: bundle.updated_at,
|
|
196
207
|
last_used: bundle.last_used,
|
|
@@ -249,6 +260,7 @@ export function listBundles() {
|
|
|
249
260
|
name,
|
|
250
261
|
description: parsed.description,
|
|
251
262
|
allow_exec: Boolean(parsed.allow_exec),
|
|
263
|
+
tier: parseTier(parsed.tier),
|
|
252
264
|
vars: parsed.vars && typeof parsed.vars === 'object' ? parsed.vars : {},
|
|
253
265
|
};
|
|
254
266
|
if (typeof parsed.created_at === 'string')
|
|
@@ -375,6 +387,25 @@ export function resolveBundleEnv(bundle, _opts = {}) {
|
|
|
375
387
|
*/
|
|
376
388
|
export function readAndResolveBundleEnv(name, opts = {}) {
|
|
377
389
|
validateBundleName(name);
|
|
390
|
+
// Fast-path: if the secrets-agent holds this bundle (user ran
|
|
391
|
+
// `agents secrets unlock <name>`), return the cached snapshot with no Touch
|
|
392
|
+
// ID. Soft — any failure falls through to the real keychain read below. macOS
|
|
393
|
+
// only; the never-unlocked path is a single stat (agentSocketExists) so it
|
|
394
|
+
// costs nothing when the agent isn't running.
|
|
395
|
+
if (!opts.noAgent && process.env.AGENTS_SECRETS_NO_AGENT !== '1') {
|
|
396
|
+
const hit = agentGetSync(name);
|
|
397
|
+
if (hit) {
|
|
398
|
+
stampLastUsed(hit.bundle);
|
|
399
|
+
emit('secrets.get', {
|
|
400
|
+
bundle: name,
|
|
401
|
+
caller: opts.caller,
|
|
402
|
+
status: 'success',
|
|
403
|
+
source: 'agent',
|
|
404
|
+
keyCount: Object.keys(hit.env).length,
|
|
405
|
+
});
|
|
406
|
+
return hit;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
378
409
|
const metaItem = bundleMetaItem(name);
|
|
379
410
|
const bundleSecretPrefix = `${SECRETS_ITEM_PREFIX}${name}.`;
|
|
380
411
|
let secretItems;
|
|
@@ -407,6 +438,7 @@ export function readAndResolveBundleEnv(name, opts = {}) {
|
|
|
407
438
|
name,
|
|
408
439
|
description: parsed.description,
|
|
409
440
|
allow_exec: Boolean(parsed.allow_exec),
|
|
441
|
+
tier: parseTier(parsed.tier),
|
|
410
442
|
vars: parsed.vars && typeof parsed.vars === 'object' ? parsed.vars : {},
|
|
411
443
|
};
|
|
412
444
|
if (typeof parsed.created_at === 'string')
|
|
@@ -476,6 +508,17 @@ export function readAndResolveBundleEnv(name, opts = {}) {
|
|
|
476
508
|
}
|
|
477
509
|
}
|
|
478
510
|
emitReadAudit('success');
|
|
511
|
+
// Auto-cache: this was a real keychain read (the agent fast-path returned
|
|
512
|
+
// earlier on a hit). If the bundle opts into the session tier and the user
|
|
513
|
+
// enabled `secrets.agent.auto`, populate the broker in the background so the
|
|
514
|
+
// next concurrent run reads silently. Skipped when noAgent (e.g. `unlock`,
|
|
515
|
+
// which loads the agent itself). Fire-and-forget — never blocks this read.
|
|
516
|
+
if (!opts.noAgent &&
|
|
517
|
+
process.env.AGENTS_SECRETS_NO_AGENT !== '1' &&
|
|
518
|
+
bundleTier(bundle) === 'session' &&
|
|
519
|
+
secretsAgentAutoEnabled()) {
|
|
520
|
+
agentAutoLoadSync(name, bundle, env, DEFAULT_TTL_MS);
|
|
521
|
+
}
|
|
479
522
|
return { bundle, env };
|
|
480
523
|
}
|
|
481
524
|
catch (err) {
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rush `SyncBackend` driver — the original (and currently default) transport
|
|
3
|
+
* for `agents secrets push/pull`. Talks to api.prix.dev and authenticates with
|
|
4
|
+
* the session token written by `rush login` (`~/.rush/user.yaml`).
|
|
5
|
+
*
|
|
6
|
+
* This is the ONE place in the secrets module allowed to reference Rush
|
|
7
|
+
* (api.prix.dev / ~/.rush). It is an opt-in driver kept for backwards
|
|
8
|
+
* compatibility with bundles already pushed to Rush; `sync.ts` selects it as
|
|
9
|
+
* the default but the transport seam (`SyncBackend`) lets other backends drop
|
|
10
|
+
* in without touching the crypto or push/pull logic.
|
|
11
|
+
*/
|
|
12
|
+
import type { SyncBackend } from '../sync-backend.js';
|
|
13
|
+
/** The Rush transport. Plaintext never reaches here — only ciphertext envelopes. */
|
|
14
|
+
export declare const rushSyncBackend: SyncBackend;
|