@phnx-labs/agents-cli 1.22.27 → 1.22.28
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 +49 -0
- package/README.md +62 -26
- package/dist/bin/agents +0 -0
- package/dist/commands/exec.js +54 -10
- package/dist/commands/hosts.js +4 -3
- package/dist/commands/perf.d.ts +10 -0
- package/dist/commands/perf.js +14 -6
- package/dist/commands/projects.js +24 -10
- package/dist/commands/routines.js +31 -4
- package/dist/commands/run-account-picker.d.ts +37 -0
- package/dist/commands/run-account-picker.js +101 -17
- package/dist/commands/secrets.js +86 -2
- package/dist/commands/sessions.d.ts +26 -6
- package/dist/commands/sessions.js +46 -12
- package/dist/commands/teams.js +19 -8
- package/dist/lib/activity.js +45 -99
- package/dist/lib/claude-account-token.js +38 -11
- package/dist/lib/cloud/host.js +1 -0
- package/dist/lib/devices/resolve-target.d.ts +1 -0
- package/dist/lib/devices/resolve-target.js +9 -2
- package/dist/lib/events.d.ts +1 -1
- package/dist/lib/events.js +2 -2
- package/dist/lib/git.d.ts +9 -3
- package/dist/lib/git.js +39 -22
- package/dist/lib/hooks/profile.d.ts +12 -1
- package/dist/lib/hooks/profile.js +6 -1
- package/dist/lib/hooks.js +4 -1
- package/dist/lib/hosts/dispatch.d.ts +10 -0
- package/dist/lib/hosts/dispatch.js +78 -22
- package/dist/lib/hosts/logs.js +11 -1
- package/dist/lib/hosts/passthrough.d.ts +5 -0
- package/dist/lib/hosts/passthrough.js +5 -2
- package/dist/lib/hosts/progress.d.ts +10 -0
- package/dist/lib/hosts/progress.js +37 -5
- package/dist/lib/hosts/ready.d.ts +41 -2
- package/dist/lib/hosts/ready.js +100 -12
- package/dist/lib/hosts/reconcile.d.ts +2 -1
- package/dist/lib/hosts/reconcile.js +13 -5
- package/dist/lib/hosts/reconnect.js +5 -3
- package/dist/lib/hosts/registry.js +1 -0
- package/dist/lib/hosts/tasks.d.ts +1 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/snapshot.js +15 -2
- package/dist/lib/models.d.ts +18 -11
- package/dist/lib/models.js +19 -15
- package/dist/lib/perf/db.js +16 -3
- package/dist/lib/perf/types.d.ts +12 -1
- package/dist/lib/project-probe.d.ts +22 -3
- package/dist/lib/project-probe.js +105 -17
- package/dist/lib/project-status.d.ts +9 -0
- package/dist/lib/project-status.js +15 -0
- package/dist/lib/refresh.js +22 -11
- package/dist/lib/rotate.d.ts +22 -0
- package/dist/lib/rotate.js +26 -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 +19 -3
- package/dist/lib/secrets/agent.js +58 -20
- package/dist/lib/secrets/audit.d.ts +1 -1
- package/dist/lib/secrets/audit.js +2 -0
- package/dist/lib/secrets/bundles.d.ts +3 -3
- package/dist/lib/secrets/bundles.js +15 -5
- package/dist/lib/secrets/filestore.d.ts +2 -0
- package/dist/lib/secrets/filestore.js +4 -0
- package/dist/lib/secrets/session-store.d.ts +7 -0
- package/dist/lib/secrets/session-store.js +21 -0
- package/dist/lib/session/active.d.ts +49 -0
- package/dist/lib/session/active.js +175 -11
- package/dist/lib/session/bash-command.d.ts +16 -0
- package/dist/lib/session/bash-command.js +65 -0
- package/dist/lib/session/db.d.ts +4 -0
- package/dist/lib/session/db.js +32 -15
- package/dist/lib/session/remote-bundle.js +1 -1
- package/dist/lib/staleness/detectors/skills.d.ts +2 -0
- package/dist/lib/staleness/detectors/skills.js +17 -2
- package/dist/lib/staleness/index.d.ts +10 -1
- package/dist/lib/staleness/index.js +28 -3
- package/dist/lib/teams/agents.d.ts +1 -0
- package/dist/lib/teams/agents.js +38 -11
- package/dist/lib/teams/remoteWorktree.d.ts +11 -7
- package/dist/lib/teams/remoteWorktree.js +29 -27
- package/dist/lib/versions.d.ts +1 -0
- package/dist/lib/versions.js +40 -22
- package/package.json +3 -1
- package/dist/lib/exec.bench.d.ts +0 -1
- package/dist/lib/exec.bench.js +0 -186
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
import * as net from 'net';
|
|
27
27
|
import type { SecretsBundle } from './bundles.js';
|
|
28
28
|
import { GLOBAL_HARNESS, bundleScopeChain } from './scope.js';
|
|
29
|
+
import { type SecretLease } from './lease.js';
|
|
29
30
|
export { GLOBAL_HARNESS, bundleScopeChain };
|
|
30
31
|
/** Default lifetime of an unlocked bundle when `--ttl` is not given. */
|
|
31
32
|
export declare const DEFAULT_TTL_MS: number;
|
|
@@ -72,6 +73,7 @@ export interface StoredBundle {
|
|
|
72
73
|
/** epoch ms; the entry is gone once Date.now() passes this. */
|
|
73
74
|
expiresAt: number;
|
|
74
75
|
harness: string;
|
|
76
|
+
lease?: SecretLease;
|
|
75
77
|
}
|
|
76
78
|
/** One unlocked bundle as reported by `status`. */
|
|
77
79
|
export interface AgentStatusEntry {
|
|
@@ -79,6 +81,8 @@ export interface AgentStatusEntry {
|
|
|
79
81
|
expiresAt: number;
|
|
80
82
|
keyCount: number;
|
|
81
83
|
harness: string;
|
|
84
|
+
leaseId?: string;
|
|
85
|
+
keys?: string[];
|
|
82
86
|
}
|
|
83
87
|
/**
|
|
84
88
|
* Read the current broker capability token, or null if none is present. Clients
|
|
@@ -119,11 +123,16 @@ export type Request = {
|
|
|
119
123
|
bundle: SecretsBundle;
|
|
120
124
|
env: Record<string, string>;
|
|
121
125
|
ttlMs: number;
|
|
126
|
+
lease?: SecretLease;
|
|
122
127
|
token?: string;
|
|
123
128
|
} | {
|
|
124
129
|
cmd: 'lock';
|
|
125
130
|
name?: string;
|
|
126
131
|
token?: string;
|
|
132
|
+
} | {
|
|
133
|
+
cmd: 'revoke';
|
|
134
|
+
leaseId: string;
|
|
135
|
+
token?: string;
|
|
127
136
|
} | {
|
|
128
137
|
cmd: 'status';
|
|
129
138
|
token?: string;
|
|
@@ -143,6 +152,7 @@ export type Response = {
|
|
|
143
152
|
hit: true;
|
|
144
153
|
bundle: SecretsBundle;
|
|
145
154
|
env: Record<string, string>;
|
|
155
|
+
lease?: SecretLease;
|
|
146
156
|
} | {
|
|
147
157
|
ok: true;
|
|
148
158
|
cmd: 'load';
|
|
@@ -150,6 +160,10 @@ export type Response = {
|
|
|
150
160
|
ok: true;
|
|
151
161
|
cmd: 'lock';
|
|
152
162
|
wiped: number;
|
|
163
|
+
} | {
|
|
164
|
+
ok: true;
|
|
165
|
+
cmd: 'revoke';
|
|
166
|
+
wiped: number;
|
|
153
167
|
} | {
|
|
154
168
|
ok: true;
|
|
155
169
|
cmd: 'status';
|
|
@@ -237,7 +251,7 @@ export declare function runSecretsAgent(opts?: {
|
|
|
237
251
|
*
|
|
238
252
|
* Serves the SAME socket and wire protocol as the standalone `runSecretsAgent`
|
|
239
253
|
* — so every existing client (`agentGetSync`, `agentPing`, `agentAutoLoadSync`)
|
|
240
|
-
* keeps working
|
|
254
|
+
* keeps working through the versioned protocol — but it is daemon-safe:
|
|
241
255
|
*
|
|
242
256
|
* - no pid-file single-instance guard (the daemon owns the instance);
|
|
243
257
|
* - no `process.exit`, no SIGTERM/SIGINT handlers, no self-heal/idle-exit
|
|
@@ -296,6 +310,7 @@ export declare function syncClientLaunch(sub: string[], agentsBin?: string): {
|
|
|
296
310
|
export declare function agentGetSync(name: string, harness?: string): {
|
|
297
311
|
bundle: SecretsBundle;
|
|
298
312
|
env: Record<string, string>;
|
|
313
|
+
lease?: SecretLease;
|
|
299
314
|
} | null;
|
|
300
315
|
/**
|
|
301
316
|
* Last non-empty line of a child's stdout — the payload line.
|
|
@@ -410,7 +425,7 @@ export declare function clampHoldMs(v: unknown): number;
|
|
|
410
425
|
* The worker reuses the robust `ensureAgentRunning` path (spawn-then-ping) rather
|
|
411
426
|
* than a tight inline retry loop. Best-effort; never throws. macOS only.
|
|
412
427
|
*/
|
|
413
|
-
export declare function agentAutoLoadSync(name: string, bundle: SecretsBundle, env: Record<string, string>, ttlMs: number, harness?: string): void;
|
|
428
|
+
export declare function agentAutoLoadSync(name: string, bundle: SecretsBundle, env: Record<string, string>, ttlMs: number, harness?: string, lease?: SecretLease): void;
|
|
414
429
|
/**
|
|
415
430
|
* Body of the hidden `secrets _agent-load` worker. Reads one `{name, bundle,
|
|
416
431
|
* env, ttlMs}` payload from stdin, ensures the broker is up (robust, generous
|
|
@@ -425,10 +440,11 @@ export declare function agentAutoLoadSync(name: string, bundle: SecretsBundle, e
|
|
|
425
440
|
*/
|
|
426
441
|
export declare function runAgentLoadFromStdin(): Promise<void>;
|
|
427
442
|
/** Store a resolved bundle in the broker. Returns false on transport failure. */
|
|
428
|
-
export declare function agentLoad(name: string, bundle: SecretsBundle, env: Record<string, string>, ttlMs: number, harness?: string): Promise<boolean>;
|
|
443
|
+
export declare function agentLoad(name: string, bundle: SecretsBundle, env: Record<string, string>, ttlMs: number, harness?: string, lease?: SecretLease): Promise<boolean>;
|
|
429
444
|
/** Wipe one bundle (or all if name omitted) from the broker. Returns the count
|
|
430
445
|
* wiped, or 0 when no broker is running. */
|
|
431
446
|
export declare function agentLock(name?: string): Promise<number>;
|
|
447
|
+
export declare function agentRevoke(leaseId: string): Promise<number>;
|
|
432
448
|
/** List currently-unlocked bundles, or [] when no broker is running. The
|
|
433
449
|
* internal `secrets list` metadata-cache entry is filtered out here as well as
|
|
434
450
|
* server-side: during a rollout a NEW client can talk to an OLD broker that
|
|
@@ -35,9 +35,11 @@ import { getKeychainHelperPath } from './install-helper.js';
|
|
|
35
35
|
import { getCliVersion, getCliVersionFresh } from '../version.js';
|
|
36
36
|
import { getCliLaunch } from '../cli-entry.js';
|
|
37
37
|
import { GLOBAL_HARNESS, bundleScopeChain } from './scope.js';
|
|
38
|
-
import { rehydrateSessions, pruneSessionsOnSleep } from './session-store.js';
|
|
38
|
+
import { deleteLeaseSession, rehydrateSessions, pruneSessionsOnSleep } from './session-store.js';
|
|
39
39
|
import { SYNC_GET_CMD, SYNC_PING_CMD, SYNC_LOCK_CMD } from './sync-commands.js';
|
|
40
40
|
import { MAX_LEASE_MS, MIN_LEASE_MS } from './lease.js';
|
|
41
|
+
import { selectLeasedEnv } from './lease.js';
|
|
42
|
+
import { emitSecretAudit } from './audit.js';
|
|
41
43
|
// Re-exported so callers already reaching for agent.js keep one obvious home for
|
|
42
44
|
// the scope vocabulary; the definitions live in the leaf module scope.ts because
|
|
43
45
|
// agent.ts and session-store.ts import each other and a cyclic `const` read can
|
|
@@ -45,7 +47,7 @@ import { MAX_LEASE_MS, MIN_LEASE_MS } from './lease.js';
|
|
|
45
47
|
export { GLOBAL_HARNESS, bundleScopeChain };
|
|
46
48
|
/** Bumped when the wire protocol changes; a client that pings a mismatched
|
|
47
49
|
* server kills and respawns it rather than talking a stale dialect. */
|
|
48
|
-
const PROTOCOL_VERSION =
|
|
50
|
+
const PROTOCOL_VERSION = 3;
|
|
49
51
|
/** Default lifetime of an unlocked bundle when `--ttl` is not given. */
|
|
50
52
|
export const DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7d
|
|
51
53
|
/**
|
|
@@ -154,7 +156,7 @@ function rehydrateStore(now = Date.now()) {
|
|
|
154
156
|
const store = new Map();
|
|
155
157
|
for (const { name, entry } of rehydrateSessions(now)) {
|
|
156
158
|
const harness = entry.harness || GLOBAL_HARNESS;
|
|
157
|
-
store.set(scopedBundleKey(name, harness), { bundle: entry.bundle, env: entry.env, expiresAt: entry.expiresAt, harness });
|
|
159
|
+
store.set(scopedBundleKey(name, harness), { bundle: entry.bundle, env: entry.env, expiresAt: entry.expiresAt, harness, lease: entry.lease });
|
|
158
160
|
}
|
|
159
161
|
return store;
|
|
160
162
|
}
|
|
@@ -352,16 +354,30 @@ export function handleAgentRequest(store, req, now = Date.now()) {
|
|
|
352
354
|
continue;
|
|
353
355
|
if (now >= e.expiresAt) {
|
|
354
356
|
store.delete(key);
|
|
357
|
+
if (e.lease) {
|
|
358
|
+
deleteLeaseSession(e.lease.id);
|
|
359
|
+
emitSecretAudit({ event: 'secrets.lease-expire', bundle: e.bundle.name, operation: 'lease-expire', source: 'broker', status: 'success', keys: e.lease.keys, keyCount: e.lease.keys.length, agent: e.lease.harness });
|
|
360
|
+
}
|
|
355
361
|
continue;
|
|
356
|
-
}
|
|
357
|
-
return { ok: true, cmd: 'get', hit: true, bundle: e.bundle, env: e.env };
|
|
362
|
+
}
|
|
363
|
+
return { ok: true, cmd: 'get', hit: true, bundle: e.bundle, env: e.env, lease: e.lease };
|
|
358
364
|
}
|
|
359
365
|
return { ok: true, cmd: 'get', hit: false };
|
|
360
366
|
}
|
|
361
|
-
case 'load':
|
|
367
|
+
case 'load': {
|
|
362
368
|
const harness = req.harness || GLOBAL_HARNESS;
|
|
363
|
-
|
|
369
|
+
let env = req.env;
|
|
370
|
+
if (req.lease) {
|
|
371
|
+
try {
|
|
372
|
+
env = selectLeasedEnv(req.lease, req.env, now);
|
|
373
|
+
}
|
|
374
|
+
catch (err) {
|
|
375
|
+
return { ok: false, error: err.message };
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
store.set(scopedBundleKey(req.name, harness), { bundle: req.bundle, env, expiresAt: req.lease?.expiresAt ?? now + req.ttlMs, harness, lease: req.lease });
|
|
364
379
|
return { ok: true, cmd: 'load' };
|
|
380
|
+
}
|
|
365
381
|
case 'lock': {
|
|
366
382
|
if (req.name) {
|
|
367
383
|
let wiped = 0;
|
|
@@ -374,6 +390,13 @@ export function handleAgentRequest(store, req, now = Date.now()) {
|
|
|
374
390
|
store.clear();
|
|
375
391
|
return { ok: true, cmd: 'lock', wiped };
|
|
376
392
|
}
|
|
393
|
+
case 'revoke': {
|
|
394
|
+
let wiped = 0;
|
|
395
|
+
for (const [key, entry] of store)
|
|
396
|
+
if (entry.lease?.id === req.leaseId && store.delete(key))
|
|
397
|
+
wiped++;
|
|
398
|
+
return { ok: true, cmd: 'revoke', wiped };
|
|
399
|
+
}
|
|
377
400
|
case 'status': {
|
|
378
401
|
const entries = [];
|
|
379
402
|
for (const [name, e] of store) {
|
|
@@ -381,7 +404,7 @@ export function handleAgentRequest(store, req, now = Date.now()) {
|
|
|
381
404
|
continue;
|
|
382
405
|
if (e.bundle.name.startsWith(META_CACHE_PREFIX))
|
|
383
406
|
continue; // internal list cache
|
|
384
|
-
entries.push({ name: e.bundle.name, expiresAt: e.expiresAt, keyCount: Object.keys(e.env).length, harness: e.harness });
|
|
407
|
+
entries.push({ name: e.bundle.name, expiresAt: e.expiresAt, keyCount: Object.keys(e.env).length, harness: e.harness, leaseId: e.lease?.id, keys: e.lease?.keys });
|
|
385
408
|
}
|
|
386
409
|
return { ok: true, cmd: 'status', entries };
|
|
387
410
|
}
|
|
@@ -649,8 +672,13 @@ export async function runSecretsAgent(opts = {}) {
|
|
|
649
672
|
const sweep = () => {
|
|
650
673
|
const now = Date.now();
|
|
651
674
|
for (const [name, e] of store)
|
|
652
|
-
if (now >= e.expiresAt)
|
|
675
|
+
if (now >= e.expiresAt) {
|
|
653
676
|
store.delete(name);
|
|
677
|
+
if (e.lease) {
|
|
678
|
+
deleteLeaseSession(e.lease.id);
|
|
679
|
+
emitSecretAudit({ event: 'secrets.lease-expire', bundle: e.bundle.name, operation: 'lease-expire', source: 'broker', status: 'success', keys: e.lease.keys, keyCount: e.lease.keys.length, agent: e.lease.harness });
|
|
680
|
+
}
|
|
681
|
+
}
|
|
654
682
|
const live = realBundleCount(store);
|
|
655
683
|
// Self-heal onto a newer in-place install — but ONLY while no real unlocks
|
|
656
684
|
// are held, so we never wipe live unlocks and force a re-prompt (#435). A
|
|
@@ -767,7 +795,7 @@ export async function runSecretsAgent(opts = {}) {
|
|
|
767
795
|
*
|
|
768
796
|
* Serves the SAME socket and wire protocol as the standalone `runSecretsAgent`
|
|
769
797
|
* — so every existing client (`agentGetSync`, `agentPing`, `agentAutoLoadSync`)
|
|
770
|
-
* keeps working
|
|
798
|
+
* keeps working through the versioned protocol — but it is daemon-safe:
|
|
771
799
|
*
|
|
772
800
|
* - no pid-file single-instance guard (the daemon owns the instance);
|
|
773
801
|
* - no `process.exit`, no SIGTERM/SIGINT handlers, no self-heal/idle-exit
|
|
@@ -796,9 +824,15 @@ export async function startHostedBroker() {
|
|
|
796
824
|
// whole daemon down with it.
|
|
797
825
|
const sweepTimer = setInterval(() => {
|
|
798
826
|
const now = Date.now();
|
|
799
|
-
for (const [name, e] of store)
|
|
800
|
-
if (now
|
|
801
|
-
|
|
827
|
+
for (const [name, e] of store) {
|
|
828
|
+
if (now < e.expiresAt)
|
|
829
|
+
continue;
|
|
830
|
+
store.delete(name);
|
|
831
|
+
if (e.lease) {
|
|
832
|
+
deleteLeaseSession(e.lease.id);
|
|
833
|
+
emitSecretAudit({ event: 'secrets.lease-expire', bundle: e.bundle.name, operation: 'lease-expire', source: 'broker', status: 'success', keys: e.lease.keys, keyCount: e.lease.keys.length, agent: e.lease.harness });
|
|
834
|
+
}
|
|
835
|
+
}
|
|
802
836
|
}, SWEEP_INTERVAL_MS);
|
|
803
837
|
// Auto-lock on sleep, same as the standalone broker: the signed helper emits
|
|
804
838
|
// LOCK/SLEEP lines; wipe the in-memory store on a wipe-worthy event.
|
|
@@ -944,7 +978,7 @@ export function agentGetSync(name, harness = GLOBAL_HARNESS) {
|
|
|
944
978
|
const o = JSON.parse(lastLine(r.stdout));
|
|
945
979
|
if (!o || typeof o !== 'object' || !o.env)
|
|
946
980
|
return null;
|
|
947
|
-
return { bundle: o.bundle, env: o.env };
|
|
981
|
+
return { bundle: o.bundle, env: o.env, lease: o.lease };
|
|
948
982
|
}
|
|
949
983
|
catch {
|
|
950
984
|
return null;
|
|
@@ -1026,7 +1060,7 @@ export async function runAgentGetSync(name, harness = GLOBAL_HARNESS) {
|
|
|
1026
1060
|
// truncated on exit — the parent would see a partial or empty payload, treat
|
|
1027
1061
|
// it as a miss, and fall through to a keychain read, silently costing the
|
|
1028
1062
|
// Touch ID prompt this whole path exists to avoid.
|
|
1029
|
-
const payload = JSON.stringify({ bundle: r.bundle, env: r.env }) + '\n';
|
|
1063
|
+
const payload = JSON.stringify({ bundle: r.bundle, env: r.env, lease: r.lease }) + '\n';
|
|
1030
1064
|
await new Promise((resolve) => { process.stdout.write(payload, () => resolve()); });
|
|
1031
1065
|
return 0;
|
|
1032
1066
|
}
|
|
@@ -1166,10 +1200,10 @@ export function clampHoldMs(v) {
|
|
|
1166
1200
|
* The worker reuses the robust `ensureAgentRunning` path (spawn-then-ping) rather
|
|
1167
1201
|
* than a tight inline retry loop. Best-effort; never throws. macOS only.
|
|
1168
1202
|
*/
|
|
1169
|
-
export function agentAutoLoadSync(name, bundle, env, ttlMs, harness = GLOBAL_HARNESS) {
|
|
1203
|
+
export function agentAutoLoadSync(name, bundle, env, ttlMs, harness = GLOBAL_HARNESS, lease) {
|
|
1170
1204
|
if (!onDarwin())
|
|
1171
1205
|
return;
|
|
1172
|
-
const payload = JSON.stringify({ name, bundle, env, ttlMs, harness });
|
|
1206
|
+
const payload = JSON.stringify({ name, bundle, env, ttlMs, harness, lease });
|
|
1173
1207
|
// Broker actually LISTENING → deterministic synchronous warm (bounded; the read
|
|
1174
1208
|
// already paid a Touch ID, so <1s here is invisible). We gate on a real liveness
|
|
1175
1209
|
// ping, NOT mere socket-file existence: a broker that died leaving its socket
|
|
@@ -1236,13 +1270,13 @@ export async function runAgentLoadFromStdin() {
|
|
|
1236
1270
|
process.exitCode = 1; // broker couldn't be brought up — did NOT load
|
|
1237
1271
|
return;
|
|
1238
1272
|
}
|
|
1239
|
-
const loaded = await agentLoad(payload.name, payload.bundle, payload.env, payload.ttlMs ?? DEFAULT_TTL_MS, payload.harness ?? GLOBAL_HARNESS);
|
|
1273
|
+
const loaded = await agentLoad(payload.name, payload.bundle, payload.env, payload.ttlMs ?? DEFAULT_TTL_MS, payload.harness ?? GLOBAL_HARNESS, payload.lease);
|
|
1240
1274
|
if (!loaded)
|
|
1241
1275
|
process.exitCode = 1; // transport failed — did NOT load
|
|
1242
1276
|
}
|
|
1243
1277
|
/** Store a resolved bundle in the broker. Returns false on transport failure. */
|
|
1244
|
-
export async function agentLoad(name, bundle, env, ttlMs, harness = GLOBAL_HARNESS) {
|
|
1245
|
-
const r = await request({ cmd: 'load', name, bundle, env, ttlMs, harness });
|
|
1278
|
+
export async function agentLoad(name, bundle, env, ttlMs, harness = GLOBAL_HARNESS, lease) {
|
|
1279
|
+
const r = await request({ cmd: 'load', name, bundle, env, ttlMs, harness, lease });
|
|
1246
1280
|
return r?.ok === true && r.cmd === 'load';
|
|
1247
1281
|
}
|
|
1248
1282
|
/** Wipe one bundle (or all if name omitted) from the broker. Returns the count
|
|
@@ -1251,6 +1285,10 @@ export async function agentLock(name) {
|
|
|
1251
1285
|
const r = await request({ cmd: 'lock', name });
|
|
1252
1286
|
return r?.ok === true && r.cmd === 'lock' ? r.wiped : 0;
|
|
1253
1287
|
}
|
|
1288
|
+
export async function agentRevoke(leaseId) {
|
|
1289
|
+
const r = await request({ cmd: 'revoke', leaseId });
|
|
1290
|
+
return r?.ok === true && r.cmd === 'revoke' ? r.wiped : 0;
|
|
1291
|
+
}
|
|
1254
1292
|
/** List currently-unlocked bundles, or [] when no broker is running. The
|
|
1255
1293
|
* internal `secrets list` metadata-cache entry is filtered out here as well as
|
|
1256
1294
|
* server-side: during a rollout a NEW client can talk to an OLD broker that
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type SecretAuditEvent = 'secrets.get' | 'secrets.unlocked' | 'secrets.create' | 'secrets.import' | 'secrets.export' | 'secrets.view';
|
|
1
|
+
export type SecretAuditEvent = 'secrets.get' | 'secrets.unlocked' | 'secrets.create' | 'secrets.import' | 'secrets.export' | 'secrets.view' | 'secrets.lease-denied' | 'secrets.lease-expire';
|
|
2
2
|
export interface SecretAuditParams {
|
|
3
3
|
/** Which audit event this is — a read (`secrets.get`) or an unlock grant. */
|
|
4
4
|
event: SecretAuditEvent;
|
|
@@ -46,6 +46,8 @@ const USAGE_KIND = {
|
|
|
46
46
|
'secrets.import': 'import',
|
|
47
47
|
'secrets.export': 'export',
|
|
48
48
|
'secrets.view': 'view',
|
|
49
|
+
'secrets.lease-denied': 'access',
|
|
50
|
+
'secrets.lease-expire': 'access',
|
|
49
51
|
};
|
|
50
52
|
/**
|
|
51
53
|
* The agent/harness identity to attribute a secret access to. Explicit callers
|
|
@@ -264,9 +264,9 @@ export interface ResolveBundleOptions {
|
|
|
264
264
|
export declare function canCacheResolvedEnv(bundle: SecretsBundle, selectedKeys: Set<string>, keyMode: ResolveBundleOptions['keyMode']): boolean;
|
|
265
265
|
/**
|
|
266
266
|
* Apply the --keys subset + expiry gate to an already-resolved snapshot from
|
|
267
|
-
* the secrets-agent fast-path. The agent stores
|
|
268
|
-
* naive fast-path return
|
|
269
|
-
* values. Mirrors the slow-path pre-checks in `resolveBundleEnv` /
|
|
267
|
+
* the secrets-agent fast-path. The agent stores either a full unlock or a
|
|
268
|
+
* scoped lease env, so a naive fast-path return could silently defeat --keys
|
|
269
|
+
* and inject expired values. Mirrors the slow-path pre-checks in `resolveBundleEnv` /
|
|
270
270
|
* `readAndResolveBundleEnv` and returns a new env whose keys match the subset.
|
|
271
271
|
*
|
|
272
272
|
* Exported for tests; production callers reach it via the fast-path branch in
|
|
@@ -1033,9 +1033,9 @@ export function canCacheResolvedEnv(bundle, selectedKeys, keyMode) {
|
|
|
1033
1033
|
}
|
|
1034
1034
|
/**
|
|
1035
1035
|
* Apply the --keys subset + expiry gate to an already-resolved snapshot from
|
|
1036
|
-
* the secrets-agent fast-path. The agent stores
|
|
1037
|
-
* naive fast-path return
|
|
1038
|
-
* values. Mirrors the slow-path pre-checks in `resolveBundleEnv` /
|
|
1036
|
+
* the secrets-agent fast-path. The agent stores either a full unlock or a
|
|
1037
|
+
* scoped lease env, so a naive fast-path return could silently defeat --keys
|
|
1038
|
+
* and inject expired values. Mirrors the slow-path pre-checks in `resolveBundleEnv` /
|
|
1039
1039
|
* `readAndResolveBundleEnv` and returns a new env whose keys match the subset.
|
|
1040
1040
|
*
|
|
1041
1041
|
* Exported for tests; production callers reach it via the fast-path branch in
|
|
@@ -1230,7 +1230,12 @@ export function readAndResolveBundleEnv(name, opts = {}) {
|
|
|
1230
1230
|
const harness = opts.agent || process.env.AGENTS_AGENT_NAME || GLOBAL_HARNESS;
|
|
1231
1231
|
const hit = agentGetSync(name, harness);
|
|
1232
1232
|
if (hit) {
|
|
1233
|
-
|
|
1233
|
+
const denied = (opts.keys ?? []).filter((key) => hit.lease && !hit.lease.keys.includes(key));
|
|
1234
|
+
if (denied.length > 0) {
|
|
1235
|
+
emitSecretAudit({ event: 'secrets.lease-denied', bundle: name, operation: opts.caller, source: 'agent', status: 'error', keys: denied, keyCount: denied.length, agent: harness, error: 'key outside lease scope' });
|
|
1236
|
+
throw new Error(`Secret lease '${hit.lease?.id}' does not grant key(s): ${denied.join(', ')}`);
|
|
1237
|
+
}
|
|
1238
|
+
// The agent stores a full unlock or a scoped lease env. Apply the same subset filter and
|
|
1234
1239
|
// expiry gate as the slow path — without this, `--secrets-keys X` would
|
|
1235
1240
|
// silently inject every key and an expired key would flow through after
|
|
1236
1241
|
// the first cache-populating run.
|
|
@@ -1256,13 +1261,18 @@ export function readAndResolveBundleEnv(name, opts = {}) {
|
|
|
1256
1261
|
const resolved = resolveSession(name, Date.now(), harness);
|
|
1257
1262
|
if (resolved) {
|
|
1258
1263
|
const session = resolved.entry;
|
|
1264
|
+
const denied = (opts.keys ?? []).filter((key) => session.lease && !session.lease.keys.includes(key));
|
|
1265
|
+
if (denied.length > 0) {
|
|
1266
|
+
emitSecretAudit({ event: 'secrets.lease-denied', bundle: name, operation: opts.caller, source: 'session', status: 'error', keys: denied, keyCount: denied.length, agent: harness, error: 'key outside lease scope' });
|
|
1267
|
+
throw new Error(`Secret lease '${session.lease?.id}' does not grant key(s): ${denied.join(', ')}`);
|
|
1268
|
+
}
|
|
1259
1269
|
const filtered = filterAgentHitBySubsetAndExpiry({ bundle: session.bundle, env: session.env }, opts);
|
|
1260
1270
|
stampLastUsed(filtered.bundle);
|
|
1261
1271
|
// Re-warm the broker with the remaining TTL so later reads hit RAM and
|
|
1262
1272
|
// `agents secrets status` is honest. Re-warm under the scope the grant was
|
|
1263
1273
|
// MADE in (resolved.harness), never the asking scope — re-warming a global
|
|
1264
1274
|
// grant as `claude` would silently narrow it for every other harness.
|
|
1265
|
-
agentAutoLoadSync(name, session.bundle, session.env, Math.max(1, session.expiresAt - Date.now()), resolved.harness);
|
|
1275
|
+
agentAutoLoadSync(name, session.bundle, session.env, Math.max(1, session.expiresAt - Date.now()), resolved.harness, session.lease);
|
|
1266
1276
|
emitSecretAudit({
|
|
1267
1277
|
event: 'secrets.get',
|
|
1268
1278
|
bundle: name,
|
|
@@ -51,6 +51,8 @@ export declare function encryptForFallback(plaintext: string, passphrase: string
|
|
|
51
51
|
/** Decrypt an EncFile under a passphrase. Throws on wrong key or tampered
|
|
52
52
|
* ciphertext (auth-tag mismatch). Exported for tests. */
|
|
53
53
|
export declare function decryptForFallback(enc: EncFile, passphrase: string): string;
|
|
54
|
+
/** Absolute path of one encrypted file-store item. No read or decrypt occurs. */
|
|
55
|
+
export declare function fileStoreItemPath(item: string): string;
|
|
54
56
|
declare function fileHas(item: string): boolean;
|
|
55
57
|
declare function fileGet(item: string): string;
|
|
56
58
|
declare function fileGetBatch(items: string[]): Map<string, string>;
|
|
@@ -214,6 +214,10 @@ export function decryptForFallback(enc, passphrase) {
|
|
|
214
214
|
function fileFor(item) {
|
|
215
215
|
return path.join(fileDir(), `${item}.enc`);
|
|
216
216
|
}
|
|
217
|
+
/** Absolute path of one encrypted file-store item. No read or decrypt occurs. */
|
|
218
|
+
export function fileStoreItemPath(item) {
|
|
219
|
+
return fileFor(item);
|
|
220
|
+
}
|
|
217
221
|
function fileHas(item) {
|
|
218
222
|
return fs.existsSync(fileFor(item));
|
|
219
223
|
}
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
* persistence is an optimization, not a correctness dependency.
|
|
26
26
|
*/
|
|
27
27
|
import type { SecretsBundle } from './bundles.js';
|
|
28
|
+
import type { SecretLease } from './lease.js';
|
|
28
29
|
/** Prefix for all durable session items (device-local, no-ACL). */
|
|
29
30
|
export declare const SESSION_ITEM_PREFIX = "agents-cli.session.";
|
|
30
31
|
/** Fixed-name index item — the ONLY thing we ever need to find without a known
|
|
@@ -39,6 +40,7 @@ export interface SessionEntry {
|
|
|
39
40
|
/** true only for `--durable` unlocks — survives SLEEP. */
|
|
40
41
|
sleepPersist: boolean;
|
|
41
42
|
harness?: string;
|
|
43
|
+
lease?: SecretLease;
|
|
42
44
|
}
|
|
43
45
|
/** Metadata for one held bundle, kept in the index so we can rehydrate / prune
|
|
44
46
|
* without reading every blob. */
|
|
@@ -90,6 +92,11 @@ export declare function loadSession(name: string, now?: number, harness?: string
|
|
|
90
92
|
export declare function deleteBundleSessions(name: string): void;
|
|
91
93
|
/** Delete one bundle's session blob and prune it from the index. */
|
|
92
94
|
export declare function deleteSession(name: string, harness?: string): void;
|
|
95
|
+
export declare function deleteLeaseSession(leaseId: string): number;
|
|
96
|
+
export declare function activeLeaseSessions(now?: number): Array<{
|
|
97
|
+
name: string;
|
|
98
|
+
lease: SecretLease;
|
|
99
|
+
}>;
|
|
93
100
|
/** Delete every session blob + the index (for `secrets lock --all`). */
|
|
94
101
|
export declare function deleteAllSessions(): void;
|
|
95
102
|
/** Rehydrate every unexpired session into `[name, entry]` pairs for the broker to
|
|
@@ -192,6 +192,27 @@ export function deleteSession(name, harness = GLOBAL_HARNESS) {
|
|
|
192
192
|
/* best-effort */
|
|
193
193
|
}
|
|
194
194
|
}
|
|
195
|
+
export function deleteLeaseSession(leaseId) {
|
|
196
|
+
if (!shouldPersist())
|
|
197
|
+
return 0;
|
|
198
|
+
const index = readIndex();
|
|
199
|
+
let deleted = 0;
|
|
200
|
+
for (const [key, meta] of Object.entries(index.bundles)) {
|
|
201
|
+
const bundleName = key.split(':').slice(1).join(':');
|
|
202
|
+
const harness = meta.harness || GLOBAL_HARNESS;
|
|
203
|
+
const entry = loadSession(bundleName, Date.now(), harness);
|
|
204
|
+
if (entry?.lease?.id !== leaseId)
|
|
205
|
+
continue;
|
|
206
|
+
deleteSession(bundleName, harness);
|
|
207
|
+
deleted++;
|
|
208
|
+
}
|
|
209
|
+
return deleted;
|
|
210
|
+
}
|
|
211
|
+
export function activeLeaseSessions(now = Date.now()) {
|
|
212
|
+
return rehydrateSessions(now)
|
|
213
|
+
.filter((item) => Boolean(item.entry.lease))
|
|
214
|
+
.map(({ name, entry }) => ({ name, lease: entry.lease }));
|
|
215
|
+
}
|
|
195
216
|
/** Delete every session blob + the index (for `secrets lock --all`). */
|
|
196
217
|
export function deleteAllSessions() {
|
|
197
218
|
if (!shouldPersist())
|
|
@@ -23,6 +23,42 @@ export declare function resolveOwner(pidActor: string | null | undefined, sessio
|
|
|
23
23
|
* cwds are just gathered at a bounded spawn rate instead of a single burst.
|
|
24
24
|
*/
|
|
25
25
|
export declare const LSOF_CONCURRENCY = 4;
|
|
26
|
+
/**
|
|
27
|
+
* Process-local process-table memo (#2047). One `getActiveSessions` scan can
|
|
28
|
+
* call `ps -A` from the terminal path, the headless path, and host ancestry —
|
|
29
|
+
* without this, each is a full process-table snapshot. 5s is well under the
|
|
30
|
+
* ~10–30s menu-bar / `--active` poll so a quiet re-poll usually reuses the
|
|
31
|
+
* snapshot; a brand-new agent pid still appears on the next full refresh.
|
|
32
|
+
*/
|
|
33
|
+
export declare const PROCESS_TABLE_FRESH_MS = 5000;
|
|
34
|
+
/**
|
|
35
|
+
* How long {@link listUnattributedActive} may reuse its last full `ps`+`lsof`
|
|
36
|
+
* result (#2047). Between full rescans the previous rows are re-emitted after
|
|
37
|
+
* dropping dead PIDs and PIDs that are now attributed. A *shrinking* attributed
|
|
38
|
+
* set forces a rescan so a process that just left teams/terminals can reappear
|
|
39
|
+
* as headless instead of vanishing until the next cold poll.
|
|
40
|
+
*/
|
|
41
|
+
export declare const UNATTRIBUTED_RESCAN_MS = 15000;
|
|
42
|
+
/** Test seam: override the clock used by process-table / unattributed memos. */
|
|
43
|
+
export declare function setActiveScanClockForTest(fn?: () => number): void;
|
|
44
|
+
/** Test seam: drop the process-table + unattributed memos and their counters. */
|
|
45
|
+
export declare function clearActiveScanCachesForTest(): void;
|
|
46
|
+
/** Test seam: how many times the live `ps` / CIM path actually ran. */
|
|
47
|
+
export declare function processTableLiveReadCountForTest(): number;
|
|
48
|
+
/** Test seam: how many full unattributed (ps + lsof) rescans ran. */
|
|
49
|
+
export declare function unattributedFullRescanCountForTest(): number;
|
|
50
|
+
/**
|
|
51
|
+
* True when any pid in `prev` is absent from `next` — the attributed set
|
|
52
|
+
* shrank, so a process may need to reappear as unattributed. Pure for tests.
|
|
53
|
+
*/
|
|
54
|
+
export declare function attributedSetLostPids(prev: Set<number>, next: Set<number>): boolean;
|
|
55
|
+
/**
|
|
56
|
+
* Drop rows whose pid is now attributed or no longer alive. Pure over the
|
|
57
|
+
* inputs so the unattributed TTL reuse path is unit-testable without a live
|
|
58
|
+
* process table. `alive` receives the row's `startedAtMs` so callers can apply
|
|
59
|
+
* the same pid-reuse guard {@link isPidAlive} uses elsewhere.
|
|
60
|
+
*/
|
|
61
|
+
export declare function filterCachedUnattributed(sessions: ActiveSession[], attributed: Set<number>, alive: (pid: number, startedAtMs?: number) => boolean): ActiveSession[];
|
|
26
62
|
export type ActiveContext = 'terminal' | 'teams' | 'cloud' | 'headless';
|
|
27
63
|
/**
|
|
28
64
|
* Every status here is COMPUTED by the framework from observable signals — PID
|
|
@@ -348,6 +384,8 @@ export declare function resolveNamesToSessionIds(sessionNames: string[], deps: {
|
|
|
348
384
|
* the start time can't be read, we keep the existence answer.
|
|
349
385
|
*/
|
|
350
386
|
export declare function isPidAlive(pid: number, startedAtMs?: number): boolean;
|
|
387
|
+
/** Test seam: drop the Claude session-file path memo. */
|
|
388
|
+
export declare function clearClaudeSessionFileCacheForTest(): void;
|
|
351
389
|
/**
|
|
352
390
|
* Resolve a Claude transcript for `cwd` across the given project roots, newest
|
|
353
391
|
* mtime winning when the same id/cwd resolves in more than one version home
|
|
@@ -431,6 +469,8 @@ interface LiveSignals {
|
|
|
431
469
|
/** Rolling output-token throughput (tokens/sec). Undefined when no usage in the tail. */
|
|
432
470
|
tokPerSec?: number;
|
|
433
471
|
}
|
|
472
|
+
/** Test seam: drop the in-process live-signals memo. */
|
|
473
|
+
export declare function clearLiveSignalsCacheForTest(): void;
|
|
434
474
|
/**
|
|
435
475
|
* Derive the inferred state (working / waiting / idle + preview/badges) and, for
|
|
436
476
|
* the two harnesses whose raw lines carry it, the output-token throughput.
|
|
@@ -444,6 +484,9 @@ interface LiveSignals {
|
|
|
444
484
|
* `unknown` it used to fall through to. An opaque/untracked kind or an
|
|
445
485
|
* unreadable/empty transcript yields an empty signal set, and the caller's
|
|
446
486
|
* {@link resolveFallbackStatus} reports the honest live floor (`running`).
|
|
487
|
+
*
|
|
488
|
+
* Unchanged-mtime hits reuse the previous result (see {@link liveSignalsCache})
|
|
489
|
+
* so a 30s active-session poll does not re-tail every quiet transcript.
|
|
447
490
|
*/
|
|
448
491
|
export declare function computeLiveSignals(kind: string, sessionFile: string | undefined, cwd: string | undefined, pidAlive: boolean): LiveSignals;
|
|
449
492
|
/**
|
|
@@ -523,6 +566,12 @@ export declare function foldSubordinateAgents(candidates: AgentCandidate[], ppid
|
|
|
523
566
|
* Classified by walking the ppid chain: any recognised UI ancestor (IDE
|
|
524
567
|
* helper, terminal-app, or multiplexer) means `terminal`; nothing of the
|
|
525
568
|
* sort means `headless` (daemon, launchd-spawned, orphan).
|
|
569
|
+
*
|
|
570
|
+
* Full `ps`+`lsof` rescans are throttled to {@link UNATTRIBUTED_RESCAN_MS}
|
|
571
|
+
* (#2047). Between rescans the previous rows are re-emitted after dropping
|
|
572
|
+
* dead PIDs and PIDs that are now attributed. A shrinking attributed set
|
|
573
|
+
* forces a rescan so a process that just left teams/terminals can reappear
|
|
574
|
+
* as headless.
|
|
526
575
|
*/
|
|
527
576
|
export declare function listUnattributedActive(attributed: Set<number>): Promise<ActiveSession[]>;
|
|
528
577
|
/** One tmux pane's resolved agent identity for the authoritative source. */
|