@phnx-labs/agents-cli 1.20.69 → 1.20.71
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 +81 -0
- package/README.md +19 -6
- package/dist/bin/agents +0 -0
- package/dist/commands/apply.js +36 -3
- package/dist/commands/check.js +3 -1
- package/dist/commands/doctor.js +5 -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/secrets.d.ts +1 -0
- package/dist/commands/secrets.js +24 -4
- package/dist/commands/sessions-tail.js +1 -2
- package/dist/commands/sessions.js +6 -6
- package/dist/commands/ssh.js +63 -16
- package/dist/commands/usage.d.ts +13 -0
- package/dist/commands/usage.js +50 -28
- package/dist/commands/view.d.ts +1 -0
- package/dist/commands/view.js +5 -2
- package/dist/lib/auth-health.d.ts +38 -0
- package/dist/lib/auth-health.js +48 -3
- package/dist/lib/commands.d.ts +10 -0
- package/dist/lib/commands.js +31 -7
- package/dist/lib/computer/download.d.ts +3 -0
- package/dist/lib/computer/download.js +13 -12
- package/dist/lib/daemon.js +38 -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/health-report.d.ts +11 -0
- package/dist/lib/devices/health-report.js +73 -4
- package/dist/lib/devices/stats-cache.d.ts +36 -0
- package/dist/lib/devices/stats-cache.js +115 -0
- 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/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -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/skills.d.ts +8 -1
- package/dist/lib/skills.js +11 -1
- package/dist/lib/types.d.ts +5 -1
- package/package.json +1 -1
|
@@ -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
|
+
}
|
|
@@ -108,6 +108,14 @@ export declare function renderConversationMarkdown(events: SessionEvent[], opts?
|
|
|
108
108
|
* `{ session, events }` wrapper so top-level fields (`plan`, `prUrl`, …) that
|
|
109
109
|
* live on `SessionMeta` are visible without re-parsing events. Legacy
|
|
110
110
|
* callers that pass no meta still get a bare `SessionEvent[]` array.
|
|
111
|
+
*
|
|
112
|
+
* Secrets are redacted by default (`opts.redact !== false`), matching
|
|
113
|
+
* `renderConversationMarkdown` — the JSON path must not be the one output
|
|
114
|
+
* format that leaks credentials. Redaction covers both events AND the session
|
|
115
|
+
* meta (e.g. `topic`, a verbatim first-message excerpt). Pass `{ redact: false }`
|
|
116
|
+
* for `--no-redact`.
|
|
111
117
|
*/
|
|
112
|
-
export declare function renderJson(events: SessionEvent[], meta?: SessionMeta
|
|
118
|
+
export declare function renderJson(events: SessionEvent[], meta?: SessionMeta, opts?: {
|
|
119
|
+
redact?: boolean;
|
|
120
|
+
}): string;
|
|
113
121
|
export {};
|
|
@@ -913,19 +913,50 @@ export function renderConversationMarkdown(events, opts = {}) {
|
|
|
913
913
|
}
|
|
914
914
|
return parts.join('\n\n');
|
|
915
915
|
}
|
|
916
|
+
/**
|
|
917
|
+
* Deep-redact every string reachable from a JSON value, preserving structure.
|
|
918
|
+
* `redactSecrets` only rewrites secret-shaped substrings, so non-secret strings
|
|
919
|
+
* (paths, URLs, ids) and non-strings (numbers, booleans) pass through unchanged.
|
|
920
|
+
* Applied to the whole `--json` payload so every free-text field — event
|
|
921
|
+
* `content`/`command`/`output`/`args`, and meta `topic`/`label`/`plan`/`todos`
|
|
922
|
+
* — is covered, including fields added later.
|
|
923
|
+
*/
|
|
924
|
+
function redactDeep(value, sanitize) {
|
|
925
|
+
if (typeof value === 'string')
|
|
926
|
+
return sanitize(value);
|
|
927
|
+
if (Array.isArray(value))
|
|
928
|
+
return value.map((item) => redactDeep(item, sanitize));
|
|
929
|
+
if (value && typeof value === 'object') {
|
|
930
|
+
const out = {};
|
|
931
|
+
for (const [key, val] of Object.entries(value))
|
|
932
|
+
out[key] = redactDeep(val, sanitize);
|
|
933
|
+
return out;
|
|
934
|
+
}
|
|
935
|
+
return value;
|
|
936
|
+
}
|
|
916
937
|
/**
|
|
917
938
|
* Render one session's JSON output — the shape `agents sessions <id> --json`
|
|
918
939
|
* emits. When `meta` is provided (the standard path), returns a
|
|
919
940
|
* `{ session, events }` wrapper so top-level fields (`plan`, `prUrl`, …) that
|
|
920
941
|
* live on `SessionMeta` are visible without re-parsing events. Legacy
|
|
921
942
|
* callers that pass no meta still get a bare `SessionEvent[]` array.
|
|
943
|
+
*
|
|
944
|
+
* Secrets are redacted by default (`opts.redact !== false`), matching
|
|
945
|
+
* `renderConversationMarkdown` — the JSON path must not be the one output
|
|
946
|
+
* format that leaks credentials. Redaction covers both events AND the session
|
|
947
|
+
* meta (e.g. `topic`, a verbatim first-message excerpt). Pass `{ redact: false }`
|
|
948
|
+
* for `--no-redact`.
|
|
922
949
|
*/
|
|
923
|
-
export function renderJson(events, meta) {
|
|
950
|
+
export function renderJson(events, meta, opts = {}) {
|
|
951
|
+
const redact = opts.redact !== false;
|
|
952
|
+
const sanitize = (text) => redactSecrets(text);
|
|
953
|
+
const safeEvents = redact ? events.map((event) => redactDeep(event, sanitize)) : events;
|
|
924
954
|
if (!meta)
|
|
925
|
-
return JSON.stringify(
|
|
955
|
+
return JSON.stringify(safeEvents, null, 2);
|
|
926
956
|
// Strip internal-only bookkeeping fields the listing --json path also strips.
|
|
927
|
-
const { _matchedTerms, _bm25Score, _remote, ...
|
|
928
|
-
|
|
957
|
+
const { _matchedTerms, _bm25Score, _remote, ...rest } = meta;
|
|
958
|
+
const session = redact ? redactDeep(rest, sanitize) : rest;
|
|
959
|
+
return JSON.stringify({ session, events: safeEvents }, null, 2);
|
|
929
960
|
}
|
|
930
961
|
/** Replace the home directory prefix with ~ for trace display. */
|
|
931
962
|
function shortenPathTrace(p) {
|
|
@@ -21,6 +21,8 @@ export declare const OG_HEIGHT = 630;
|
|
|
21
21
|
export declare const OG_SCALE = 2;
|
|
22
22
|
/** Ordered list of candidate Chromium-family binaries to try for headless capture. */
|
|
23
23
|
export declare function candidateBrowsers(): string[];
|
|
24
|
+
/** Newest-first Chromium binaries under the Playwright / Puppeteer download caches. */
|
|
25
|
+
export declare function scanCaches(home?: string, platform?: NodeJS.Platform): string[];
|
|
24
26
|
/**
|
|
25
27
|
* Screenshot `htmlPath`'s top 1200×630 (its hero) to a PNG buffer, or null if no
|
|
26
28
|
* headless-capable browser is available or every candidate fails. Never throws —
|
|
@@ -55,22 +55,29 @@ export function candidateBrowsers() {
|
|
|
55
55
|
return out;
|
|
56
56
|
}
|
|
57
57
|
/** Newest-first Chromium binaries under the Playwright / Puppeteer download caches. */
|
|
58
|
-
function scanCaches() {
|
|
59
|
-
const
|
|
60
|
-
const roots = os.platform() === 'darwin'
|
|
58
|
+
export function scanCaches(home = os.homedir(), platform = os.platform()) {
|
|
59
|
+
const roots = platform === 'darwin'
|
|
61
60
|
? [
|
|
62
61
|
path.join(home, 'Library/Caches/ms-playwright'),
|
|
63
62
|
path.join(home, '.cache/ms-playwright'),
|
|
64
63
|
path.join(home, '.cache/puppeteer/chrome'),
|
|
65
64
|
]
|
|
66
65
|
: [path.join(home, '.cache/ms-playwright'), path.join(home, '.cache/puppeteer/chrome')];
|
|
67
|
-
const rel =
|
|
66
|
+
const rel = platform === 'darwin'
|
|
68
67
|
? [
|
|
69
68
|
'chrome-mac/Chromium.app/Contents/MacOS/Chromium',
|
|
70
69
|
'chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing',
|
|
71
70
|
'chrome-mac-x64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing',
|
|
71
|
+
// Playwright's newer headless-shell packages (chromium_headless_shell-*) —
|
|
72
|
+
// a raw binary, not an .app bundle.
|
|
73
|
+
'chrome-headless-shell-mac-arm64/chrome-headless-shell',
|
|
74
|
+
'chrome-headless-shell-mac-x64/chrome-headless-shell',
|
|
72
75
|
]
|
|
73
|
-
: [
|
|
76
|
+
: [
|
|
77
|
+
'chrome-linux/chrome',
|
|
78
|
+
'chrome-linux64/chrome',
|
|
79
|
+
'chrome-headless-shell-linux64/chrome-headless-shell',
|
|
80
|
+
];
|
|
74
81
|
const found = [];
|
|
75
82
|
for (const root of roots) {
|
|
76
83
|
let entries;
|
package/dist/lib/skills.d.ts
CHANGED
|
@@ -92,7 +92,14 @@ export declare function listAllSkills(): string[];
|
|
|
92
92
|
*/
|
|
93
93
|
export declare function getVersionSkillsDir(agent: AgentId, version: string): string;
|
|
94
94
|
/**
|
|
95
|
-
* List skill names installed in a specific version home.
|
|
95
|
+
* List real skill names installed in a specific version home.
|
|
96
|
+
*
|
|
97
|
+
* Command-as-skill WRAPPERS (a `skills/<name>/SKILL.md` carrying the
|
|
98
|
+
* `agents_command` marker — how command-runtime agents like Codex/Kimi install
|
|
99
|
+
* commands) are excluded: they are commands, reconciled by the commands diff
|
|
100
|
+
* against the command source, not skills. Counting them here made the skill
|
|
101
|
+
* orphan/extra detectors flag every command wrapper (tickets, prune, swarm-plan,
|
|
102
|
+
* …) as an unmanaged skill — a false positive that `prune cleanup` would delete.
|
|
96
103
|
*/
|
|
97
104
|
export declare function listSkillsInVersionHome(agent: AgentId, version: string): string[];
|
|
98
105
|
export interface VersionSkillDiff {
|
package/dist/lib/skills.js
CHANGED
|
@@ -14,6 +14,7 @@ import { AGENTS, ensureSkillsDir, agentConfigDirName } from './agents.js';
|
|
|
14
14
|
import { capableAgents, isCapable } from './capabilities.js';
|
|
15
15
|
import { getUserSkillsDir, getSkillsDir as getSystemSkillsDir, getProjectAgentsDir, getEnabledExtraRepos, getTrashSkillsDir } from './state.js';
|
|
16
16
|
import { getEffectiveHome, getVersionHomePath, listInstalledVersions } from './versions.js';
|
|
17
|
+
import { listCommandSkillsInVersion } from './command-skills.js';
|
|
17
18
|
import { emit } from './events.js';
|
|
18
19
|
const HOME = os.homedir();
|
|
19
20
|
/** User-scoped skills dir (~/.agents/skills/). Used for installs. */
|
|
@@ -392,16 +393,25 @@ export function getVersionSkillsDir(agent, version) {
|
|
|
392
393
|
return path.join(home, agentConfigDirName(agent), 'skills');
|
|
393
394
|
}
|
|
394
395
|
/**
|
|
395
|
-
* List skill names installed in a specific version home.
|
|
396
|
+
* List real skill names installed in a specific version home.
|
|
397
|
+
*
|
|
398
|
+
* Command-as-skill WRAPPERS (a `skills/<name>/SKILL.md` carrying the
|
|
399
|
+
* `agents_command` marker — how command-runtime agents like Codex/Kimi install
|
|
400
|
+
* commands) are excluded: they are commands, reconciled by the commands diff
|
|
401
|
+
* against the command source, not skills. Counting them here made the skill
|
|
402
|
+
* orphan/extra detectors flag every command wrapper (tickets, prune, swarm-plan,
|
|
403
|
+
* …) as an unmanaged skill — a false positive that `prune cleanup` would delete.
|
|
396
404
|
*/
|
|
397
405
|
export function listSkillsInVersionHome(agent, version) {
|
|
398
406
|
const dir = getVersionSkillsDir(agent, version);
|
|
399
407
|
if (!fs.existsSync(dir))
|
|
400
408
|
return [];
|
|
409
|
+
const commandWrappers = new Set(listCommandSkillsInVersion(path.join(getVersionHomePath(agent, version), agentConfigDirName(agent))));
|
|
401
410
|
return fs.readdirSync(dir, { withFileTypes: true })
|
|
402
411
|
.filter((e) => e.isDirectory() && !e.name.startsWith('.'))
|
|
403
412
|
.filter((e) => fs.existsSync(path.join(dir, e.name, 'SKILL.md')))
|
|
404
413
|
.map((e) => e.name)
|
|
414
|
+
.filter((name) => !commandWrappers.has(name))
|
|
405
415
|
.sort();
|
|
406
416
|
}
|
|
407
417
|
/**
|
package/dist/lib/types.d.ts
CHANGED
|
@@ -664,12 +664,16 @@ export interface Meta {
|
|
|
664
664
|
* concurrent runs read silently — set it `false` to force a prompt on every read.
|
|
665
665
|
* `holdMs` caps how long an unlocked/auto-cached bundle is held before the next
|
|
666
666
|
* read re-prompts (default 7 days; e.g. 86400000 for a 24h cap). Clamped to
|
|
667
|
-
* [1 minute, 30 days].
|
|
667
|
+
* [1 minute, 30 days]. `durable` (default off) makes every `agents secrets
|
|
668
|
+
* unlock` survive sleep + reboot as well as upgrade/restart — the same effect as
|
|
669
|
+
* passing `--durable` per unlock; off means the secure split default (survive
|
|
670
|
+
* upgrade/restart, re-lock on sleep). */
|
|
668
671
|
secrets?: {
|
|
669
672
|
policy?: 'always' | 'daily';
|
|
670
673
|
agent?: {
|
|
671
674
|
auto?: boolean;
|
|
672
675
|
holdMs?: number;
|
|
676
|
+
durable?: boolean;
|
|
673
677
|
};
|
|
674
678
|
};
|
|
675
679
|
/** Spend guardrails (issue #346). User-global caps; project agents.yaml overrides. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@phnx-labs/agents-cli",
|
|
3
|
-
"version": "1.20.
|
|
3
|
+
"version": "1.20.71",
|
|
4
4
|
"description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|