@phnx-labs/agents-cli 1.14.2 → 1.14.4

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.
Files changed (121) hide show
  1. package/README.md +17 -7
  2. package/dist/browser.d.ts +2 -0
  3. package/dist/browser.js +7 -0
  4. package/dist/commands/browser.d.ts +3 -0
  5. package/dist/commands/browser.js +392 -0
  6. package/dist/commands/daemon.js +1 -1
  7. package/dist/commands/doctor.d.ts +16 -9
  8. package/dist/commands/doctor.js +248 -12
  9. package/dist/commands/prune.js +9 -3
  10. package/dist/commands/refresh-rules.d.ts +15 -0
  11. package/dist/commands/{refresh-memory.js → refresh-rules.js} +14 -14
  12. package/dist/commands/routines.js +1 -1
  13. package/dist/commands/rules.js +100 -4
  14. package/dist/commands/secrets.js +198 -11
  15. package/dist/commands/sync.js +19 -0
  16. package/dist/commands/teams.js +184 -22
  17. package/dist/commands/trash.d.ts +10 -0
  18. package/dist/commands/trash.js +187 -0
  19. package/dist/commands/view.js +47 -14
  20. package/dist/index.js +62 -4
  21. package/dist/lib/agents.js +2 -2
  22. package/dist/lib/browser/cdp.d.ts +24 -0
  23. package/dist/lib/browser/cdp.js +94 -0
  24. package/dist/lib/browser/chrome.d.ts +16 -0
  25. package/dist/lib/browser/chrome.js +157 -0
  26. package/dist/lib/browser/drivers/local.d.ts +8 -0
  27. package/dist/lib/browser/drivers/local.js +22 -0
  28. package/dist/lib/browser/drivers/ssh.d.ts +9 -0
  29. package/dist/lib/browser/drivers/ssh.js +129 -0
  30. package/dist/lib/browser/index.d.ts +5 -0
  31. package/dist/lib/browser/index.js +5 -0
  32. package/dist/lib/browser/input.d.ts +6 -0
  33. package/dist/lib/browser/input.js +52 -0
  34. package/dist/lib/browser/ipc.d.ts +12 -0
  35. package/dist/lib/browser/ipc.js +223 -0
  36. package/dist/lib/browser/profiles.d.ts +11 -0
  37. package/dist/lib/browser/profiles.js +61 -0
  38. package/dist/lib/browser/refs.d.ts +21 -0
  39. package/dist/lib/browser/refs.js +88 -0
  40. package/dist/lib/browser/service.d.ts +45 -0
  41. package/dist/lib/browser/service.js +404 -0
  42. package/dist/lib/browser/types.d.ts +73 -0
  43. package/dist/lib/browser/types.js +7 -0
  44. package/dist/lib/cloud/codex.js +1 -1
  45. package/dist/lib/cloud/registry.js +2 -2
  46. package/dist/lib/cloud/rush.js +2 -2
  47. package/dist/lib/cloud/store.js +2 -2
  48. package/dist/lib/daemon.d.ts +1 -1
  49. package/dist/lib/daemon.js +47 -11
  50. package/dist/lib/diff-text.d.ts +25 -0
  51. package/dist/lib/diff-text.js +47 -0
  52. package/dist/lib/doctor-diff.d.ts +64 -0
  53. package/dist/lib/doctor-diff.js +497 -0
  54. package/dist/lib/git.js +3 -3
  55. package/dist/lib/hooks.d.ts +6 -0
  56. package/dist/lib/hooks.js +6 -1
  57. package/dist/lib/migrate.js +123 -0
  58. package/dist/lib/pty-client.js +3 -3
  59. package/dist/lib/pty-server.js +36 -7
  60. package/dist/lib/resources/commands.d.ts +46 -0
  61. package/dist/lib/resources/commands.js +208 -0
  62. package/dist/lib/resources/hooks.d.ts +12 -0
  63. package/dist/lib/resources/hooks.js +136 -0
  64. package/dist/lib/resources/index.d.ts +36 -0
  65. package/dist/lib/resources/index.js +69 -0
  66. package/dist/lib/resources/mcp.d.ts +34 -0
  67. package/dist/lib/resources/mcp.js +483 -0
  68. package/dist/lib/resources/permissions.d.ts +13 -0
  69. package/dist/lib/resources/permissions.js +184 -0
  70. package/dist/lib/resources/rules.d.ts +43 -0
  71. package/dist/lib/resources/rules.js +146 -0
  72. package/dist/lib/resources/skills.d.ts +37 -0
  73. package/dist/lib/resources/skills.js +238 -0
  74. package/dist/lib/resources/subagents.d.ts +46 -0
  75. package/dist/lib/resources/subagents.js +198 -0
  76. package/dist/lib/resources/types.d.ts +82 -0
  77. package/dist/lib/resources/types.js +8 -0
  78. package/dist/lib/resources.js +1 -1
  79. package/dist/lib/rotate.d.ts +8 -1
  80. package/dist/lib/rotate.js +17 -4
  81. package/dist/lib/rules/compile.d.ts +104 -0
  82. package/dist/lib/{memory-compile.js → rules/compile.js} +160 -21
  83. package/dist/lib/rules/compose.d.ts +78 -0
  84. package/dist/lib/rules/compose.js +170 -0
  85. package/dist/lib/{memory.d.ts → rules/rules.d.ts} +5 -5
  86. package/dist/lib/{memory.js → rules/rules.js} +10 -10
  87. package/dist/lib/secrets/AgentsKeychain.app/Contents/CodeResources +0 -0
  88. package/dist/lib/secrets/AgentsKeychain.app/Contents/MacOS/AgentsKeychain +0 -0
  89. package/dist/lib/secrets/bundles.d.ts +61 -4
  90. package/dist/lib/secrets/bundles.js +222 -54
  91. package/dist/lib/secrets/index.d.ts +24 -5
  92. package/dist/lib/secrets/index.js +70 -41
  93. package/dist/lib/session/active.js +5 -5
  94. package/dist/lib/session/db.js +4 -4
  95. package/dist/lib/session/discover.js +2 -2
  96. package/dist/lib/session/render.js +21 -7
  97. package/dist/lib/shims.d.ts +28 -4
  98. package/dist/lib/shims.js +72 -14
  99. package/dist/lib/state.d.ts +22 -28
  100. package/dist/lib/state.js +83 -78
  101. package/dist/lib/sync-manifest.d.ts +2 -2
  102. package/dist/lib/sync-manifest.js +5 -5
  103. package/dist/lib/teams/agents.d.ts +4 -2
  104. package/dist/lib/teams/agents.js +11 -4
  105. package/dist/lib/teams/api.d.ts +1 -1
  106. package/dist/lib/teams/api.js +2 -2
  107. package/dist/lib/teams/index.d.ts +1 -0
  108. package/dist/lib/teams/index.js +1 -0
  109. package/dist/lib/teams/persistence.js +3 -3
  110. package/dist/lib/teams/registry.d.ts +12 -1
  111. package/dist/lib/teams/registry.js +12 -2
  112. package/dist/lib/teams/worktree.d.ts +30 -0
  113. package/dist/lib/teams/worktree.js +96 -0
  114. package/dist/lib/types.d.ts +12 -6
  115. package/dist/lib/types.js +3 -3
  116. package/dist/lib/versions.d.ts +32 -3
  117. package/dist/lib/versions.js +147 -119
  118. package/package.json +3 -2
  119. package/scripts/postinstall.js +29 -0
  120. package/dist/commands/refresh-memory.d.ts +0 -15
  121. package/dist/lib/memory-compile.d.ts +0 -66
@@ -1,23 +1,53 @@
1
1
  /**
2
2
  * Secret bundles -- named sets of keychain-backed environment variables.
3
3
  *
4
- * Each bundle is a YAML file in ~/.agents/secrets/ declaring key names.
5
- * Values live in the macOS Keychain and are injected into the agent's
6
- * environment at spawn time via `agents run --secrets <bundle>`.
4
+ * Bundle metadata (name, description, vars map) is stored in the macOS
5
+ * Keychain as a JSON blob under `agents-cli.bundles.<name>`. Bundles created
6
+ * with `--icloud-sync` write the metadata to the iCloud-synced keychain so
7
+ * the full bundle definition (not just secret values) propagates across
8
+ * the user's Macs. Nothing about secrets ever lives in plaintext on disk.
9
+ *
10
+ * Secret values keep their old layout: one keychain item per key under
11
+ * `agents-cli.secrets.<bundle>.<key>`, sync-state matching the bundle's
12
+ * `icloud_sync` flag.
7
13
  */
8
14
  import { type BundleValue, type SecretRef } from './index.js';
15
+ /** Allowed values for a secret's `type` metadata field. */
16
+ export declare const SECRET_TYPES: readonly ["api-key", "token", "password", "url", "database-url", "ssh-key", "certificate", "webhook", "note"];
17
+ export type SecretType = typeof SECRET_TYPES[number];
18
+ /** Per-secret metadata. All fields optional; absent ones omitted at write time. */
19
+ export interface VarMeta {
20
+ type?: SecretType;
21
+ /** ISO date 'YYYY-MM-DD'. Always future-dated at write time. */
22
+ expires?: string;
23
+ /** Singular freeform note. */
24
+ note?: string;
25
+ }
9
26
  /** A named set of environment variable definitions backed by various secret providers. */
10
27
  export interface SecretsBundle {
11
28
  name: string;
12
29
  description?: string;
13
30
  allow_exec?: boolean;
14
- /** When true, keychain-backed values are stored in iCloud Keychain so they sync across the user's Macs. */
31
+ /** When true, keychain-backed values and bundle metadata sync via iCloud Keychain. */
15
32
  icloud_sync?: boolean;
16
33
  vars: Record<string, BundleValue>;
34
+ /** Optional per-var metadata, keyed by var name (parallel to `vars`). */
35
+ meta?: Record<string, VarMeta>;
17
36
  }
37
+ export declare const RESERVED_ENV_NAMES: Set<string>;
38
+ export declare function bundleToEnvPrefix(name: string): string;
39
+ export declare function isReservedEnvName(key: string): boolean;
18
40
  /** Validate a bundle name against the allowed pattern. Throws on invalid input. */
19
41
  export declare function validateBundleName(name: string): void;
20
42
  export declare function validateEnvKey(key: string): void;
43
+ /** Assert that `t` is one of the known SECRET_TYPES. Throws with the allowed list otherwise. */
44
+ export declare function validateSecretType(t: string): asserts t is SecretType;
45
+ /**
46
+ * Validate an `expires` value. Accepts strict 'YYYY-MM-DD' only and rejects
47
+ * any date <= now. We compare against end-of-day UTC for the chosen date so
48
+ * "today" is treated as past (per spec).
49
+ */
50
+ export declare function validateExpiresFutureDated(iso: string): void;
21
51
  export declare function bundleExists(name: string): boolean;
22
52
  export declare function readBundle(name: string): SecretsBundle;
23
53
  export declare function writeBundle(bundle: SecretsBundle): void;
@@ -31,9 +61,36 @@ export interface BundleEntryInfo {
31
61
  export declare function describeBundle(bundle: SecretsBundle): BundleEntryInfo[];
32
62
  export declare function resolveBundleEnv(bundle: SecretsBundle): Record<string, string>;
33
63
  export declare function keychainRef(key: string): string;
64
+ /** Options for rotateBundleSecret. */
65
+ export interface RotateOptions {
66
+ /** New plaintext value to write into keychain (replaces the old one). */
67
+ newValue: string;
68
+ /** When true, drop existing meta for this key. Mutually exclusive with `meta`. */
69
+ clearMeta?: boolean;
70
+ /** Patch to merge into existing meta. Undefined fields preserve current values. */
71
+ meta?: Partial<VarMeta>;
72
+ }
73
+ /**
74
+ * Rotate a keychain-backed secret in `bundle`. Errors if `key` is not present
75
+ * in the bundle (use `add` to introduce a new key). Preserves existing meta
76
+ * unless `clearMeta` or a `meta` patch is supplied.
77
+ */
78
+ export declare function rotateBundleSecret(bundle: SecretsBundle, key: string, opts: RotateOptions): void;
34
79
  export declare function keychainItemsForBundle(bundle: SecretsBundle): Array<{
35
80
  key: string;
36
81
  item: string;
37
82
  }>;
38
83
  export declare function parseDotenv(content: string): Record<string, string>;
84
+ /**
85
+ * One-shot migration: move legacy YAML bundles into the keychain. Scans both
86
+ * `~/.agents/secrets/` and `~/.agents-system/secrets/` — past versions of the
87
+ * CLI sometimes wrote bundles into the system repo even though that's never
88
+ * been a legitimate location. After migration the directories are removed so
89
+ * the system repo never carries a `secrets/` subdir again.
90
+ *
91
+ * Idempotent: re-runs after the dirs are gone are no-ops. Called eagerly at
92
+ * the top of every `agents secrets` subcommand. Skipped on the latency-
93
+ * sensitive `agents run` path.
94
+ */
95
+ export declare function migrateLegacyBundles(): void;
39
96
  export type { SecretRef };
@@ -1,21 +1,51 @@
1
1
  /**
2
2
  * Secret bundles -- named sets of keychain-backed environment variables.
3
3
  *
4
- * Each bundle is a YAML file in ~/.agents/secrets/ declaring key names.
5
- * Values live in the macOS Keychain and are injected into the agent's
6
- * environment at spawn time via `agents run --secrets <bundle>`.
4
+ * Bundle metadata (name, description, vars map) is stored in the macOS
5
+ * Keychain as a JSON blob under `agents-cli.bundles.<name>`. Bundles created
6
+ * with `--icloud-sync` write the metadata to the iCloud-synced keychain so
7
+ * the full bundle definition (not just secret values) propagates across
8
+ * the user's Macs. Nothing about secrets ever lives in plaintext on disk.
9
+ *
10
+ * Secret values keep their old layout: one keychain item per key under
11
+ * `agents-cli.secrets.<bundle>.<key>`, sync-state matching the bundle's
12
+ * `icloud_sync` flag.
7
13
  */
8
14
  import * as fs from 'fs';
15
+ import * as os from 'os';
9
16
  import * as path from 'path';
10
17
  import * as yaml from 'yaml';
11
- import { getSecretsDir, getUserSecretsDir } from '../state.js';
12
- import { parseBundleValue, resolveRef, secretsKeychainItem, } from './index.js';
13
- const BUNDLE_NAME_PATTERN = /^[a-z0-9][a-z0-9-_]{0,48}$/i;
18
+ import { deleteKeychainToken, getKeychainToken, hasKeychainToken, listKeychainItems, parseBundleValue, resolveRef, secretsKeychainItem, setKeychainToken, } from './index.js';
19
+ /** Allowed values for a secret's `type` metadata field. */
20
+ export const SECRET_TYPES = [
21
+ 'api-key',
22
+ 'token',
23
+ 'password',
24
+ 'url',
25
+ 'database-url',
26
+ 'ssh-key',
27
+ 'certificate',
28
+ 'webhook',
29
+ 'note',
30
+ ];
31
+ const BUNDLE_NAME_PATTERN = /^[a-z0-9][a-z0-9\-_.]{0,48}$/i;
14
32
  const ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
33
+ const BUNDLE_META_PREFIX = 'agents-cli.bundles.';
34
+ export const RESERVED_ENV_NAMES = new Set([
35
+ 'PATH', 'HOME', 'USER', 'USERNAME', 'SHELL', 'PWD', 'OLDPWD',
36
+ 'TERM', 'LANG', 'LC_ALL', 'DISPLAY', 'EDITOR', 'VISUAL',
37
+ 'TMPDIR', 'TMP', 'TEMP', 'LOGNAME', 'UID', 'EUID', 'HOSTNAME',
38
+ ]);
39
+ export function bundleToEnvPrefix(name) {
40
+ return name.replace(/[-\.]/g, '_').toUpperCase();
41
+ }
42
+ export function isReservedEnvName(key) {
43
+ return RESERVED_ENV_NAMES.has(key);
44
+ }
15
45
  /** Validate a bundle name against the allowed pattern. Throws on invalid input. */
16
46
  export function validateBundleName(name) {
17
47
  if (!BUNDLE_NAME_PATTERN.test(name)) {
18
- throw new Error(`Invalid bundle name '${name}'. Use letters, digits, dash, underscore (max 48 chars).`);
48
+ throw new Error(`Invalid bundle name '${name}'. Use letters, digits, dash, underscore, dot (max 48 chars).`);
19
49
  }
20
50
  }
21
51
  export function validateEnvKey(key) {
@@ -23,37 +53,64 @@ export function validateEnvKey(key) {
23
53
  throw new Error(`Invalid environment variable name '${key}'. Must match [A-Za-z_][A-Za-z0-9_]*.`);
24
54
  }
25
55
  }
26
- function bundlePath(name) {
27
- // Check user dir first (for reads), write to user dir
28
- const userPath = path.join(getUserSecretsDir(), `${name}.yml`);
29
- if (fs.existsSync(userPath))
30
- return userPath;
31
- const systemPath = path.join(getSecretsDir(), `${name}.yml`);
32
- if (fs.existsSync(systemPath))
33
- return systemPath;
34
- return userPath; // default write location
56
+ /** Assert that `t` is one of the known SECRET_TYPES. Throws with the allowed list otherwise. */
57
+ export function validateSecretType(t) {
58
+ if (!SECRET_TYPES.includes(t)) {
59
+ throw new Error(`Invalid type '${t}'. One of: ${SECRET_TYPES.join(', ')}.`);
60
+ }
61
+ }
62
+ /**
63
+ * Validate an `expires` value. Accepts strict 'YYYY-MM-DD' only and rejects
64
+ * any date <= now. We compare against end-of-day UTC for the chosen date so
65
+ * "today" is treated as past (per spec).
66
+ */
67
+ export function validateExpiresFutureDated(iso) {
68
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) {
69
+ throw new Error(`Invalid --expires '${iso}'. Use YYYY-MM-DD.`);
70
+ }
71
+ const target = new Date(iso + 'T23:59:59Z');
72
+ if (Number.isNaN(target.getTime()))
73
+ throw new Error(`Invalid --expires date '${iso}'.`);
74
+ if (target.getTime() <= Date.now()) {
75
+ throw new Error(`--expires must be future-dated. Got '${iso}'.`);
76
+ }
77
+ }
78
+ function bundleMetaItem(name) {
79
+ return BUNDLE_META_PREFIX + name;
35
80
  }
36
81
  export function bundleExists(name) {
37
- return fs.existsSync(bundlePath(name));
82
+ validateBundleName(name);
83
+ return hasKeychainToken(bundleMetaItem(name));
38
84
  }
39
85
  export function readBundle(name) {
40
86
  validateBundleName(name);
41
- const file = bundlePath(name);
42
- if (!fs.existsSync(file)) {
87
+ let json;
88
+ try {
89
+ json = getKeychainToken(bundleMetaItem(name));
90
+ }
91
+ catch {
43
92
  throw new Error(`Secrets bundle '${name}' not found.`);
44
93
  }
45
- const raw = fs.readFileSync(file, 'utf-8');
46
- const parsed = yaml.parse(raw);
94
+ let parsed;
95
+ try {
96
+ parsed = JSON.parse(json);
97
+ }
98
+ catch {
99
+ throw new Error(`Bundle '${name}' is malformed.`);
100
+ }
47
101
  if (!parsed || typeof parsed !== 'object') {
48
102
  throw new Error(`Bundle '${name}' is malformed.`);
49
103
  }
50
104
  const bundle = {
51
- name: parsed.name || name,
105
+ name,
52
106
  description: parsed.description,
53
107
  allow_exec: Boolean(parsed.allow_exec),
54
108
  icloud_sync: Boolean(parsed.icloud_sync),
55
109
  vars: parsed.vars && typeof parsed.vars === 'object' ? parsed.vars : {},
56
110
  };
111
+ if (parsed.meta && typeof parsed.meta === 'object') {
112
+ bundle.meta = parsed.meta;
113
+ }
57
114
  for (const key of Object.keys(bundle.vars)) {
58
115
  validateEnvKey(key);
59
116
  }
@@ -64,48 +121,59 @@ export function writeBundle(bundle) {
64
121
  for (const key of Object.keys(bundle.vars)) {
65
122
  validateEnvKey(key);
66
123
  }
67
- const dir = getUserSecretsDir();
68
- fs.mkdirSync(dir, { recursive: true });
69
- const body = yaml.stringify({
70
- name: bundle.name,
124
+ // Strip empty/all-undefined meta entries so the JSON stays tidy.
125
+ let meta;
126
+ if (bundle.meta) {
127
+ for (const [key, m] of Object.entries(bundle.meta)) {
128
+ const cleaned = {};
129
+ if (m.type)
130
+ cleaned.type = m.type;
131
+ if (m.expires)
132
+ cleaned.expires = m.expires;
133
+ if (m.note)
134
+ cleaned.note = m.note;
135
+ if (Object.keys(cleaned).length > 0) {
136
+ if (!meta)
137
+ meta = {};
138
+ meta[key] = cleaned;
139
+ }
140
+ }
141
+ }
142
+ const payload = {
71
143
  description: bundle.description,
72
144
  allow_exec: bundle.allow_exec ? true : undefined,
73
145
  icloud_sync: bundle.icloud_sync ? true : undefined,
74
146
  vars: bundle.vars,
75
- });
76
- const file = bundlePath(bundle.name);
77
- const tmp = `${file}.tmp-${process.pid}`;
78
- fs.writeFileSync(tmp, body, 'utf-8');
79
- fs.renameSync(tmp, file);
147
+ meta,
148
+ };
149
+ const json = JSON.stringify(payload);
150
+ setKeychainToken(bundleMetaItem(bundle.name), json, Boolean(bundle.icloud_sync));
80
151
  }
81
152
  export function deleteBundle(name) {
82
153
  validateBundleName(name);
83
- const file = bundlePath(name);
84
- if (!fs.existsSync(file))
85
- return false;
86
- fs.unlinkSync(file);
87
- return true;
154
+ return deleteKeychainToken(bundleMetaItem(name));
88
155
  }
89
156
  export function listBundles() {
90
- const seen = new Set();
91
- const bundles = [];
92
- for (const dir of [getUserSecretsDir(), getSecretsDir()]) {
93
- if (!fs.existsSync(dir))
94
- continue;
95
- for (const entry of fs.readdirSync(dir).filter((f) => f.endsWith('.yml') || f.endsWith('.yaml'))) {
96
- const name = entry.replace(/\.(yml|yaml)$/, '');
97
- if (seen.has(name))
98
- continue;
99
- seen.add(name);
100
- try {
101
- bundles.push(readBundle(name));
102
- }
103
- catch {
104
- // Skip malformed bundles; surfaced via `agents secrets view <name>`.
105
- }
157
+ let services;
158
+ try {
159
+ services = listKeychainItems(BUNDLE_META_PREFIX);
160
+ }
161
+ catch {
162
+ return [];
163
+ }
164
+ const names = services
165
+ .map((s) => s.slice(BUNDLE_META_PREFIX.length))
166
+ .filter((n) => BUNDLE_NAME_PATTERN.test(n));
167
+ const out = [];
168
+ for (const name of names) {
169
+ try {
170
+ out.push(readBundle(name));
171
+ }
172
+ catch {
173
+ // Skip malformed bundles; surfaced via `agents secrets view <name>`.
106
174
  }
107
175
  }
108
- return bundles.sort((a, b) => a.name.localeCompare(b.name));
176
+ return out.sort((a, b) => a.name.localeCompare(b.name));
109
177
  }
110
178
  export function describeBundle(bundle) {
111
179
  const out = [];
@@ -149,10 +217,49 @@ export function resolveBundleEnv(bundle) {
149
217
  }
150
218
  return env;
151
219
  }
152
- // Build a keychain ref expression from a bundle+key pair, for storage in YAML.
220
+ // Build a keychain ref expression from a bundle+key pair, for storage in the bundle metadata.
153
221
  export function keychainRef(key) {
154
222
  return `keychain:${key}`;
155
223
  }
224
+ /**
225
+ * Rotate a keychain-backed secret in `bundle`. Errors if `key` is not present
226
+ * in the bundle (use `add` to introduce a new key). Preserves existing meta
227
+ * unless `clearMeta` or a `meta` patch is supplied.
228
+ */
229
+ export function rotateBundleSecret(bundle, key, opts) {
230
+ validateBundleName(bundle.name);
231
+ validateEnvKey(key);
232
+ if (!(key in bundle.vars)) {
233
+ throw new Error(`Key '${key}' not in bundle '${bundle.name}'. Use 'agents secrets add' to add a new key.`);
234
+ }
235
+ const raw = bundle.vars[key];
236
+ // We only rotate keychain-backed values. Literals/refs aren't "secrets" in
237
+ // the same sense — pivot the user back to add/remove.
238
+ if (typeof raw !== 'string' || !raw.startsWith('keychain:')) {
239
+ throw new Error(`Key '${key}' in bundle '${bundle.name}' is not keychain-backed; cannot rotate.`);
240
+ }
241
+ const shortId = raw.slice('keychain:'.length);
242
+ const item = secretsKeychainItem(bundle.name, shortId);
243
+ setKeychainToken(item, opts.newValue, bundle.icloud_sync);
244
+ if (opts.clearMeta) {
245
+ if (bundle.meta)
246
+ delete bundle.meta[key];
247
+ }
248
+ else if (opts.meta && Object.keys(opts.meta).length > 0) {
249
+ if (!bundle.meta)
250
+ bundle.meta = {};
251
+ const current = bundle.meta[key] ?? {};
252
+ const patched = { ...current };
253
+ if (opts.meta.type !== undefined)
254
+ patched.type = opts.meta.type;
255
+ if (opts.meta.expires !== undefined)
256
+ patched.expires = opts.meta.expires;
257
+ if (opts.meta.note !== undefined)
258
+ patched.note = opts.meta.note;
259
+ bundle.meta[key] = patched;
260
+ }
261
+ writeBundle(bundle);
262
+ }
156
263
  // Iterate all keychain-backed keys in a bundle for cleanup on rm/unset.
157
264
  export function keychainItemsForBundle(bundle) {
158
265
  const items = [];
@@ -187,3 +294,64 @@ export function parseDotenv(content) {
187
294
  }
188
295
  return out;
189
296
  }
297
+ /**
298
+ * One-shot migration: move legacy YAML bundles into the keychain. Scans both
299
+ * `~/.agents/secrets/` and `~/.agents-system/secrets/` — past versions of the
300
+ * CLI sometimes wrote bundles into the system repo even though that's never
301
+ * been a legitimate location. After migration the directories are removed so
302
+ * the system repo never carries a `secrets/` subdir again.
303
+ *
304
+ * Idempotent: re-runs after the dirs are gone are no-ops. Called eagerly at
305
+ * the top of every `agents secrets` subcommand. Skipped on the latency-
306
+ * sensitive `agents run` path.
307
+ */
308
+ export function migrateLegacyBundles() {
309
+ const home = os.homedir();
310
+ const dirs = [
311
+ path.join(home, '.agents', 'secrets'),
312
+ path.join(home, '.agents-system', 'secrets'),
313
+ ];
314
+ let migrated = 0;
315
+ for (const dir of dirs) {
316
+ let entries;
317
+ try {
318
+ entries = fs.readdirSync(dir);
319
+ }
320
+ catch {
321
+ continue;
322
+ }
323
+ const ymls = entries.filter((f) => f.endsWith('.yml') || f.endsWith('.yaml'));
324
+ for (const entry of ymls) {
325
+ const file = path.join(dir, entry);
326
+ const name = entry.replace(/\.(yml|yaml)$/, '');
327
+ try {
328
+ validateBundleName(name);
329
+ const raw = fs.readFileSync(file, 'utf-8');
330
+ const parsed = yaml.parse(raw);
331
+ if (!parsed || typeof parsed !== 'object')
332
+ continue;
333
+ const bundle = {
334
+ name,
335
+ description: parsed.description,
336
+ allow_exec: Boolean(parsed.allow_exec),
337
+ icloud_sync: Boolean(parsed.icloud_sync),
338
+ vars: parsed.vars && typeof parsed.vars === 'object' ? parsed.vars : {},
339
+ };
340
+ writeBundle(bundle);
341
+ fs.unlinkSync(file);
342
+ migrated++;
343
+ }
344
+ catch {
345
+ // Leave malformed YAMLs in place so the user can inspect them.
346
+ }
347
+ }
348
+ try {
349
+ if (fs.readdirSync(dir).length === 0)
350
+ fs.rmdirSync(dir);
351
+ }
352
+ catch { /* not empty or already gone */ }
353
+ }
354
+ if (migrated > 0) {
355
+ console.log(`Migrated ${migrated} legacy bundle${migrated === 1 ? '' : 's'} into keychain.`);
356
+ }
357
+ }
@@ -1,9 +1,11 @@
1
1
  /**
2
2
  * macOS Keychain integration for secure credential storage.
3
3
  *
4
- * Calls a compiled Swift helper (keychain-helper.swift) to store and retrieve
5
- * API keys and tokens via the Security framework, with kSecAttrSynchronizable
6
- * set so iCloud Keychain syncs them across the user's Macs.
4
+ * All reads/writes go through a signed Swift helper (keychain-helper.swift)
5
+ * compiled into AgentsKeychain.app. The .app embeds a provisioning profile
6
+ * that grants the application-identifier + keychain-access-groups entitlement
7
+ * macOS requires for kSecAttrSynchronizable writes (iCloud Keychain).
8
+ * For device-local writes the helper is invoked with the `nosync` arg.
7
9
  */
8
10
  /** Supported secret resolution backends. */
9
11
  export type SecretProvider = 'keychain' | 'env' | 'file' | 'exec';
@@ -13,14 +15,14 @@ export interface SecretRef {
13
15
  value: string;
14
16
  }
15
17
  /**
16
- * A bundle YAML value: either a string (literal or provider-prefixed ref) or
18
+ * A bundle value: either a string (literal or provider-prefixed ref) or
17
19
  * an object `{value: string}` used to escape a literal that would otherwise
18
20
  * be parsed as a ref (e.g. a URL that happens to start with 'env:').
19
21
  */
20
22
  export type BundleValue = string | {
21
23
  value: string;
22
24
  };
23
- /** Parse a bundle YAML value into either a literal string or a typed secret ref. */
25
+ /** Parse a bundle value into either a literal string or a typed secret ref. */
24
26
  export declare function parseBundleValue(raw: BundleValue): {
25
27
  literal: string;
26
28
  } | {
@@ -32,6 +34,21 @@ export declare function serializeRef(ref: SecretRef): string;
32
34
  export declare function profileKeychainItem(provider: string): string;
33
35
  /** Build the keychain item name for a secrets-bundle key. */
34
36
  export declare function secretsKeychainItem(bundle: string, key: string): string;
37
+ /**
38
+ * Test seam: lets bundle storage tests swap the keychain backend for an
39
+ * in-memory map without touching the user's real keychain. Mocking is
40
+ * justified here because the alternative (touching real keychain in unit
41
+ * tests) is destructive and would require an interactive Keychain unlock.
42
+ */
43
+ export interface KeychainBackend {
44
+ has(item: string, sync: boolean): boolean;
45
+ get(item: string, sync: boolean): string;
46
+ set(item: string, value: string, sync: boolean): void;
47
+ delete(item: string, sync: boolean): boolean;
48
+ list(prefix: string): string[];
49
+ }
50
+ /** Install a custom keychain backend (test only). Returns the previous backend so callers can restore. */
51
+ export declare function setKeychainBackendForTest(b: KeychainBackend | null): KeychainBackend | null;
35
52
  /** Check if a keychain item exists (macOS only). */
36
53
  export declare function hasKeychainToken(item: string, sync?: boolean): boolean;
37
54
  /** Retrieve a secret value from the macOS Keychain. Throws if not found. */
@@ -40,6 +57,8 @@ export declare function getKeychainToken(item: string, sync?: boolean): string;
40
57
  export declare function setKeychainToken(item: string, value: string, sync?: boolean): void;
41
58
  /** Delete a keychain item. Returns true if it existed. */
42
59
  export declare function deleteKeychainToken(item: string, sync?: boolean): boolean;
60
+ /** Enumerate keychain item service names whose name starts with the given prefix. */
61
+ export declare function listKeychainItems(prefix: string): string[];
43
62
  /** Options controlling how secret refs are resolved. */
44
63
  export interface ResolveOptions {
45
64
  /** Translate a short keychain ID to a fully namespaced item name. */