@phnx-labs/agents-cli 1.20.51 → 1.20.53
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 +71 -0
- package/README.md +12 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/browser.js +215 -7
- package/dist/commands/cloud.d.ts +3 -0
- package/dist/commands/cloud.js +8 -1
- package/dist/commands/events.d.ts +1 -1
- package/dist/commands/events.js +2 -3
- package/dist/commands/exec.js +82 -3
- package/dist/commands/factory.js +8 -0
- package/dist/commands/feed.d.ts +38 -0
- package/dist/commands/feed.js +274 -0
- package/dist/commands/logs.d.ts +5 -1
- package/dist/commands/logs.js +248 -3
- package/dist/commands/mcp.js +7 -0
- package/dist/commands/memory.d.ts +9 -0
- package/dist/commands/memory.js +164 -0
- package/dist/commands/message.d.ts +11 -6
- package/dist/commands/message.js +140 -5
- package/dist/commands/routines.js +12 -0
- package/dist/commands/secrets-migrate.d.ts +2 -1
- package/dist/commands/secrets-migrate.js +88 -13
- package/dist/commands/secrets.d.ts +22 -0
- package/dist/commands/secrets.js +176 -42
- package/dist/commands/sessions.js +10 -1
- package/dist/commands/teams.js +4 -0
- package/dist/commands/worktree.js +4 -2
- package/dist/index.js +21 -22
- package/dist/lib/agents.js +249 -17
- package/dist/lib/answer-router.d.ts +75 -0
- package/dist/lib/answer-router.js +149 -0
- package/dist/lib/ask-classifier.d.ts +71 -0
- package/dist/lib/ask-classifier.js +197 -0
- package/dist/lib/browser/login-detection.d.ts +94 -0
- package/dist/lib/browser/login-detection.js +274 -0
- package/dist/lib/browser/profiles.d.ts +17 -8
- package/dist/lib/browser/profiles.js +27 -8
- package/dist/lib/browser/secret-ref.d.ts +10 -0
- package/dist/lib/browser/secret-ref.js +14 -0
- package/dist/lib/browser/service.js +14 -12
- package/dist/lib/cloud/antigravity.d.ts +0 -2
- package/dist/lib/cloud/antigravity.js +2 -17
- package/dist/lib/cloud/codex.js +3 -18
- package/dist/lib/cloud/rush.d.ts +15 -0
- package/dist/lib/cloud/rush.js +10 -16
- package/dist/lib/cloud/stream.js +2 -0
- package/dist/lib/cloud/types.d.ts +21 -0
- package/dist/lib/cloud/types.js +81 -0
- package/dist/lib/crabbox/cli.d.ts +1 -1
- package/dist/lib/crabbox/cli.js +12 -2
- package/dist/lib/crabbox/lease.d.ts +19 -0
- package/dist/lib/crabbox/lease.js +22 -11
- package/dist/lib/crabbox/progress.d.ts +62 -0
- package/dist/lib/crabbox/progress.js +129 -0
- package/dist/lib/crabbox/runtimes.d.ts +38 -1
- package/dist/lib/crabbox/runtimes.js +98 -5
- package/dist/lib/daemon.d.ts +12 -9
- package/dist/lib/daemon.js +32 -17
- package/dist/lib/events.d.ts +31 -5
- package/dist/lib/events.js +291 -101
- package/dist/lib/exec.js +20 -1
- package/dist/lib/feed-outcome.d.ts +101 -0
- package/dist/lib/feed-outcome.js +244 -0
- package/dist/lib/feed-policy.d.ts +30 -0
- package/dist/lib/feed-policy.js +133 -0
- package/dist/lib/feed.d.ts +180 -0
- package/dist/lib/feed.js +627 -0
- package/dist/lib/git.d.ts +17 -1
- package/dist/lib/git.js +20 -1
- package/dist/lib/hooks.js +529 -14
- package/dist/lib/hosts/passthrough.d.ts +3 -3
- package/dist/lib/hosts/passthrough.js +3 -3
- package/dist/lib/mailbox-gc.d.ts +22 -0
- package/dist/lib/mailbox-gc.js +161 -0
- package/dist/lib/mailbox.d.ts +26 -2
- package/dist/lib/mailbox.js +80 -5
- package/dist/lib/mcp.js +82 -0
- package/dist/lib/memory.d.ts +55 -0
- package/dist/lib/memory.js +274 -0
- package/dist/lib/notify.d.ts +16 -0
- package/dist/lib/notify.js +61 -0
- package/dist/lib/operator.d.ts +26 -0
- package/dist/lib/operator.js +107 -0
- package/dist/lib/plugins.d.ts +35 -0
- package/dist/lib/plugins.js +217 -0
- package/dist/lib/remote-agents-json.d.ts +14 -0
- package/dist/lib/remote-agents-json.js +94 -0
- package/dist/lib/resources/mcp.js +44 -0
- package/dist/lib/resources/memory.d.ts +15 -0
- package/dist/lib/resources/memory.js +46 -0
- package/dist/lib/resources/types.d.ts +2 -2
- package/dist/lib/rotate.js +2 -0
- package/dist/lib/runner.d.ts +43 -0
- package/dist/lib/runner.js +323 -74
- package/dist/lib/sandbox.js +6 -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 +21 -0
- package/dist/lib/secrets/agent.js +63 -1
- package/dist/lib/secrets/bundles.d.ts +33 -1
- package/dist/lib/secrets/bundles.js +76 -21
- package/dist/lib/secrets/icloud-import.d.ts +79 -0
- package/dist/lib/secrets/icloud-import.js +203 -0
- package/dist/lib/secrets/index.d.ts +142 -2
- package/dist/lib/secrets/index.js +695 -30
- package/dist/lib/secrets/remote.js +1 -1
- package/dist/lib/secrets/sync.js +1 -1
- package/dist/lib/session/active.d.ts +18 -0
- package/dist/lib/session/active.js +47 -17
- package/dist/lib/session/db.d.ts +9 -1
- package/dist/lib/session/db.js +18 -3
- package/dist/lib/session/discover.d.ts +13 -0
- package/dist/lib/session/discover.js +32 -2
- package/dist/lib/session/parse.d.ts +8 -0
- package/dist/lib/session/parse.js +42 -21
- package/dist/lib/session/remote-active.js +8 -89
- package/dist/lib/session/state.d.ts +11 -0
- package/dist/lib/session/state.js +50 -1
- package/dist/lib/session/tail.d.ts +23 -4
- package/dist/lib/session/tail.js +34 -16
- package/dist/lib/session/throughput.d.ts +30 -0
- package/dist/lib/session/throughput.js +86 -0
- package/dist/lib/shim-heal.d.ts +12 -3
- package/dist/lib/shim-heal.js +12 -6
- package/dist/lib/staleness/detectors/subagents.js +57 -3
- package/dist/lib/staleness/writers/hooks.js +7 -3
- package/dist/lib/staleness/writers/subagents.js +37 -6
- package/dist/lib/startup/command-registry.d.ts +2 -0
- package/dist/lib/startup/command-registry.js +4 -0
- package/dist/lib/state.d.ts +2 -0
- package/dist/lib/state.js +25 -8
- package/dist/lib/subagents.d.ts +52 -0
- package/dist/lib/subagents.js +315 -12
- package/dist/lib/teams/agents.js +6 -3
- package/dist/lib/teams/worktree.d.ts +8 -0
- package/dist/lib/teams/worktree.js +8 -0
- package/dist/lib/types.d.ts +20 -2
- package/dist/lib/versions.js +38 -48
- package/dist/lib/whats-new.d.ts +5 -3
- package/dist/lib/whats-new.js +25 -5
- package/package.json +4 -3
- package/scripts/postinstall.js +61 -1
|
@@ -89,6 +89,9 @@ export interface LegacyBundleCandidate {
|
|
|
89
89
|
file: string;
|
|
90
90
|
keys: string[];
|
|
91
91
|
}
|
|
92
|
+
export declare const BUNDLE_NAME_PATTERN: RegExp;
|
|
93
|
+
export declare const ENV_KEY_PATTERN: RegExp;
|
|
94
|
+
export declare const BUNDLE_META_PREFIX = "agents-cli.bundles.";
|
|
92
95
|
export declare const RESERVED_ENV_NAMES: Set<string>;
|
|
93
96
|
export declare function bundleToEnvPrefix(name: string): string;
|
|
94
97
|
export declare function isReservedEnvName(key: string): boolean;
|
|
@@ -114,7 +117,29 @@ export declare function readBundle(name: string): SecretsBundle;
|
|
|
114
117
|
export declare function secretsDefaultPolicy(): SecretsPolicy;
|
|
115
118
|
/** The effective prompt policy of a bundle (absent ⇒ the configured default). */
|
|
116
119
|
export declare function bundlePolicy(bundle: SecretsBundle): SecretsPolicy;
|
|
117
|
-
|
|
120
|
+
/** Options for writeBundle. */
|
|
121
|
+
export interface WriteBundleOptions {
|
|
122
|
+
/**
|
|
123
|
+
* Skip evicting the bundle from the secrets-agent broker after the write.
|
|
124
|
+
* Only for writers that change nothing the broker serves — today that is
|
|
125
|
+
* stampLastUsed (a usage-telemetry timestamp, fired on every broker HIT):
|
|
126
|
+
* evicting there would make the cache destroy itself on first use. Every
|
|
127
|
+
* mutating writer (add / rotate / remove / rename / policy / import) must
|
|
128
|
+
* leave this unset so a broker-held copy never serves stale values for up
|
|
129
|
+
* to the ~7d hold.
|
|
130
|
+
*/
|
|
131
|
+
skipBrokerEviction?: boolean;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Whether a bundle write should evict the broker-held copy. Pure + exported
|
|
135
|
+
* for regression coverage. Skips when the writer opted out (stampLastUsed),
|
|
136
|
+
* when the broker integration is disabled (AGENTS_SECRETS_NO_AGENT — the same
|
|
137
|
+
* kill-switch the read fast-path honors), or when a test keychain backend is
|
|
138
|
+
* installed (an in-memory backend has no real keychain behind it, and a test
|
|
139
|
+
* writing bundle 'prod' must never evict the user's real 'prod' unlock).
|
|
140
|
+
*/
|
|
141
|
+
export declare function shouldEvictAfterBundleWrite(skipRequested: boolean, noAgentEnv: string | undefined, backendOverridden: boolean): boolean;
|
|
142
|
+
export declare function writeBundle(bundle: SecretsBundle, opts?: WriteBundleOptions): void;
|
|
118
143
|
export declare function deleteBundle(name: string): boolean;
|
|
119
144
|
export declare function listBundles(): SecretsBundle[];
|
|
120
145
|
export interface BundleEntryInfo {
|
|
@@ -140,6 +165,13 @@ export interface ResolveBundleOptions {
|
|
|
140
165
|
* needs live values. Also honored via AGENTS_SECRETS_NO_AGENT=1.
|
|
141
166
|
*/
|
|
142
167
|
noAgent?: boolean;
|
|
168
|
+
/**
|
|
169
|
+
* Resolve only from an already-unlocked secrets-agent snapshot. If the
|
|
170
|
+
* broker has no snapshot, fail before touching Keychain or any other store.
|
|
171
|
+
* Background processes use this to guarantee they never surface a biometric
|
|
172
|
+
* prompt that nobody can answer.
|
|
173
|
+
*/
|
|
174
|
+
agentOnly?: boolean;
|
|
143
175
|
/**
|
|
144
176
|
* Inject only this subset of keys from the bundle. Keys not in this list are
|
|
145
177
|
* silently excluded from the returned env map. An error is thrown if any
|
|
@@ -21,11 +21,11 @@ import * as fs from 'fs';
|
|
|
21
21
|
import * as os from 'os';
|
|
22
22
|
import * as path from 'path';
|
|
23
23
|
import * as yaml from 'yaml';
|
|
24
|
-
import { deleteKeychainToken, getKeychainToken, getKeychainTokens, hasKeychainToken, isKeychainBackendOverridden, keychainUsesFileFallback, listKeychainItems, parseBundleValue, resolveRef, secretsKeychainItem, setKeychainToken, } from './index.js';
|
|
24
|
+
import { deleteKeychainToken, getKeychainToken, getKeychainTokens, hasKeychainToken, isKeychainBackendOverridden, keychainServiceAlias, keychainUsesFileFallback, listKeychainItems, parseBundleValue, resolveRef, secretsKeychainItem, setKeychainToken, } from './index.js';
|
|
25
25
|
import { fileStore } from './filestore.js';
|
|
26
26
|
import { emit } from '../events.js';
|
|
27
27
|
import { readMeta } from '../state.js';
|
|
28
|
-
import { agentGetSync, agentAutoLoadSync, agentGetMetaSync, agentAutoLoadMetaSync, secretsAgentAutoEnabled, DEFAULT_TTL_MS } from './agent.js';
|
|
28
|
+
import { agentGetSync, agentAutoLoadSync, agentGetMetaSync, agentAutoLoadMetaSync, agentEvictSync, secretsAgentAutoEnabled, DEFAULT_TTL_MS } from './agent.js';
|
|
29
29
|
import { createHash } from 'node:crypto';
|
|
30
30
|
const keychainStore = {
|
|
31
31
|
has: hasKeychainToken,
|
|
@@ -105,9 +105,9 @@ export const SECRET_TYPES = [
|
|
|
105
105
|
];
|
|
106
106
|
/** Minimum gap between last_used updates so the keychain isn't written on every secrets injection. */
|
|
107
107
|
const LAST_USED_THROTTLE_MS = 60_000;
|
|
108
|
-
const BUNDLE_NAME_PATTERN = /^[a-z0-9][a-z0-9\-_.]{0,48}$/i;
|
|
109
|
-
const ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
110
|
-
const BUNDLE_META_PREFIX = 'agents-cli.bundles.';
|
|
108
|
+
export const BUNDLE_NAME_PATTERN = /^[a-z0-9][a-z0-9\-_.]{0,48}$/i;
|
|
109
|
+
export const ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
110
|
+
export const BUNDLE_META_PREFIX = 'agents-cli.bundles.';
|
|
111
111
|
const SECRETS_ITEM_PREFIX = 'agents-cli.secrets.';
|
|
112
112
|
export const RESERVED_ENV_NAMES = new Set([
|
|
113
113
|
'PATH', 'HOME', 'USER', 'USERNAME', 'SHELL', 'PWD', 'OLDPWD',
|
|
@@ -277,7 +277,24 @@ export function secretsDefaultPolicy() {
|
|
|
277
277
|
export function bundlePolicy(bundle) {
|
|
278
278
|
return bundle.policy ?? secretsDefaultPolicy();
|
|
279
279
|
}
|
|
280
|
-
|
|
280
|
+
/**
|
|
281
|
+
* Whether a bundle write should evict the broker-held copy. Pure + exported
|
|
282
|
+
* for regression coverage. Skips when the writer opted out (stampLastUsed),
|
|
283
|
+
* when the broker integration is disabled (AGENTS_SECRETS_NO_AGENT — the same
|
|
284
|
+
* kill-switch the read fast-path honors), or when a test keychain backend is
|
|
285
|
+
* installed (an in-memory backend has no real keychain behind it, and a test
|
|
286
|
+
* writing bundle 'prod' must never evict the user's real 'prod' unlock).
|
|
287
|
+
*/
|
|
288
|
+
export function shouldEvictAfterBundleWrite(skipRequested, noAgentEnv, backendOverridden) {
|
|
289
|
+
if (skipRequested)
|
|
290
|
+
return false;
|
|
291
|
+
if (noAgentEnv === '1')
|
|
292
|
+
return false;
|
|
293
|
+
if (backendOverridden)
|
|
294
|
+
return false;
|
|
295
|
+
return true;
|
|
296
|
+
}
|
|
297
|
+
export function writeBundle(bundle, opts = {}) {
|
|
281
298
|
validateBundleName(bundle.name);
|
|
282
299
|
const backend = bundle.backend ?? 'keychain';
|
|
283
300
|
if (backend === 'file')
|
|
@@ -311,6 +328,10 @@ export function writeBundle(bundle) {
|
|
|
311
328
|
bundle.created_at = now;
|
|
312
329
|
bundle.updated_at = now;
|
|
313
330
|
const payload = {
|
|
331
|
+
// The bundle's own name, persisted since #316: with hashed service names
|
|
332
|
+
// the keychain item name is opaque, so listBundles recovers the display
|
|
333
|
+
// name from this field. Older CLIs drop unknown fields on read — safe.
|
|
334
|
+
name: bundle.name,
|
|
314
335
|
description: bundle.description,
|
|
315
336
|
allow_exec: bundle.allow_exec ? true : undefined,
|
|
316
337
|
backend: backend === 'file' ? 'file' : undefined,
|
|
@@ -337,12 +358,20 @@ export function writeBundle(bundle) {
|
|
|
337
358
|
// no-ACL command is missing) rather than silently landing an ACL'd item.
|
|
338
359
|
itemStore(backend).set(bundleMetaItem(bundle.name), json, { noAcl: bundle.policy === 'never' });
|
|
339
360
|
emit('secrets.set', { module: 'secrets', bundle: bundle.name });
|
|
361
|
+
// A broker-held snapshot predates this write; evict it so the next read
|
|
362
|
+
// re-resolves from the keychain instead of serving stale values.
|
|
363
|
+
if (shouldEvictAfterBundleWrite(Boolean(opts.skipBrokerEviction), process.env.AGENTS_SECRETS_NO_AGENT, isKeychainBackendOverridden())) {
|
|
364
|
+
agentEvictSync(bundle.name);
|
|
365
|
+
}
|
|
340
366
|
}
|
|
341
367
|
export function deleteBundle(name) {
|
|
342
368
|
validateBundleName(name);
|
|
343
369
|
const deleted = itemStore(bundleBackend(name)).delete(bundleMetaItem(name));
|
|
344
370
|
if (deleted) {
|
|
345
371
|
emit('secrets.delete', { module: 'secrets', bundle: name });
|
|
372
|
+
if (shouldEvictAfterBundleWrite(false, process.env.AGENTS_SECRETS_NO_AGENT, isKeychainBackendOverridden())) {
|
|
373
|
+
agentEvictSync(name);
|
|
374
|
+
}
|
|
346
375
|
}
|
|
347
376
|
return deleted;
|
|
348
377
|
}
|
|
@@ -351,8 +380,14 @@ export function deleteBundle(name) {
|
|
|
351
380
|
* posture listBundles wants (skip malformed / invalid-key bundles rather than
|
|
352
381
|
* throw). `backend` is authoritative from where the item was found. Returns
|
|
353
382
|
* null to skip.
|
|
383
|
+
*
|
|
384
|
+
* `nameHint` is the name recovered from a cleartext service name (Linux, the
|
|
385
|
+
* file store, pre-re-key items) — authoritative when present, and the only
|
|
386
|
+
* source for legacy metadata that predates the persisted `name` field. With
|
|
387
|
+
* hashed service names (macOS, #316) the hint is undefined and the name comes
|
|
388
|
+
* from the JSON payload written by writeBundle.
|
|
354
389
|
*/
|
|
355
|
-
function parseBundleMeta(
|
|
390
|
+
function parseBundleMeta(nameHint, json, backend) {
|
|
356
391
|
let parsed;
|
|
357
392
|
try {
|
|
358
393
|
parsed = JSON.parse(json);
|
|
@@ -362,6 +397,9 @@ function parseBundleMeta(name, json, backend) {
|
|
|
362
397
|
}
|
|
363
398
|
if (!parsed || typeof parsed !== 'object')
|
|
364
399
|
return null;
|
|
400
|
+
const name = nameHint ?? (typeof parsed.name === 'string' ? parsed.name : undefined);
|
|
401
|
+
if (!name || !BUNDLE_NAME_PATTERN.test(name))
|
|
402
|
+
return null;
|
|
365
403
|
const bundle = {
|
|
366
404
|
name,
|
|
367
405
|
description: parsed.description,
|
|
@@ -406,10 +444,12 @@ export function listBundles() {
|
|
|
406
444
|
catch {
|
|
407
445
|
keychainServices = [];
|
|
408
446
|
}
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
447
|
+
// With hashed service names (macOS, #316) the enumerated services are
|
|
448
|
+
// opaque (`agents-cli.h.<ns>.m`) — the display name is recovered from the
|
|
449
|
+
// metadata JSON after the batch read below. Cleartext services (Linux,
|
|
450
|
+
// pre-re-key items) still carry the name; it's kept as the parse hint so
|
|
451
|
+
// legacy metadata without the persisted `name` field keeps listing.
|
|
452
|
+
if (keychainServices.length > 0) {
|
|
413
453
|
// Daily-policy fast-path (macOS). Bundle metadata items are biometry-gated,
|
|
414
454
|
// so the getKeychainTokens batch below pops Touch ID on every `secrets
|
|
415
455
|
// list` — the broker/`daily` mechanism only ever covered value reads, not
|
|
@@ -427,7 +467,7 @@ export function listBundles() {
|
|
|
427
467
|
!isKeychainBackendOverridden() &&
|
|
428
468
|
secretsAgentAutoEnabled();
|
|
429
469
|
const nameSetHash = createHash('sha256')
|
|
430
|
-
.update([...
|
|
470
|
+
.update([...keychainServices].sort().join('\n'))
|
|
431
471
|
.digest('hex')
|
|
432
472
|
.slice(0, 32);
|
|
433
473
|
const cached = useAgent ? agentGetMetaSync(nameSetHash) : null;
|
|
@@ -436,13 +476,16 @@ export function listBundles() {
|
|
|
436
476
|
out.push(bundle);
|
|
437
477
|
}
|
|
438
478
|
else {
|
|
439
|
-
const fetched = getKeychainTokens(
|
|
479
|
+
const fetched = getKeychainTokens(keychainServices);
|
|
440
480
|
const keychainBundles = [];
|
|
441
|
-
for (const
|
|
442
|
-
const json = fetched.get(
|
|
481
|
+
for (const service of keychainServices) {
|
|
482
|
+
const json = fetched.get(service);
|
|
443
483
|
if (json === undefined)
|
|
444
484
|
continue;
|
|
445
|
-
const
|
|
485
|
+
const nameHint = service.startsWith(BUNDLE_META_PREFIX)
|
|
486
|
+
? service.slice(BUNDLE_META_PREFIX.length)
|
|
487
|
+
: undefined;
|
|
488
|
+
const bundle = parseBundleMeta(nameHint, json, 'keychain');
|
|
446
489
|
if (bundle)
|
|
447
490
|
keychainBundles.push(bundle);
|
|
448
491
|
}
|
|
@@ -513,7 +556,9 @@ function stampLastUsed(bundle) {
|
|
|
513
556
|
}
|
|
514
557
|
try {
|
|
515
558
|
bundle.last_used = new Date(nowMs).toISOString();
|
|
516
|
-
|
|
559
|
+
// skipBrokerEviction: this stamp fires on every broker HIT; letting it
|
|
560
|
+
// evict would make the cache destroy itself on first use.
|
|
561
|
+
writeBundle(bundle, { skipBrokerEviction: true });
|
|
517
562
|
}
|
|
518
563
|
catch {
|
|
519
564
|
// Swallow — telemetry must never block secret resolution.
|
|
@@ -694,7 +739,7 @@ export function readAndResolveBundleEnv(name, opts = {}) {
|
|
|
694
739
|
emit('secrets.get', {
|
|
695
740
|
module: 'secrets',
|
|
696
741
|
bundle: name,
|
|
697
|
-
|
|
742
|
+
operation: opts.caller,
|
|
698
743
|
status: 'success',
|
|
699
744
|
source: 'agent',
|
|
700
745
|
keyCount: Object.keys(filtered.env).length,
|
|
@@ -702,6 +747,9 @@ export function readAndResolveBundleEnv(name, opts = {}) {
|
|
|
702
747
|
return filtered;
|
|
703
748
|
}
|
|
704
749
|
}
|
|
750
|
+
if (opts.agentOnly) {
|
|
751
|
+
throw new Error(`Secrets bundle '${name}' is not unlocked in the secrets agent.`);
|
|
752
|
+
}
|
|
705
753
|
if (backend === 'file')
|
|
706
754
|
assertFileBackendUsable(name);
|
|
707
755
|
const store = itemStore(backend);
|
|
@@ -718,7 +766,11 @@ export function readAndResolveBundleEnv(name, opts = {}) {
|
|
|
718
766
|
? `read ${name} secrets (for ${opts.caller})`
|
|
719
767
|
: `read ${name} secrets`;
|
|
720
768
|
void reason;
|
|
721
|
-
|
|
769
|
+
// secretItems are storage names as enumerated (opaque hashed names on macOS
|
|
770
|
+
// with #316 hashing active, cleartext elsewhere); metaItem is cleartext and
|
|
771
|
+
// hashed inside getBatch. Deduped because the hashed enumeration spans the
|
|
772
|
+
// bundle's whole namespace.
|
|
773
|
+
const fetched = store.getBatch([...new Set([metaItem, ...secretItems])]);
|
|
722
774
|
const json = fetched.get(metaItem);
|
|
723
775
|
if (json === undefined) {
|
|
724
776
|
// For a file-backed bundle the metadata item is on disk (that's how
|
|
@@ -784,7 +836,7 @@ export function readAndResolveBundleEnv(name, opts = {}) {
|
|
|
784
836
|
emit('secrets.get', {
|
|
785
837
|
module: 'secrets',
|
|
786
838
|
bundle: bundle.name,
|
|
787
|
-
|
|
839
|
+
operation: opts.caller,
|
|
788
840
|
status,
|
|
789
841
|
keyCount: keys.length,
|
|
790
842
|
keys,
|
|
@@ -805,7 +857,10 @@ export function readAndResolveBundleEnv(name, opts = {}) {
|
|
|
805
857
|
}
|
|
806
858
|
if (p.ref.provider === 'keychain') {
|
|
807
859
|
const item = secretsKeychainItem(bundle.name, p.ref.value);
|
|
808
|
-
|
|
860
|
+
// The batch keys results by the names it was ASKED for: the cleartext
|
|
861
|
+
// metaItem, plus enumerated storage names. Look up the cleartext name
|
|
862
|
+
// first (Linux / file store), then its hashed storage alias (macOS).
|
|
863
|
+
const value = fetched.get(item) ?? fetched.get(keychainServiceAlias(item));
|
|
809
864
|
if (value === undefined) {
|
|
810
865
|
throw new Error(`Bundle '${bundle.name}' key '${key}': stored item '${item}' not found. ` +
|
|
811
866
|
`Run: agents secrets add ${bundle.name} ${key}`);
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recovery for LEGACY SYNCHRONIZABLE (iCloud Keychain) bundles.
|
|
3
|
+
*
|
|
4
|
+
* The pre-biometry helper era defaulted bundles to iCloud Keychain sync. The
|
|
5
|
+
* device-local cutover (biometry ACL + kSecAttrSynchronizable false on every
|
|
6
|
+
* query) orphaned those items: they still sync back via iCloud Keychain, but
|
|
7
|
+
* neither `secrets list` nor `migrate-acl` can see them. This module powers
|
|
8
|
+
* `agents secrets import --from icloud` — discover the orphaned bundles,
|
|
9
|
+
* re-import them as normal device-local bundles, and optionally purge the
|
|
10
|
+
* iCloud copies.
|
|
11
|
+
*
|
|
12
|
+
* The item-name scheme is the same one the modern store uses (see bundles.ts):
|
|
13
|
+
* metadata under `agents-cli.bundles.<name>`, one value per key under
|
|
14
|
+
* `agents-cli.secrets.<bundle>.<key>`. Env keys can never contain a dot
|
|
15
|
+
* (ENV_KEY_PATTERN), so splitting a secret service at its LAST dot recovers
|
|
16
|
+
* the bundle/key boundary even for dotted bundle names like `hetzner.com`.
|
|
17
|
+
*/
|
|
18
|
+
import { type SecretsBackend } from './bundles.js';
|
|
19
|
+
/** One orphaned iCloud bundle, as discovered from the synced item names. */
|
|
20
|
+
export interface SyncedBundleCandidate {
|
|
21
|
+
/** Bundle name derived from the iCloud service names. */
|
|
22
|
+
name: string;
|
|
23
|
+
/** Env keys that have a per-key secret item in the iCloud keychain. */
|
|
24
|
+
keys: string[];
|
|
25
|
+
/** True when an `agents-cli.bundles.<name>` metadata item exists in iCloud. */
|
|
26
|
+
hasMeta: boolean;
|
|
27
|
+
/** Every iCloud service name belonging to this candidate (the purge set). */
|
|
28
|
+
services: string[];
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Group raw synced service names into per-bundle candidates. Pure — separated
|
|
32
|
+
* from discovery so the parsing rules are unit-testable without a keychain.
|
|
33
|
+
*
|
|
34
|
+
* A bundle can surface as metadata only (`agents-cli.bundles.<name>`), as
|
|
35
|
+
* secret items only (`agents-cli.secrets.<bundle>.<KEY>` — metadata never
|
|
36
|
+
* synced), or both; all three shapes appear in real iCloud strays, so every
|
|
37
|
+
* one becomes a candidate.
|
|
38
|
+
*/
|
|
39
|
+
export declare function groupSyncedServices(services: string[]): SyncedBundleCandidate[];
|
|
40
|
+
/** Enumerate the iCloud keychain and return every orphaned bundle candidate. */
|
|
41
|
+
export declare function discoverSyncedBundles(): SyncedBundleCandidate[];
|
|
42
|
+
export interface ImportSyncedOptions {
|
|
43
|
+
/** Overwrite keys that already exist in the local bundle. */
|
|
44
|
+
force?: boolean;
|
|
45
|
+
/** Store imported values as literals in the bundle metadata (no keychain items). */
|
|
46
|
+
allPlaintext?: boolean;
|
|
47
|
+
/** Backend for a newly created bundle (existing bundles keep theirs). */
|
|
48
|
+
backend?: SecretsBackend;
|
|
49
|
+
/** Delete the iCloud copies of successfully-read items after import. */
|
|
50
|
+
purge?: boolean;
|
|
51
|
+
}
|
|
52
|
+
export interface ImportSyncedResult {
|
|
53
|
+
name: string;
|
|
54
|
+
added: number;
|
|
55
|
+
skipped: number;
|
|
56
|
+
/** Keys whose iCloud value could not be read (left in place, never purged). */
|
|
57
|
+
missing: string[];
|
|
58
|
+
/**
|
|
59
|
+
* Keys the modern store refuses by policy (reserved env names like USER,
|
|
60
|
+
* loader/interpreter vars like DYLD_*) — the pre-cutover store accepted
|
|
61
|
+
* them. Left in iCloud, never purged: the iCloud item is the only copy.
|
|
62
|
+
*/
|
|
63
|
+
unimportable: string[];
|
|
64
|
+
purged: number;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Import one discovered iCloud bundle into the local (device-local) store.
|
|
68
|
+
*
|
|
69
|
+
* Values come from the synced secret items; the synced metadata item, when
|
|
70
|
+
* present, contributes the description, literal vars, and non-keychain refs
|
|
71
|
+
* (env:/file:/exec: refs carry no stored secret, so copying the ref preserves
|
|
72
|
+
* them exactly). Existing local keys are skipped unless `force`. Keys the
|
|
73
|
+
* modern store refuses by policy (reserved / loader env names the pre-cutover
|
|
74
|
+
* store accepted) are reported as `unimportable` instead of aborting the whole
|
|
75
|
+
* bundle. With `purge`, only services whose value provably lives locally
|
|
76
|
+
* (imported now, or skipped-because-present) are deleted from iCloud — an
|
|
77
|
+
* unreadable or unimportable item is never destroyed.
|
|
78
|
+
*/
|
|
79
|
+
export declare function importSyncedBundle(candidate: SyncedBundleCandidate, opts?: ImportSyncedOptions): ImportSyncedResult;
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recovery for LEGACY SYNCHRONIZABLE (iCloud Keychain) bundles.
|
|
3
|
+
*
|
|
4
|
+
* The pre-biometry helper era defaulted bundles to iCloud Keychain sync. The
|
|
5
|
+
* device-local cutover (biometry ACL + kSecAttrSynchronizable false on every
|
|
6
|
+
* query) orphaned those items: they still sync back via iCloud Keychain, but
|
|
7
|
+
* neither `secrets list` nor `migrate-acl` can see them. This module powers
|
|
8
|
+
* `agents secrets import --from icloud` — discover the orphaned bundles,
|
|
9
|
+
* re-import them as normal device-local bundles, and optionally purge the
|
|
10
|
+
* iCloud copies.
|
|
11
|
+
*
|
|
12
|
+
* The item-name scheme is the same one the modern store uses (see bundles.ts):
|
|
13
|
+
* metadata under `agents-cli.bundles.<name>`, one value per key under
|
|
14
|
+
* `agents-cli.secrets.<bundle>.<key>`. Env keys can never contain a dot
|
|
15
|
+
* (ENV_KEY_PATTERN), so splitting a secret service at its LAST dot recovers
|
|
16
|
+
* the bundle/key boundary even for dotted bundle names like `hetzner.com`.
|
|
17
|
+
*/
|
|
18
|
+
import { deleteSyncedKeychainItem, getSyncedKeychainTokens, listSyncedKeychainItems, parseBundleValue, secretsKeychainItem, serializeRef, SECRETS_ITEM_PREFIX, } from './index.js';
|
|
19
|
+
import { BUNDLE_META_PREFIX, BUNDLE_NAME_PATTERN, ENV_KEY_PATTERN, bundleExists, bundleItemStore, bundlePolicy, isLoaderOrInterpreterEnv, isReservedEnvName, keychainRef, readBundle, writeBundle, } from './bundles.js';
|
|
20
|
+
/**
|
|
21
|
+
* Group raw synced service names into per-bundle candidates. Pure — separated
|
|
22
|
+
* from discovery so the parsing rules are unit-testable without a keychain.
|
|
23
|
+
*
|
|
24
|
+
* A bundle can surface as metadata only (`agents-cli.bundles.<name>`), as
|
|
25
|
+
* secret items only (`agents-cli.secrets.<bundle>.<KEY>` — metadata never
|
|
26
|
+
* synced), or both; all three shapes appear in real iCloud strays, so every
|
|
27
|
+
* one becomes a candidate.
|
|
28
|
+
*/
|
|
29
|
+
export function groupSyncedServices(services) {
|
|
30
|
+
const byName = new Map();
|
|
31
|
+
const claim = (name) => {
|
|
32
|
+
let c = byName.get(name);
|
|
33
|
+
if (!c) {
|
|
34
|
+
c = { name, keys: [], hasMeta: false, services: [] };
|
|
35
|
+
byName.set(name, c);
|
|
36
|
+
}
|
|
37
|
+
return c;
|
|
38
|
+
};
|
|
39
|
+
for (const svc of services) {
|
|
40
|
+
if (svc.startsWith(BUNDLE_META_PREFIX)) {
|
|
41
|
+
const name = svc.slice(BUNDLE_META_PREFIX.length);
|
|
42
|
+
if (!BUNDLE_NAME_PATTERN.test(name))
|
|
43
|
+
continue;
|
|
44
|
+
const c = claim(name);
|
|
45
|
+
c.hasMeta = true;
|
|
46
|
+
c.services.push(svc);
|
|
47
|
+
}
|
|
48
|
+
else if (svc.startsWith(SECRETS_ITEM_PREFIX)) {
|
|
49
|
+
const rest = svc.slice(SECRETS_ITEM_PREFIX.length);
|
|
50
|
+
const cut = rest.lastIndexOf('.');
|
|
51
|
+
if (cut <= 0)
|
|
52
|
+
continue;
|
|
53
|
+
const name = rest.slice(0, cut);
|
|
54
|
+
const key = rest.slice(cut + 1);
|
|
55
|
+
if (!BUNDLE_NAME_PATTERN.test(name) || !ENV_KEY_PATTERN.test(key))
|
|
56
|
+
continue;
|
|
57
|
+
const c = claim(name);
|
|
58
|
+
if (!c.keys.includes(key))
|
|
59
|
+
c.keys.push(key);
|
|
60
|
+
c.services.push(svc);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
64
|
+
}
|
|
65
|
+
/** Enumerate the iCloud keychain and return every orphaned bundle candidate. */
|
|
66
|
+
export function discoverSyncedBundles() {
|
|
67
|
+
return groupSyncedServices(listSyncedKeychainItems('agents-cli.'));
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Import one discovered iCloud bundle into the local (device-local) store.
|
|
71
|
+
*
|
|
72
|
+
* Values come from the synced secret items; the synced metadata item, when
|
|
73
|
+
* present, contributes the description, literal vars, and non-keychain refs
|
|
74
|
+
* (env:/file:/exec: refs carry no stored secret, so copying the ref preserves
|
|
75
|
+
* them exactly). Existing local keys are skipped unless `force`. Keys the
|
|
76
|
+
* modern store refuses by policy (reserved / loader env names the pre-cutover
|
|
77
|
+
* store accepted) are reported as `unimportable` instead of aborting the whole
|
|
78
|
+
* bundle. With `purge`, only services whose value provably lives locally
|
|
79
|
+
* (imported now, or skipped-because-present) are deleted from iCloud — an
|
|
80
|
+
* unreadable or unimportable item is never destroyed.
|
|
81
|
+
*/
|
|
82
|
+
export function importSyncedBundle(candidate, opts = {}) {
|
|
83
|
+
const values = getSyncedKeychainTokens(candidate.services);
|
|
84
|
+
let bundle;
|
|
85
|
+
if (bundleExists(candidate.name)) {
|
|
86
|
+
bundle = readBundle(candidate.name);
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
bundle = {
|
|
90
|
+
name: candidate.name,
|
|
91
|
+
backend: opts.backend === 'file' ? 'file' : undefined,
|
|
92
|
+
vars: {},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
const metaService = BUNDLE_META_PREFIX + candidate.name;
|
|
96
|
+
let metaVars = {};
|
|
97
|
+
const metaJson = values.get(metaService);
|
|
98
|
+
if (metaJson !== undefined) {
|
|
99
|
+
try {
|
|
100
|
+
const parsed = JSON.parse(metaJson);
|
|
101
|
+
if (parsed && typeof parsed === 'object') {
|
|
102
|
+
if (typeof parsed.description === 'string' && !bundle.description) {
|
|
103
|
+
bundle.description = parsed.description;
|
|
104
|
+
}
|
|
105
|
+
if (parsed.vars && typeof parsed.vars === 'object') {
|
|
106
|
+
metaVars = parsed.vars;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
// Corrupt legacy metadata — the per-key secret items still import.
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const store = bundleItemStore(bundle.backend, { noAcl: bundlePolicy(bundle) === 'never' });
|
|
115
|
+
let added = 0;
|
|
116
|
+
let skipped = 0;
|
|
117
|
+
const missing = [];
|
|
118
|
+
const unimportable = [];
|
|
119
|
+
// Services whose value now provably lives in the local store — imported this
|
|
120
|
+
// run, or skipped because the local bundle already carries the key. Only
|
|
121
|
+
// these may be purged; a missing or unimportable key's iCloud item is its
|
|
122
|
+
// only copy and must survive.
|
|
123
|
+
const purgeable = new Set();
|
|
124
|
+
const rejectedByPolicy = (key) => isLoaderOrInterpreterEnv(key) || isReservedEnvName(key);
|
|
125
|
+
// Keys with a synced secret item: re-store the value device-locally.
|
|
126
|
+
for (const key of candidate.keys) {
|
|
127
|
+
// The pre-cutover store accepted keys the modern one refuses (writeBundle
|
|
128
|
+
// throws) — skip them instead of aborting the whole bundle.
|
|
129
|
+
if (rejectedByPolicy(key)) {
|
|
130
|
+
unimportable.push(key);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
const value = values.get(secretsKeychainItem(candidate.name, key));
|
|
134
|
+
if (value === undefined) {
|
|
135
|
+
missing.push(key);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (!opts.force && key in bundle.vars) {
|
|
139
|
+
skipped++;
|
|
140
|
+
purgeable.add(secretsKeychainItem(candidate.name, key));
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (opts.allPlaintext) {
|
|
144
|
+
bundle.vars[key] = { value };
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
store.set(secretsKeychainItem(candidate.name, key), value);
|
|
148
|
+
bundle.vars[key] = keychainRef(key);
|
|
149
|
+
}
|
|
150
|
+
purgeable.add(secretsKeychainItem(candidate.name, key));
|
|
151
|
+
added++;
|
|
152
|
+
}
|
|
153
|
+
// Vars declared only in the synced metadata: literals and non-keychain refs
|
|
154
|
+
// carry everything they need; a keychain ref without its synced item is
|
|
155
|
+
// unrecoverable.
|
|
156
|
+
for (const [key, raw] of Object.entries(metaVars)) {
|
|
157
|
+
if (!ENV_KEY_PATTERN.test(key))
|
|
158
|
+
continue;
|
|
159
|
+
if (candidate.keys.includes(key))
|
|
160
|
+
continue; // the secret item already covered it
|
|
161
|
+
if (rejectedByPolicy(key)) {
|
|
162
|
+
if (!unimportable.includes(key))
|
|
163
|
+
unimportable.push(key);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
let parsed;
|
|
167
|
+
try {
|
|
168
|
+
parsed = parseBundleValue(raw);
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
continue; // malformed legacy entry
|
|
172
|
+
}
|
|
173
|
+
if ('ref' in parsed && parsed.ref.provider === 'keychain') {
|
|
174
|
+
if (!missing.includes(key))
|
|
175
|
+
missing.push(key);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (!opts.force && key in bundle.vars) {
|
|
179
|
+
skipped++;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
bundle.vars[key] = 'literal' in parsed ? { value: parsed.literal } : serializeRef(parsed.ref);
|
|
183
|
+
added++;
|
|
184
|
+
}
|
|
185
|
+
writeBundle(bundle);
|
|
186
|
+
// The metadata item is only a projection of what was just written locally —
|
|
187
|
+
// safe to purge once writeBundle has succeeded, UNLESS it still names keys
|
|
188
|
+
// that never made it over (missing/unimportable): then it stays as the only
|
|
189
|
+
// record of them.
|
|
190
|
+
if (metaJson !== undefined && missing.length === 0 && unimportable.length === 0) {
|
|
191
|
+
purgeable.add(metaService);
|
|
192
|
+
}
|
|
193
|
+
let purged = 0;
|
|
194
|
+
if (opts.purge) {
|
|
195
|
+
for (const svc of candidate.services) {
|
|
196
|
+
if (!purgeable.has(svc))
|
|
197
|
+
continue;
|
|
198
|
+
if (deleteSyncedKeychainItem(svc))
|
|
199
|
+
purged++;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return { name: candidate.name, added, skipped, missing, unimportable, purged };
|
|
203
|
+
}
|