@phnx-labs/agents-cli 1.20.70 → 1.20.72
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 +85 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/apply.js +36 -3
- package/dist/commands/check.js +3 -1
- package/dist/commands/commands.js +2 -0
- package/dist/commands/exec.js +12 -0
- package/dist/commands/fleet-capture.d.ts +14 -0
- package/dist/commands/fleet-capture.js +107 -0
- package/dist/commands/fork.js +13 -7
- package/dist/commands/hosts.js +11 -0
- package/dist/commands/logs.js +38 -9
- package/dist/commands/mcp.js +2 -0
- package/dist/commands/resource-view.d.ts +2 -0
- package/dist/commands/resource-view.js +8 -0
- package/dist/commands/secrets.d.ts +1 -0
- package/dist/commands/secrets.js +24 -4
- package/dist/commands/sessions-tail.js +1 -2
- package/dist/commands/sessions.d.ts +6 -0
- package/dist/commands/sessions.js +14 -6
- package/dist/commands/skills.js +2 -0
- package/dist/commands/ssh.js +24 -6
- package/dist/commands/status.js +8 -2
- package/dist/commands/subagents.js +3 -1
- package/dist/commands/uninstall.d.ts +11 -0
- package/dist/commands/uninstall.js +158 -0
- package/dist/commands/usage.d.ts +13 -0
- package/dist/commands/usage.js +50 -28
- package/dist/commands/utils.d.ts +29 -0
- package/dist/commands/utils.js +24 -0
- package/dist/commands/versions.js +120 -11
- package/dist/index.js +5 -3
- package/dist/lib/commands.d.ts +10 -0
- package/dist/lib/commands.js +31 -7
- package/dist/lib/daemon.d.ts +9 -0
- package/dist/lib/daemon.js +43 -0
- package/dist/lib/devices/connect.d.ts +13 -0
- package/dist/lib/devices/connect.js +20 -0
- package/dist/lib/devices/fleet.d.ts +36 -3
- package/dist/lib/devices/fleet.js +42 -4
- package/dist/lib/devices/sync.d.ts +30 -0
- package/dist/lib/devices/sync.js +50 -0
- package/dist/lib/doctor-diff.js +38 -16
- package/dist/lib/fleet/apply.d.ts +4 -0
- package/dist/lib/fleet/apply.js +28 -5
- package/dist/lib/fleet/capture.d.ts +35 -0
- package/dist/lib/fleet/capture.js +51 -0
- package/dist/lib/fleet/manifest.d.ts +6 -0
- package/dist/lib/fleet/manifest.js +24 -1
- package/dist/lib/fleet/types.d.ts +24 -1
- package/dist/lib/git.d.ts +14 -0
- package/dist/lib/git.js +39 -1
- package/dist/lib/hosts/logs.d.ts +12 -0
- package/dist/lib/hosts/logs.js +18 -0
- package/dist/lib/menubar/install-menubar.d.ts +12 -0
- package/dist/lib/menubar/install-menubar.js +26 -13
- package/dist/lib/secrets/agent.d.ts +5 -0
- package/dist/lib/secrets/agent.js +35 -3
- package/dist/lib/secrets/bundles.js +28 -0
- package/dist/lib/secrets/index.d.ts +14 -0
- package/dist/lib/secrets/index.js +36 -5
- package/dist/lib/secrets/session-store.d.ts +90 -0
- package/dist/lib/secrets/session-store.js +221 -0
- package/dist/lib/session/render.d.ts +9 -1
- package/dist/lib/session/render.js +35 -4
- package/dist/lib/share/capture.d.ts +2 -0
- package/dist/lib/share/capture.js +12 -5
- package/dist/lib/shims.d.ts +27 -0
- package/dist/lib/shims.js +29 -3
- package/dist/lib/skills.d.ts +8 -1
- package/dist/lib/skills.js +11 -1
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +2 -0
- package/dist/lib/types.d.ts +5 -1
- package/dist/lib/uninstall.d.ts +84 -0
- package/dist/lib/uninstall.js +347 -0
- package/dist/lib/usage.d.ts +13 -1
- package/dist/lib/usage.js +32 -14
- package/dist/lib/versions.d.ts +16 -0
- package/dist/lib/versions.js +40 -3
- package/package.json +1 -1
package/dist/lib/hosts/logs.js
CHANGED
|
@@ -54,6 +54,24 @@ export async function showHostTaskLog(id, follow, full = false) {
|
|
|
54
54
|
process.stdout.write(full ? raw : tailLines(raw, HOST_LOG_TAIL_LINES));
|
|
55
55
|
return { found: true, exitCode: 0 };
|
|
56
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* Machine-readable form of a host-dispatch task's log — the task record plus its
|
|
59
|
+
* combined stdout. Powers `agents logs <id> --json` for the host-task branch.
|
|
60
|
+
* Reconciles a still-'running' record from the remote `.exit` first, like the
|
|
61
|
+
* text path does.
|
|
62
|
+
*/
|
|
63
|
+
export function hostTaskLogJson(id) {
|
|
64
|
+
const task = loadTask(id);
|
|
65
|
+
if (!task)
|
|
66
|
+
return { found: false };
|
|
67
|
+
// reconcileTask returns the healed record; it does NOT mutate its argument in
|
|
68
|
+
// place. Emit the reconciled task so a run that finished between dispatch and
|
|
69
|
+
// this one-shot read surfaces its terminal status/exitCode, not a stale
|
|
70
|
+
// 'running'. (The text path can discard the return — it only reads the log by
|
|
71
|
+
// id — but this JSON payload carries task.status straight to the consumer.)
|
|
72
|
+
const reconciled = reconcileTask(task);
|
|
73
|
+
return { found: true, task: reconciled, log: readTaskLog(reconciled) };
|
|
74
|
+
}
|
|
57
75
|
/** Read the task's combined-stdout — local mirror first, else fetch+cache remote. */
|
|
58
76
|
function readTaskLog(task) {
|
|
59
77
|
try {
|
|
@@ -26,6 +26,18 @@ export declare function menubarServiceInstalled(): boolean;
|
|
|
26
26
|
export declare function ensureMenubarAppInstalled(opts?: {
|
|
27
27
|
forceReinstall?: boolean;
|
|
28
28
|
}): string | null;
|
|
29
|
+
/**
|
|
30
|
+
* Restart the menu-bar launchd agent from a clean state.
|
|
31
|
+
*
|
|
32
|
+
* Always `bootout` first: on modern macOS `bootstrap` fails when the job is
|
|
33
|
+
* already bootstrapped, and the deprecated `load -w` fallback is unreliable.
|
|
34
|
+
* A prior WindowServer disconnect can leave the job throttled so that a plain
|
|
35
|
+
* `kickstart -k` does not bring it back; booting it out and bootstrapping fresh
|
|
36
|
+
* is the only sequence that reliably re-attaches the status item.
|
|
37
|
+
*/
|
|
38
|
+
export declare function restartMenubarLaunchAgent(uid: number, plist: string, exec?: (cmd: string, args: readonly string[], opts: {
|
|
39
|
+
stdio: ['ignore', 'ignore', 'ignore'];
|
|
40
|
+
}) => Buffer): void;
|
|
29
41
|
/**
|
|
30
42
|
* Install + start the menu-bar helper as a launchd user service (idempotent).
|
|
31
43
|
* Clears the sticky opt-out, installs the .app, writes the plist, and
|
|
@@ -210,6 +210,31 @@ ${envXml}
|
|
|
210
210
|
</dict>
|
|
211
211
|
</plist>`;
|
|
212
212
|
}
|
|
213
|
+
/**
|
|
214
|
+
* Restart the menu-bar launchd agent from a clean state.
|
|
215
|
+
*
|
|
216
|
+
* Always `bootout` first: on modern macOS `bootstrap` fails when the job is
|
|
217
|
+
* already bootstrapped, and the deprecated `load -w` fallback is unreliable.
|
|
218
|
+
* A prior WindowServer disconnect can leave the job throttled so that a plain
|
|
219
|
+
* `kickstart -k` does not bring it back; booting it out and bootstrapping fresh
|
|
220
|
+
* is the only sequence that reliably re-attaches the status item.
|
|
221
|
+
*/
|
|
222
|
+
export function restartMenubarLaunchAgent(uid, plist, exec = execFileSync) {
|
|
223
|
+
const serviceTarget = `gui/${uid}/${SERVICE_LABEL}`;
|
|
224
|
+
const opts = { stdio: ['ignore', 'ignore', 'ignore'] };
|
|
225
|
+
try {
|
|
226
|
+
exec('launchctl', ['bootout', serviceTarget], opts);
|
|
227
|
+
}
|
|
228
|
+
catch { /* may not be loaded */ }
|
|
229
|
+
try {
|
|
230
|
+
exec('launchctl', ['bootstrap', `gui/${uid}`, plist], opts);
|
|
231
|
+
}
|
|
232
|
+
catch { /* best effort */ }
|
|
233
|
+
try {
|
|
234
|
+
exec('launchctl', ['kickstart', serviceTarget], opts);
|
|
235
|
+
}
|
|
236
|
+
catch { /* best effort */ }
|
|
237
|
+
}
|
|
213
238
|
/**
|
|
214
239
|
* Install + start the menu-bar helper as a launchd user service (idempotent).
|
|
215
240
|
* Clears the sticky opt-out, installs the .app, writes the plist, and
|
|
@@ -232,19 +257,7 @@ export function enableMenubarService(opts = { clearOptOut: true }) {
|
|
|
232
257
|
fs.mkdirSync(path.dirname(plist), { recursive: true });
|
|
233
258
|
fs.writeFileSync(plist, generateServicePlist(exec));
|
|
234
259
|
const uid = process.getuid?.() ?? 0;
|
|
235
|
-
|
|
236
|
-
execFileSync('launchctl', ['bootstrap', `gui/${uid}`, plist], { stdio: ['ignore', 'ignore', 'ignore'] });
|
|
237
|
-
}
|
|
238
|
-
catch {
|
|
239
|
-
try {
|
|
240
|
-
execFileSync('launchctl', ['load', '-w', plist], { stdio: ['ignore', 'ignore', 'ignore'] });
|
|
241
|
-
}
|
|
242
|
-
catch { /* may already be loaded */ }
|
|
243
|
-
}
|
|
244
|
-
try {
|
|
245
|
-
execFileSync('launchctl', ['kickstart', '-k', `gui/${uid}/${SERVICE_LABEL}`], { stdio: ['ignore', 'ignore', 'ignore'] });
|
|
246
|
-
}
|
|
247
|
-
catch { /* best effort */ }
|
|
260
|
+
restartMenubarLaunchAgent(uid, plist);
|
|
248
261
|
// Stamp the version we just installed so the upgrade self-heal can tell when
|
|
249
262
|
// a later release ships a newer helper that needs reinstalling.
|
|
250
263
|
try {
|
|
@@ -280,6 +280,11 @@ export declare function agentAutoLoadMetaSync(nameSetHash: string, bundles: Secr
|
|
|
280
280
|
* per ~7d), so auto-caching is ON by default; opt out with
|
|
281
281
|
* `secrets.agent.auto: false`. Best-effort; an unreadable meta reads as on. */
|
|
282
282
|
export declare function secretsAgentAutoEnabled(): boolean;
|
|
283
|
+
/** Default `sleepPersist` for a new unlock when `--durable` is not passed. OFF by
|
|
284
|
+
* default (the secure split default: survive restart, re-lock on sleep); set
|
|
285
|
+
* `secrets.agent.durable: true` in agents.yaml to make every unlock sleep-durable.
|
|
286
|
+
* Best-effort; an unreadable meta reads as off. */
|
|
287
|
+
export declare function secretsAgentDurable(): boolean;
|
|
283
288
|
/** Minimum / maximum bounds for the configurable hold window. A too-small value
|
|
284
289
|
* would defeat the broker (constant re-prompts); a too-large one pins secrets in
|
|
285
290
|
* memory far longer than intended. */
|
|
@@ -34,6 +34,7 @@ import { isAlive } from '../platform/process.js';
|
|
|
34
34
|
import { getKeychainHelperPath } from './install-helper.js';
|
|
35
35
|
import { getCliVersion, getCliVersionFresh } from '../version.js';
|
|
36
36
|
import { getCliLaunch } from '../cli-entry.js';
|
|
37
|
+
import { rehydrateSessions, pruneSessionsOnSleep } from './session-store.js';
|
|
37
38
|
/** Bumped when the wire protocol changes; a client that pings a mismatched
|
|
38
39
|
* server kills and respawns it rather than talking a stale dialect. */
|
|
39
40
|
const PROTOCOL_VERSION = 1;
|
|
@@ -93,6 +94,20 @@ export function shouldTeardownVersionSkewedBroker(realHeldBundles) {
|
|
|
93
94
|
function onDarwin() {
|
|
94
95
|
return process.platform === 'darwin';
|
|
95
96
|
}
|
|
97
|
+
/**
|
|
98
|
+
* Build the broker's in-memory store, REHYDRATED from the durable session store
|
|
99
|
+
* (session-store.ts) so an unlock survives a daemon restart / agents-cli upgrade.
|
|
100
|
+
* Expired sessions are dropped; `--durable` and default entries alike come back
|
|
101
|
+
* (SLEEP already pruned the non-durable ones from the keychain while the broker
|
|
102
|
+
* was alive). Empty off darwin or when nothing was held.
|
|
103
|
+
*/
|
|
104
|
+
function rehydrateStore(now = Date.now()) {
|
|
105
|
+
const store = new Map();
|
|
106
|
+
for (const { name, entry } of rehydrateSessions(now)) {
|
|
107
|
+
store.set(name, { bundle: entry.bundle, env: entry.env, expiresAt: entry.expiresAt });
|
|
108
|
+
}
|
|
109
|
+
return store;
|
|
110
|
+
}
|
|
96
111
|
/** Broker runtime dir under the regenerable cache, locked to the user (0700).
|
|
97
112
|
* AGENTS_SECRETS_AGENT_DIR overrides the location — a test seam so the suite can
|
|
98
113
|
* run a real broker on a temp socket without touching the user's real dir. */
|
|
@@ -442,7 +457,7 @@ export async function runSecretsAgent(opts = {}) {
|
|
|
442
457
|
throw err;
|
|
443
458
|
}
|
|
444
459
|
}
|
|
445
|
-
const store =
|
|
460
|
+
const store = rehydrateStore();
|
|
446
461
|
// emptySince tracks the last moment the store held something; the sweep exits
|
|
447
462
|
// the process once it's been empty for IDLE_EXIT_MS so no idle broker lingers.
|
|
448
463
|
let emptySince = Date.now();
|
|
@@ -589,6 +604,9 @@ export async function runSecretsAgent(opts = {}) {
|
|
|
589
604
|
if (shouldWipeOnWatchEvent(chunk)) {
|
|
590
605
|
store.clear();
|
|
591
606
|
emptySince = Date.now();
|
|
607
|
+
// Split default: delete non-`--durable` durable sessions too, so a default
|
|
608
|
+
// unlock re-locks on sleep; `--durable` ones survive (session-store.ts).
|
|
609
|
+
pruneSessionsOnSleep();
|
|
592
610
|
}
|
|
593
611
|
});
|
|
594
612
|
watcher.on('error', () => { watcher = null; });
|
|
@@ -627,7 +645,7 @@ export async function runSecretsAgent(opts = {}) {
|
|
|
627
645
|
export async function startHostedBroker() {
|
|
628
646
|
if (!onDarwin())
|
|
629
647
|
return null;
|
|
630
|
-
const store =
|
|
648
|
+
const store = rehydrateStore();
|
|
631
649
|
const sock = socketPath(); // agentDir() creates the 0700 dir as a side effect
|
|
632
650
|
const handle = (req) => handleAgentRequest(store, req);
|
|
633
651
|
const onConn = makeConnectionHandler(handle, readAgentToken);
|
|
@@ -651,8 +669,10 @@ export async function startHostedBroker() {
|
|
|
651
669
|
watcher = spawn(getKeychainHelperPath(), ['watch-lock'], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
652
670
|
watcher.stdout?.setEncoding('utf-8');
|
|
653
671
|
watcher.stdout?.on('data', (chunk) => {
|
|
654
|
-
if (shouldWipeOnWatchEvent(chunk))
|
|
672
|
+
if (shouldWipeOnWatchEvent(chunk)) {
|
|
655
673
|
store.clear();
|
|
674
|
+
pruneSessionsOnSleep(); // split default — see runSecretsAgent's handler
|
|
675
|
+
}
|
|
656
676
|
});
|
|
657
677
|
watcher.on('error', () => { watcher = null; });
|
|
658
678
|
}
|
|
@@ -912,6 +932,18 @@ export function secretsAgentAutoEnabled() {
|
|
|
912
932
|
return true;
|
|
913
933
|
}
|
|
914
934
|
}
|
|
935
|
+
/** Default `sleepPersist` for a new unlock when `--durable` is not passed. OFF by
|
|
936
|
+
* default (the secure split default: survive restart, re-lock on sleep); set
|
|
937
|
+
* `secrets.agent.durable: true` in agents.yaml to make every unlock sleep-durable.
|
|
938
|
+
* Best-effort; an unreadable meta reads as off. */
|
|
939
|
+
export function secretsAgentDurable() {
|
|
940
|
+
try {
|
|
941
|
+
return readMeta().secrets?.agent?.durable === true;
|
|
942
|
+
}
|
|
943
|
+
catch {
|
|
944
|
+
return false;
|
|
945
|
+
}
|
|
946
|
+
}
|
|
915
947
|
/** Minimum / maximum bounds for the configurable hold window. A too-small value
|
|
916
948
|
* would defeat the broker (constant re-prompts); a too-large one pins secrets in
|
|
917
949
|
* memory far longer than intended. */
|
|
@@ -26,6 +26,7 @@ import { fileStore } from './filestore.js';
|
|
|
26
26
|
import { emit } from '../events.js';
|
|
27
27
|
import { readMeta } from '../state.js';
|
|
28
28
|
import { agentGetSync, agentAutoLoadSync, agentGetMetaSync, agentAutoLoadMetaSync, agentEvictSync, secretsAgentAutoEnabled, secretsHoldMs } from './agent.js';
|
|
29
|
+
import { loadSession, deleteSession } from './session-store.js';
|
|
29
30
|
import { createHash } from 'node:crypto';
|
|
30
31
|
const keychainStore = {
|
|
31
32
|
has: hasKeychainToken,
|
|
@@ -362,6 +363,9 @@ export function writeBundle(bundle, opts = {}) {
|
|
|
362
363
|
// re-resolves from the keychain instead of serving stale values.
|
|
363
364
|
if (shouldEvictAfterBundleWrite(Boolean(opts.skipBrokerEviction), process.env.AGENTS_SECRETS_NO_AGENT, isKeychainBackendOverridden())) {
|
|
364
365
|
agentEvictSync(bundle.name);
|
|
366
|
+
// Also drop any durable session snapshot, or a broker restart would rehydrate
|
|
367
|
+
// the stale env after a rotate/rename (session-store.ts).
|
|
368
|
+
deleteSession(bundle.name);
|
|
365
369
|
}
|
|
366
370
|
}
|
|
367
371
|
export function deleteBundle(name) {
|
|
@@ -371,6 +375,7 @@ export function deleteBundle(name) {
|
|
|
371
375
|
emit('secrets.delete', { module: 'secrets', bundle: name });
|
|
372
376
|
if (shouldEvictAfterBundleWrite(false, process.env.AGENTS_SECRETS_NO_AGENT, isKeychainBackendOverridden())) {
|
|
373
377
|
agentEvictSync(name);
|
|
378
|
+
deleteSession(name); // a deleted bundle must not be rehydratable
|
|
374
379
|
}
|
|
375
380
|
}
|
|
376
381
|
return deleted;
|
|
@@ -787,6 +792,29 @@ export function readAndResolveBundleEnv(name, opts = {}) {
|
|
|
787
792
|
});
|
|
788
793
|
return filtered;
|
|
789
794
|
}
|
|
795
|
+
// Durable-session fallback (Correction B). After a daemon restart / agents-cli
|
|
796
|
+
// upgrade the broker RAM is empty, so the fast-path above misses — but the
|
|
797
|
+
// unlock persisted a no-ACL session item (session-store.ts) that reads with NO
|
|
798
|
+
// Touch ID. Serve from it and re-warm the broker, so a warm bundle stays warm
|
|
799
|
+
// across restart — this fixes BOTH the interactive re-prompt and the headless
|
|
800
|
+
// throw below (which now fires only when there is genuinely no session).
|
|
801
|
+
const session = loadSession(name);
|
|
802
|
+
if (session) {
|
|
803
|
+
const filtered = filterAgentHitBySubsetAndExpiry({ bundle: session.bundle, env: session.env }, opts);
|
|
804
|
+
stampLastUsed(filtered.bundle);
|
|
805
|
+
// Re-warm the broker with the remaining TTL so later reads hit RAM and
|
|
806
|
+
// `agents secrets status` is honest. Best-effort; no-ops off darwin.
|
|
807
|
+
agentAutoLoadSync(name, session.bundle, session.env, Math.max(1, session.expiresAt - Date.now()));
|
|
808
|
+
emit('secrets.get', {
|
|
809
|
+
module: 'secrets',
|
|
810
|
+
bundle: name,
|
|
811
|
+
operation: opts.caller,
|
|
812
|
+
status: 'success',
|
|
813
|
+
source: 'session',
|
|
814
|
+
keyCount: Object.keys(filtered.env).length,
|
|
815
|
+
});
|
|
816
|
+
return filtered;
|
|
817
|
+
}
|
|
790
818
|
}
|
|
791
819
|
// Only keychain-backed bundles can pop a Touch ID prompt and are the only ones
|
|
792
820
|
// the broker ever holds. A file-backed bundle resolves via passphrase with no
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
* through the explicit export/import flow in src/lib/secrets/sync.ts
|
|
23
23
|
* rather than the system's cloud-keychain path.
|
|
24
24
|
*/
|
|
25
|
+
import { type SpawnSyncOptions } from 'child_process';
|
|
25
26
|
import type { NativeImportReport } from './fallback.js';
|
|
26
27
|
export type { NativeImportReport, NativeImportResult, NativeImportStatus } from './fallback.js';
|
|
27
28
|
export declare const SECRETS_ITEM_PREFIX = "agents-cli.secrets.";
|
|
@@ -221,6 +222,19 @@ export declare function getKeychainTokens(items: string[]): Map<string, string>;
|
|
|
221
222
|
* a `ps` snapshot. Exported so a test can assert the value is absent from argv.
|
|
222
223
|
*/
|
|
223
224
|
export declare function buildAddGenericPasswordArgs(account: string, item: string): string[];
|
|
225
|
+
/**
|
|
226
|
+
* spawnSync options for the bare `-w` keychain write. Pure so the two
|
|
227
|
+
* load-bearing properties are unit-testable without touching the real keychain:
|
|
228
|
+
* - `input` pipes the value TWICE (bare `-w` prompts enter+confirm; one line
|
|
229
|
+
* fails the confirm and stores an empty secret).
|
|
230
|
+
* - `detached: true` runs `security` in a new session with no controlling
|
|
231
|
+
* terminal, so readpassphrase(3) falls back to our piped stdin instead of
|
|
232
|
+
* prompting the user's `/dev/tty` in an interactive shell (see setKeychainToken).
|
|
233
|
+
*/
|
|
234
|
+
export declare function buildAddGenericPasswordSpawnOptions(value: string): SpawnSyncOptions & {
|
|
235
|
+
input: string;
|
|
236
|
+
detached: boolean;
|
|
237
|
+
};
|
|
224
238
|
export declare function setKeychainToken(item: string, value: string, opts?: {
|
|
225
239
|
noAcl?: boolean;
|
|
226
240
|
}): void;
|
|
@@ -506,6 +506,11 @@ export function computeRekeyPlan(services, values, key) {
|
|
|
506
506
|
if (dot > 0)
|
|
507
507
|
noAcl = bundleNoAcl(rest.slice(0, dot));
|
|
508
508
|
}
|
|
509
|
+
// Durable "unlocked session" items (agents-cli.session.*, see session-store.ts)
|
|
510
|
+
// are always no-ACL — re-wrapping them in a biometry ACL on rekey would break
|
|
511
|
+
// their silent (no-Touch-ID) reads that the rehydrate/fallback paths depend on.
|
|
512
|
+
if (service.startsWith('agents-cli.session.'))
|
|
513
|
+
noAcl = true;
|
|
509
514
|
items.push({ oldService: service, newService: hashedServiceName(service, key), noAcl });
|
|
510
515
|
}
|
|
511
516
|
return { items, unreadable };
|
|
@@ -853,6 +858,25 @@ function parseBatchRecords(out, record) {
|
|
|
853
858
|
export function buildAddGenericPasswordArgs(account, item) {
|
|
854
859
|
return ['add-generic-password', '-U', '-a', account, '-s', item, '-w'];
|
|
855
860
|
}
|
|
861
|
+
/**
|
|
862
|
+
* spawnSync options for the bare `-w` keychain write. Pure so the two
|
|
863
|
+
* load-bearing properties are unit-testable without touching the real keychain:
|
|
864
|
+
* - `input` pipes the value TWICE (bare `-w` prompts enter+confirm; one line
|
|
865
|
+
* fails the confirm and stores an empty secret).
|
|
866
|
+
* - `detached: true` runs `security` in a new session with no controlling
|
|
867
|
+
* terminal, so readpassphrase(3) falls back to our piped stdin instead of
|
|
868
|
+
* prompting the user's `/dev/tty` in an interactive shell (see setKeychainToken).
|
|
869
|
+
*/
|
|
870
|
+
export function buildAddGenericPasswordSpawnOptions(value) {
|
|
871
|
+
// `detached` is honored by spawnSync at runtime (libuv setsid) but is not
|
|
872
|
+
// declared on Node's SpawnSyncOptions type, so widen the return explicitly.
|
|
873
|
+
return {
|
|
874
|
+
input: `${value}\n${value}\n`,
|
|
875
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
876
|
+
timeout: 10_000,
|
|
877
|
+
detached: true,
|
|
878
|
+
};
|
|
879
|
+
}
|
|
856
880
|
export function setKeychainToken(item, value, opts) {
|
|
857
881
|
// Validate the CLEARTEXT name (a hashed storage name is always clean), then
|
|
858
882
|
// resolve the storage name.
|
|
@@ -893,11 +917,18 @@ export function setKeychainToken(item, value, opts) {
|
|
|
893
917
|
// (assertValueStorable above), so each line carries the whole secret verbatim
|
|
894
918
|
// (no shell/quoting layer). `timeout` bounds the call so a context that cannot
|
|
895
919
|
// read the prompt fails loudly instead of hanging.
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
920
|
+
//
|
|
921
|
+
// `detached: true` is load-bearing, not an afterthought: readpassphrase(3)
|
|
922
|
+
// reads from the *controlling terminal* (`/dev/tty`) when one exists, and only
|
|
923
|
+
// falls back to fd 0 when it cannot open one. So piping over stdin works in a
|
|
924
|
+
// headless/CI context (no controlling tty) but is IGNORED in an interactive
|
|
925
|
+
// shell — there `security` prompts the real user ("password data for new
|
|
926
|
+
// item:") and hangs to the timeout, and any keystroke would be stored AS the
|
|
927
|
+
// secret. `detached` runs the child in a new session (setsid) with no
|
|
928
|
+
// controlling terminal, so readpassphrase always falls back to our piped
|
|
929
|
+
// stdin. Without it, an interactive `agents view` (refreshing+saving a Claude
|
|
930
|
+
// OAuth token) or `agents secrets add` pops a keychain password sheet.
|
|
931
|
+
const sec = spawnSync('/usr/bin/security', buildAddGenericPasswordArgs(os.userInfo().username, item), buildAddGenericPasswordSpawnOptions(value));
|
|
901
932
|
if (sec.status !== 0) {
|
|
902
933
|
const msg = sec.stderr?.toString().trim();
|
|
903
934
|
throw new Error(msg || `Failed to write keychain item '${item}'.`);
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable "unlocked session" store (macOS).
|
|
3
|
+
*
|
|
4
|
+
* The secrets broker (agent.ts) holds unlocked bundles only in RAM, so an
|
|
5
|
+
* `agents secrets unlock` grant is lost whenever that process dies: on an
|
|
6
|
+
* agents-cli upgrade (the postinstall bounces the daemon) and on system SLEEP.
|
|
7
|
+
* This module persists the resolved bundle+env of an unlocked bundle as a
|
|
8
|
+
* NO-ACL keychain item — the `set-no-acl` write path, `kSecAttrAccessibleAfter-
|
|
9
|
+
* FirstUnlockThisDeviceOnly`, whose reads never pop Touch ID — so the broker can
|
|
10
|
+
* REHYDRATE it on start and reads can FALL BACK to it when the broker RAM misses.
|
|
11
|
+
*
|
|
12
|
+
* Split-default posture: `sleepPersist` is false by default — the item is deleted
|
|
13
|
+
* on SLEEP so the bundle re-locks on sleep but survives restart/upgrade; the
|
|
14
|
+
* `--durable` flag sets it true so it survives sleep too. Both expire at the TTL.
|
|
15
|
+
*
|
|
16
|
+
* Cross-platform: a NO-OP off macOS. Linux (secret-service) and Windows
|
|
17
|
+
* (Credential Manager) have no broker and resolve secrets durably from the OS
|
|
18
|
+
* store on every read with no prompt (see agent.ts:onDarwin gates) — there is no
|
|
19
|
+
* ephemeral unlock to persist.
|
|
20
|
+
*
|
|
21
|
+
* NEVER enumerate session items: once keychain-name hashing (#316) is active a
|
|
22
|
+
* `list('agents-cli.session.')` matches nothing. All I/O is by KNOWN name — one
|
|
23
|
+
* fixed-name index item lists the held bundles + their metadata, and one blob per
|
|
24
|
+
* bundle holds the env. Every adapter call is best-effort and never throws;
|
|
25
|
+
* persistence is an optimization, not a correctness dependency.
|
|
26
|
+
*/
|
|
27
|
+
import type { SecretsBundle } from './bundles.js';
|
|
28
|
+
/** Prefix for all durable session items (device-local, no-ACL). */
|
|
29
|
+
export declare const SESSION_ITEM_PREFIX = "agents-cli.session.";
|
|
30
|
+
/** Fixed-name index item — the ONLY thing we ever need to find without a known
|
|
31
|
+
* bundle name, so it is read/written by this exact name (never enumerated). */
|
|
32
|
+
export declare const SESSION_INDEX_ITEM = "agents-cli.session.index";
|
|
33
|
+
/** One persisted unlocked bundle (mirrors the broker's StoredBundle + posture). */
|
|
34
|
+
export interface SessionEntry {
|
|
35
|
+
bundle: SecretsBundle;
|
|
36
|
+
env: Record<string, string>;
|
|
37
|
+
/** epoch ms; the entry is dead once Date.now() passes this. */
|
|
38
|
+
expiresAt: number;
|
|
39
|
+
/** true only for `--durable` unlocks — survives SLEEP. */
|
|
40
|
+
sleepPersist: boolean;
|
|
41
|
+
}
|
|
42
|
+
/** Metadata for one held bundle, kept in the index so we can rehydrate / prune
|
|
43
|
+
* without reading every blob. */
|
|
44
|
+
export interface SessionIndexMeta {
|
|
45
|
+
expiresAt: number;
|
|
46
|
+
sleepPersist: boolean;
|
|
47
|
+
}
|
|
48
|
+
export interface SessionIndex {
|
|
49
|
+
bundles: Record<string, SessionIndexMeta>;
|
|
50
|
+
}
|
|
51
|
+
/** The bundle names in the index that are still within their TTL at `now`. */
|
|
52
|
+
export declare function selectRehydratable(index: SessionIndex, now: number): string[];
|
|
53
|
+
/** Split an index on a SLEEP event: keep `sleepPersist` entries, report the rest
|
|
54
|
+
* (which the caller deletes from the keychain). Default entries re-lock on sleep. */
|
|
55
|
+
export declare function pruneOnSleep(index: SessionIndex): {
|
|
56
|
+
survivors: SessionIndex;
|
|
57
|
+
deletedNames: string[];
|
|
58
|
+
};
|
|
59
|
+
/** Drop entries past their TTL; report which were dropped so the caller can
|
|
60
|
+
* delete their blobs. */
|
|
61
|
+
export declare function pruneExpired(index: SessionIndex, now: number): {
|
|
62
|
+
survivors: SessionIndex;
|
|
63
|
+
expiredNames: string[];
|
|
64
|
+
};
|
|
65
|
+
/** Insert/replace one bundle in the index (pure). */
|
|
66
|
+
export declare function upsertEntry(index: SessionIndex, name: string, meta: SessionIndexMeta): SessionIndex;
|
|
67
|
+
/** Remove one bundle from the index (pure). */
|
|
68
|
+
export declare function removeEntry(index: SessionIndex, name: string): SessionIndex;
|
|
69
|
+
/** Read the session index by its fixed name. `{bundles:{}}` when absent/unreadable. */
|
|
70
|
+
export declare function readIndex(): SessionIndex;
|
|
71
|
+
/** Persist the index as a no-ACL item. Deletes the item when empty (tidy). */
|
|
72
|
+
export declare function writeIndex(index: SessionIndex): void;
|
|
73
|
+
/** Persist one unlocked bundle: write its blob no-ACL and record it in the index. */
|
|
74
|
+
export declare function saveSession(name: string, entry: SessionEntry): void;
|
|
75
|
+
/** Read one session blob by known name. Null when absent/expired/malformed. */
|
|
76
|
+
export declare function loadSession(name: string, now?: number): SessionEntry | null;
|
|
77
|
+
/** Delete one bundle's session blob and prune it from the index. */
|
|
78
|
+
export declare function deleteSession(name: string): void;
|
|
79
|
+
/** Delete every session blob + the index (for `secrets lock --all`). */
|
|
80
|
+
export declare function deleteAllSessions(): void;
|
|
81
|
+
/** Rehydrate every unexpired session into `[name, entry]` pairs for the broker to
|
|
82
|
+
* load on start; prunes expired index entries + blobs as a side effect. Survives
|
|
83
|
+
* upgrade/restart. Empty off darwin or when nothing is held. */
|
|
84
|
+
export declare function rehydrateSessions(now?: number): Array<{
|
|
85
|
+
name: string;
|
|
86
|
+
entry: SessionEntry;
|
|
87
|
+
}>;
|
|
88
|
+
/** On a SLEEP event: delete every non-`--durable` session (blob + index entry),
|
|
89
|
+
* keep the durable ones. Default bundles thus re-lock on sleep. */
|
|
90
|
+
export declare function pruneSessionsOnSleep(): void;
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable "unlocked session" store (macOS).
|
|
3
|
+
*
|
|
4
|
+
* The secrets broker (agent.ts) holds unlocked bundles only in RAM, so an
|
|
5
|
+
* `agents secrets unlock` grant is lost whenever that process dies: on an
|
|
6
|
+
* agents-cli upgrade (the postinstall bounces the daemon) and on system SLEEP.
|
|
7
|
+
* This module persists the resolved bundle+env of an unlocked bundle as a
|
|
8
|
+
* NO-ACL keychain item — the `set-no-acl` write path, `kSecAttrAccessibleAfter-
|
|
9
|
+
* FirstUnlockThisDeviceOnly`, whose reads never pop Touch ID — so the broker can
|
|
10
|
+
* REHYDRATE it on start and reads can FALL BACK to it when the broker RAM misses.
|
|
11
|
+
*
|
|
12
|
+
* Split-default posture: `sleepPersist` is false by default — the item is deleted
|
|
13
|
+
* on SLEEP so the bundle re-locks on sleep but survives restart/upgrade; the
|
|
14
|
+
* `--durable` flag sets it true so it survives sleep too. Both expire at the TTL.
|
|
15
|
+
*
|
|
16
|
+
* Cross-platform: a NO-OP off macOS. Linux (secret-service) and Windows
|
|
17
|
+
* (Credential Manager) have no broker and resolve secrets durably from the OS
|
|
18
|
+
* store on every read with no prompt (see agent.ts:onDarwin gates) — there is no
|
|
19
|
+
* ephemeral unlock to persist.
|
|
20
|
+
*
|
|
21
|
+
* NEVER enumerate session items: once keychain-name hashing (#316) is active a
|
|
22
|
+
* `list('agents-cli.session.')` matches nothing. All I/O is by KNOWN name — one
|
|
23
|
+
* fixed-name index item lists the held bundles + their metadata, and one blob per
|
|
24
|
+
* bundle holds the env. Every adapter call is best-effort and never throws;
|
|
25
|
+
* persistence is an optimization, not a correctness dependency.
|
|
26
|
+
*/
|
|
27
|
+
import { getKeychainToken, setKeychainToken, deleteKeychainToken, isKeychainBackendOverridden } from './index.js';
|
|
28
|
+
/** Prefix for all durable session items (device-local, no-ACL). */
|
|
29
|
+
export const SESSION_ITEM_PREFIX = 'agents-cli.session.';
|
|
30
|
+
/** Fixed-name index item — the ONLY thing we ever need to find without a known
|
|
31
|
+
* bundle name, so it is read/written by this exact name (never enumerated). */
|
|
32
|
+
export const SESSION_INDEX_ITEM = 'agents-cli.session.index';
|
|
33
|
+
// ─── Pure core (no I/O — unit-testable on any platform) ─────────────────────
|
|
34
|
+
/** The bundle names in the index that are still within their TTL at `now`. */
|
|
35
|
+
export function selectRehydratable(index, now) {
|
|
36
|
+
return Object.entries(index.bundles)
|
|
37
|
+
.filter(([, m]) => now < m.expiresAt)
|
|
38
|
+
.map(([name]) => name);
|
|
39
|
+
}
|
|
40
|
+
/** Split an index on a SLEEP event: keep `sleepPersist` entries, report the rest
|
|
41
|
+
* (which the caller deletes from the keychain). Default entries re-lock on sleep. */
|
|
42
|
+
export function pruneOnSleep(index) {
|
|
43
|
+
const survivors = { bundles: {} };
|
|
44
|
+
const deletedNames = [];
|
|
45
|
+
for (const [name, m] of Object.entries(index.bundles)) {
|
|
46
|
+
if (m.sleepPersist)
|
|
47
|
+
survivors.bundles[name] = m;
|
|
48
|
+
else
|
|
49
|
+
deletedNames.push(name);
|
|
50
|
+
}
|
|
51
|
+
return { survivors, deletedNames };
|
|
52
|
+
}
|
|
53
|
+
/** Drop entries past their TTL; report which were dropped so the caller can
|
|
54
|
+
* delete their blobs. */
|
|
55
|
+
export function pruneExpired(index, now) {
|
|
56
|
+
const survivors = { bundles: {} };
|
|
57
|
+
const expiredNames = [];
|
|
58
|
+
for (const [name, m] of Object.entries(index.bundles)) {
|
|
59
|
+
if (now < m.expiresAt)
|
|
60
|
+
survivors.bundles[name] = m;
|
|
61
|
+
else
|
|
62
|
+
expiredNames.push(name);
|
|
63
|
+
}
|
|
64
|
+
return { survivors, expiredNames };
|
|
65
|
+
}
|
|
66
|
+
/** Insert/replace one bundle in the index (pure). */
|
|
67
|
+
export function upsertEntry(index, name, meta) {
|
|
68
|
+
return { bundles: { ...index.bundles, [name]: meta } };
|
|
69
|
+
}
|
|
70
|
+
/** Remove one bundle from the index (pure). */
|
|
71
|
+
export function removeEntry(index, name) {
|
|
72
|
+
const { [name]: _drop, ...rest } = index.bundles;
|
|
73
|
+
void _drop;
|
|
74
|
+
return { bundles: rest };
|
|
75
|
+
}
|
|
76
|
+
// ─── Keychain adapter (macOS only; best-effort, never throws) ────────────────
|
|
77
|
+
/** Whether to actually persist. macOS in production; also whenever a test
|
|
78
|
+
* keychain backend is installed, so the adapter is exercisable on Linux CI
|
|
79
|
+
* against an in-memory store (mirrors the broker's hermetic-test gating). In real
|
|
80
|
+
* Linux/Windows production this is false — secrets already persist in the OS store
|
|
81
|
+
* with no broker, so there is nothing to mirror. */
|
|
82
|
+
function shouldPersist() {
|
|
83
|
+
return process.platform === 'darwin' || isKeychainBackendOverridden();
|
|
84
|
+
}
|
|
85
|
+
function sessionBlobItem(name) {
|
|
86
|
+
return `${SESSION_ITEM_PREFIX}${name}`;
|
|
87
|
+
}
|
|
88
|
+
/** Read the session index by its fixed name. `{bundles:{}}` when absent/unreadable. */
|
|
89
|
+
export function readIndex() {
|
|
90
|
+
try {
|
|
91
|
+
const raw = getKeychainToken(SESSION_INDEX_ITEM);
|
|
92
|
+
const parsed = JSON.parse(raw);
|
|
93
|
+
if (parsed && typeof parsed === 'object' && parsed.bundles)
|
|
94
|
+
return parsed;
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
/* absent or malformed → empty */
|
|
98
|
+
}
|
|
99
|
+
return { bundles: {} };
|
|
100
|
+
}
|
|
101
|
+
/** Persist the index as a no-ACL item. Deletes the item when empty (tidy). */
|
|
102
|
+
export function writeIndex(index) {
|
|
103
|
+
try {
|
|
104
|
+
if (Object.keys(index.bundles).length === 0) {
|
|
105
|
+
deleteKeychainToken(SESSION_INDEX_ITEM);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
setKeychainToken(SESSION_INDEX_ITEM, JSON.stringify(index), { noAcl: true });
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
/* best-effort */
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/** Persist one unlocked bundle: write its blob no-ACL and record it in the index. */
|
|
115
|
+
export function saveSession(name, entry) {
|
|
116
|
+
if (!shouldPersist())
|
|
117
|
+
return;
|
|
118
|
+
try {
|
|
119
|
+
setKeychainToken(sessionBlobItem(name), JSON.stringify(entry), { noAcl: true });
|
|
120
|
+
writeIndex(upsertEntry(readIndex(), name, { expiresAt: entry.expiresAt, sleepPersist: entry.sleepPersist }));
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
/* best-effort — persistence is an optimization */
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/** Read one session blob by known name. Null when absent/expired/malformed. */
|
|
127
|
+
export function loadSession(name, now = Date.now()) {
|
|
128
|
+
if (!shouldPersist())
|
|
129
|
+
return null;
|
|
130
|
+
try {
|
|
131
|
+
const raw = getKeychainToken(sessionBlobItem(name));
|
|
132
|
+
const entry = JSON.parse(raw);
|
|
133
|
+
if (!entry || typeof entry !== 'object' || !entry.bundle || !entry.env)
|
|
134
|
+
return null;
|
|
135
|
+
if (now >= entry.expiresAt) {
|
|
136
|
+
deleteSession(name); // drop expired on read, mirroring the broker's get handler
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
return entry;
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/** Delete one bundle's session blob and prune it from the index. */
|
|
146
|
+
export function deleteSession(name) {
|
|
147
|
+
if (!shouldPersist())
|
|
148
|
+
return;
|
|
149
|
+
try {
|
|
150
|
+
deleteKeychainToken(sessionBlobItem(name));
|
|
151
|
+
writeIndex(removeEntry(readIndex(), name));
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
/* best-effort */
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/** Delete every session blob + the index (for `secrets lock --all`). */
|
|
158
|
+
export function deleteAllSessions() {
|
|
159
|
+
if (!shouldPersist())
|
|
160
|
+
return;
|
|
161
|
+
try {
|
|
162
|
+
for (const name of Object.keys(readIndex().bundles)) {
|
|
163
|
+
try {
|
|
164
|
+
deleteKeychainToken(sessionBlobItem(name));
|
|
165
|
+
}
|
|
166
|
+
catch { /* keep going */ }
|
|
167
|
+
}
|
|
168
|
+
deleteKeychainToken(SESSION_INDEX_ITEM);
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
/* best-effort */
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/** Rehydrate every unexpired session into `[name, entry]` pairs for the broker to
|
|
175
|
+
* load on start; prunes expired index entries + blobs as a side effect. Survives
|
|
176
|
+
* upgrade/restart. Empty off darwin or when nothing is held. */
|
|
177
|
+
export function rehydrateSessions(now = Date.now()) {
|
|
178
|
+
if (!shouldPersist())
|
|
179
|
+
return [];
|
|
180
|
+
const out = [];
|
|
181
|
+
try {
|
|
182
|
+
const index = readIndex();
|
|
183
|
+
const { survivors, expiredNames } = pruneExpired(index, now);
|
|
184
|
+
for (const name of expiredNames) {
|
|
185
|
+
try {
|
|
186
|
+
deleteKeychainToken(sessionBlobItem(name));
|
|
187
|
+
}
|
|
188
|
+
catch { /* keep going */ }
|
|
189
|
+
}
|
|
190
|
+
if (expiredNames.length)
|
|
191
|
+
writeIndex(survivors);
|
|
192
|
+
for (const name of selectRehydratable(survivors, now)) {
|
|
193
|
+
const entry = loadSession(name, now);
|
|
194
|
+
if (entry)
|
|
195
|
+
out.push({ name, entry });
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
/* best-effort */
|
|
200
|
+
}
|
|
201
|
+
return out;
|
|
202
|
+
}
|
|
203
|
+
/** On a SLEEP event: delete every non-`--durable` session (blob + index entry),
|
|
204
|
+
* keep the durable ones. Default bundles thus re-lock on sleep. */
|
|
205
|
+
export function pruneSessionsOnSleep() {
|
|
206
|
+
if (!shouldPersist())
|
|
207
|
+
return;
|
|
208
|
+
try {
|
|
209
|
+
const { survivors, deletedNames } = pruneOnSleep(readIndex());
|
|
210
|
+
for (const name of deletedNames) {
|
|
211
|
+
try {
|
|
212
|
+
deleteKeychainToken(sessionBlobItem(name));
|
|
213
|
+
}
|
|
214
|
+
catch { /* keep going */ }
|
|
215
|
+
}
|
|
216
|
+
writeIndex(survivors);
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
/* best-effort */
|
|
220
|
+
}
|
|
221
|
+
}
|