@phnx-labs/agents-cli 1.14.2 → 1.14.3
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/README.md +17 -7
- package/dist/commands/browser.d.ts +2 -0
- package/dist/commands/browser.js +388 -0
- package/dist/commands/daemon.js +1 -1
- package/dist/commands/doctor.d.ts +16 -9
- package/dist/commands/doctor.js +248 -12
- package/dist/commands/prune.js +9 -3
- package/dist/commands/refresh-rules.d.ts +15 -0
- package/dist/commands/{refresh-memory.js → refresh-rules.js} +14 -14
- package/dist/commands/routines.js +1 -1
- package/dist/commands/rules.js +100 -4
- package/dist/commands/secrets.js +198 -11
- package/dist/commands/sync.js +19 -0
- package/dist/commands/teams.js +162 -22
- package/dist/commands/trash.d.ts +10 -0
- package/dist/commands/trash.js +187 -0
- package/dist/commands/view.js +46 -13
- package/dist/index.js +62 -4
- package/dist/lib/agents.js +2 -2
- package/dist/lib/browser/cdp.d.ts +24 -0
- package/dist/lib/browser/cdp.js +94 -0
- package/dist/lib/browser/chrome.d.ts +16 -0
- package/dist/lib/browser/chrome.js +157 -0
- package/dist/lib/browser/drivers/local.d.ts +8 -0
- package/dist/lib/browser/drivers/local.js +22 -0
- package/dist/lib/browser/drivers/ssh.d.ts +9 -0
- package/dist/lib/browser/drivers/ssh.js +129 -0
- package/dist/lib/browser/index.d.ts +5 -0
- package/dist/lib/browser/index.js +5 -0
- package/dist/lib/browser/input.d.ts +6 -0
- package/dist/lib/browser/input.js +52 -0
- package/dist/lib/browser/ipc.d.ts +12 -0
- package/dist/lib/browser/ipc.js +223 -0
- package/dist/lib/browser/profiles.d.ts +11 -0
- package/dist/lib/browser/profiles.js +61 -0
- package/dist/lib/browser/refs.d.ts +21 -0
- package/dist/lib/browser/refs.js +88 -0
- package/dist/lib/browser/service.d.ts +45 -0
- package/dist/lib/browser/service.js +404 -0
- package/dist/lib/browser/types.d.ts +73 -0
- package/dist/lib/browser/types.js +7 -0
- package/dist/lib/cloud/codex.js +1 -1
- package/dist/lib/cloud/registry.js +2 -2
- package/dist/lib/cloud/rush.js +2 -2
- package/dist/lib/cloud/store.js +2 -2
- package/dist/lib/daemon.d.ts +1 -1
- package/dist/lib/daemon.js +47 -11
- package/dist/lib/diff-text.d.ts +25 -0
- package/dist/lib/diff-text.js +47 -0
- package/dist/lib/doctor-diff.d.ts +64 -0
- package/dist/lib/doctor-diff.js +497 -0
- package/dist/lib/git.js +3 -3
- package/dist/lib/hooks.d.ts +6 -0
- package/dist/lib/hooks.js +6 -1
- package/dist/lib/migrate.js +77 -0
- package/dist/lib/pty-client.js +3 -3
- package/dist/lib/pty-server.js +36 -7
- package/dist/lib/resources.js +1 -1
- package/dist/lib/rotate.d.ts +8 -1
- package/dist/lib/rotate.js +17 -4
- package/dist/lib/rules/compile.d.ts +104 -0
- package/dist/lib/{memory-compile.js → rules/compile.js} +160 -21
- package/dist/lib/rules/compose.d.ts +78 -0
- package/dist/lib/rules/compose.js +170 -0
- package/dist/lib/{memory.d.ts → rules/rules.d.ts} +5 -5
- package/dist/lib/{memory.js → rules/rules.js} +10 -10
- package/dist/lib/secrets/AgentsKeychain.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/AgentsKeychain.app/Contents/MacOS/AgentsKeychain +0 -0
- package/dist/lib/secrets/bundles.d.ts +61 -4
- package/dist/lib/secrets/bundles.js +222 -54
- package/dist/lib/secrets/index.d.ts +24 -5
- package/dist/lib/secrets/index.js +70 -41
- package/dist/lib/session/active.js +5 -5
- package/dist/lib/session/db.js +4 -4
- package/dist/lib/session/discover.js +2 -2
- package/dist/lib/session/render.js +21 -7
- package/dist/lib/shims.d.ts +28 -4
- package/dist/lib/shims.js +72 -14
- package/dist/lib/state.d.ts +22 -28
- package/dist/lib/state.js +83 -76
- package/dist/lib/sync-manifest.d.ts +2 -2
- package/dist/lib/sync-manifest.js +5 -5
- package/dist/lib/teams/agents.d.ts +4 -2
- package/dist/lib/teams/agents.js +11 -4
- package/dist/lib/teams/api.d.ts +1 -1
- package/dist/lib/teams/api.js +2 -2
- package/dist/lib/teams/index.d.ts +1 -0
- package/dist/lib/teams/index.js +1 -0
- package/dist/lib/teams/persistence.js +3 -3
- package/dist/lib/teams/registry.d.ts +8 -1
- package/dist/lib/teams/registry.js +8 -2
- package/dist/lib/teams/worktree.d.ts +30 -0
- package/dist/lib/teams/worktree.js +96 -0
- package/dist/lib/types.d.ts +12 -6
- package/dist/lib/types.js +3 -3
- package/dist/lib/versions.d.ts +30 -2
- package/dist/lib/versions.js +127 -105
- package/package.json +1 -1
- package/scripts/postinstall.js +29 -0
- package/dist/commands/refresh-memory.d.ts +0 -15
- package/dist/lib/memory-compile.d.ts +0 -66
|
@@ -1,21 +1,51 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Secret bundles -- named sets of keychain-backed environment variables.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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 {
|
|
12
|
-
|
|
13
|
-
const
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
82
|
+
validateBundleName(name);
|
|
83
|
+
return hasKeychainToken(bundleMetaItem(name));
|
|
38
84
|
}
|
|
39
85
|
export function readBundle(name) {
|
|
40
86
|
validateBundleName(name);
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
46
|
-
|
|
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
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
77
|
-
const
|
|
78
|
-
|
|
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
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
|
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
|
|
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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
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
|
|
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. */
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* macOS Keychain integration for secure credential storage.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
import { fileURLToPath } from 'url';
|
|
9
11
|
import { execFileSync, spawnSync } from 'child_process';
|
|
@@ -12,7 +14,7 @@ import * as os from 'os';
|
|
|
12
14
|
import * as path from 'path';
|
|
13
15
|
const SERVICE_PREFIX = 'agents-cli';
|
|
14
16
|
const REF_PATTERN = /^(keychain|env|file|exec):(.+)$/s;
|
|
15
|
-
/** Parse a bundle
|
|
17
|
+
/** Parse a bundle value into either a literal string or a typed secret ref. */
|
|
16
18
|
export function parseBundleValue(raw) {
|
|
17
19
|
if (typeof raw === 'object' && raw !== null && typeof raw.value === 'string') {
|
|
18
20
|
return { literal: raw.value };
|
|
@@ -53,58 +55,64 @@ function ensureKeychainHelper() {
|
|
|
53
55
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
54
56
|
const binPath = path.join(here, 'AgentsKeychain.app', 'Contents', 'MacOS', 'AgentsKeychain');
|
|
55
57
|
if (!fs.existsSync(binPath)) {
|
|
56
|
-
throw new Error(`
|
|
57
|
-
'This npm package was built without the signed helper bundle. '
|
|
58
|
-
'Reinstall agents-cli, or create the bundle without --icloud-sync to use device-local storage.');
|
|
58
|
+
throw new Error(`Keychain helper missing at ${binPath}. ` +
|
|
59
|
+
'This npm package was built without the signed helper bundle. Reinstall agents-cli.');
|
|
59
60
|
}
|
|
60
61
|
return binPath;
|
|
61
62
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
63
|
+
let backend = null;
|
|
64
|
+
/** Install a custom keychain backend (test only). Returns the previous backend so callers can restore. */
|
|
65
|
+
export function setKeychainBackendForTest(b) {
|
|
66
|
+
const prev = backend;
|
|
67
|
+
backend = b;
|
|
68
|
+
return prev;
|
|
69
|
+
}
|
|
70
|
+
// Backend routing: non-sync items go through /usr/bin/security so they share
|
|
71
|
+
// an ACL identity with items created by previous CLI versions (no prompts on
|
|
72
|
+
// existing data). Sync items must go through the signed .app — only the .app
|
|
73
|
+
// holds the keychain-access-groups entitlement macOS requires for
|
|
74
|
+
// kSecAttrSynchronizable. Enumeration also goes through the .app because the
|
|
75
|
+
// security CLI doesn't expose listing by service prefix.
|
|
66
76
|
/** Check if a keychain item exists (macOS only). */
|
|
67
77
|
export function hasKeychainToken(item, sync = false) {
|
|
78
|
+
if (backend)
|
|
79
|
+
return backend.has(item, sync);
|
|
68
80
|
assertMacOS();
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
81
|
+
// Try security first (no prompts for local items), fall back to binary for synced items.
|
|
82
|
+
if (spawnSync('security', ['find-generic-password', '-a', os.userInfo().username, '-s', item, '-w'], {
|
|
83
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
84
|
+
}).status === 0)
|
|
85
|
+
return true;
|
|
86
|
+
// Fallback: binary searches both synced and non-synced via kSecAttrSynchronizableAny
|
|
87
|
+
const bin = ensureKeychainHelper();
|
|
88
|
+
return spawnSync(bin, ['has', item, os.userInfo().username], {
|
|
76
89
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
77
90
|
}).status === 0;
|
|
78
91
|
}
|
|
79
92
|
/** Retrieve a secret value from the macOS Keychain. Throws if not found. */
|
|
80
93
|
export function getKeychainToken(item, sync = false) {
|
|
94
|
+
if (backend)
|
|
95
|
+
return backend.get(item, sync);
|
|
81
96
|
assertMacOS();
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
const msg = result.stderr?.toString().trim();
|
|
91
|
-
throw new Error(msg || `Failed to read keychain item '${item}'.`);
|
|
92
|
-
}
|
|
93
|
-
const token = result.stdout?.toString().trim();
|
|
94
|
-
if (!token)
|
|
95
|
-
throw new Error(`Keychain item '${item}' exists but is empty.`);
|
|
96
|
-
return token;
|
|
97
|
+
// Try security first (no prompts for local items)
|
|
98
|
+
const secResult = spawnSync('security', ['find-generic-password', '-a', os.userInfo().username, '-s', item, '-w'], {
|
|
99
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
100
|
+
});
|
|
101
|
+
if (secResult.status === 0) {
|
|
102
|
+
const token = secResult.stdout?.toString().trim();
|
|
103
|
+
if (token)
|
|
104
|
+
return token;
|
|
97
105
|
}
|
|
98
|
-
|
|
106
|
+
// Fallback: binary searches both synced and non-synced via kSecAttrSynchronizableAny
|
|
107
|
+
const bin = ensureKeychainHelper();
|
|
108
|
+
const result = spawnSync(bin, ['get', item, os.userInfo().username], {
|
|
99
109
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
100
110
|
});
|
|
101
|
-
if (result.status ===
|
|
111
|
+
if (result.status === 1)
|
|
102
112
|
throw new Error(`Keychain item '${item}' not found.`);
|
|
103
113
|
if (result.status !== 0) {
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
throw new Error(`Keychain item '${item}' not found.`);
|
|
107
|
-
throw new Error(`Failed to read keychain item '${item}': ${stderr.trim() || `exit ${result.status}`}`);
|
|
114
|
+
const msg = result.stderr?.toString().trim();
|
|
115
|
+
throw new Error(msg || `Failed to read keychain item '${item}'.`);
|
|
108
116
|
}
|
|
109
117
|
const token = result.stdout?.toString().trim();
|
|
110
118
|
if (!token)
|
|
@@ -113,6 +121,10 @@ export function getKeychainToken(item, sync = false) {
|
|
|
113
121
|
}
|
|
114
122
|
/** Store or update a secret value in the macOS Keychain. iCloud-synced when sync=true. */
|
|
115
123
|
export function setKeychainToken(item, value, sync = false) {
|
|
124
|
+
if (backend) {
|
|
125
|
+
backend.set(item, value, sync);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
116
128
|
assertMacOS();
|
|
117
129
|
if (!value || !value.trim())
|
|
118
130
|
throw new Error('Secret value is empty.');
|
|
@@ -130,7 +142,7 @@ export function setKeychainToken(item, value, sync = false) {
|
|
|
130
142
|
}
|
|
131
143
|
return;
|
|
132
144
|
}
|
|
133
|
-
//
|
|
145
|
+
// `security -i` keeps the value out of argv (and `ps`).
|
|
134
146
|
const user = os.userInfo().username;
|
|
135
147
|
const cmd = `add-generic-password -a ${quoteForSecurityCli(user)} -s ${quoteForSecurityCli(item)} -w ${quoteForSecurityCli(value)} -U\n`;
|
|
136
148
|
const result = spawnSync('security', ['-i'], {
|
|
@@ -143,6 +155,8 @@ export function setKeychainToken(item, value, sync = false) {
|
|
|
143
155
|
}
|
|
144
156
|
/** Delete a keychain item. Returns true if it existed. */
|
|
145
157
|
export function deleteKeychainToken(item, sync = false) {
|
|
158
|
+
if (backend)
|
|
159
|
+
return backend.delete(item, sync);
|
|
146
160
|
assertMacOS();
|
|
147
161
|
if (sync) {
|
|
148
162
|
const bin = ensureKeychainHelper();
|
|
@@ -154,10 +168,25 @@ export function deleteKeychainToken(item, sync = false) {
|
|
|
154
168
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
155
169
|
}).status === 0;
|
|
156
170
|
}
|
|
157
|
-
// Quote a value for `security -i`'s shell-like tokenizer so it stays out of argv.
|
|
158
171
|
function quoteForSecurityCli(s) {
|
|
159
172
|
return '"' + s.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
|
|
160
173
|
}
|
|
174
|
+
/** Enumerate keychain item service names whose name starts with the given prefix. */
|
|
175
|
+
export function listKeychainItems(prefix) {
|
|
176
|
+
if (backend)
|
|
177
|
+
return backend.list(prefix);
|
|
178
|
+
assertMacOS();
|
|
179
|
+
const bin = ensureKeychainHelper();
|
|
180
|
+
const result = spawnSync(bin, ['list', prefix], {
|
|
181
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
182
|
+
});
|
|
183
|
+
if (result.status !== 0) {
|
|
184
|
+
const msg = result.stderr?.toString().trim();
|
|
185
|
+
throw new Error(msg || `Failed to enumerate keychain items with prefix '${prefix}'.`);
|
|
186
|
+
}
|
|
187
|
+
const out = result.stdout?.toString() || '';
|
|
188
|
+
return out.split('\n').map((s) => s.trim()).filter(Boolean);
|
|
189
|
+
}
|
|
161
190
|
function expandHome(p) {
|
|
162
191
|
if (p.startsWith('~/') || p === '~') {
|
|
163
192
|
return path.join(os.homedir(), p.slice(1));
|
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
* Active-session detection across every context an agent can run in:
|
|
3
3
|
*
|
|
4
4
|
* - `terminal` — agents launched from VS Code / Cursor / Codium via the
|
|
5
|
-
* agents-cli extension. Published to `~/.agents
|
|
5
|
+
* agents-cli extension. Published to `~/.agents/runtime/live-terminals.json`
|
|
6
6
|
* with PID + session UUID per entry.
|
|
7
7
|
* - `teams` — agents spawned by `agents teams add`, tracked in
|
|
8
|
-
* `~/.agents
|
|
8
|
+
* `~/.agents/teams/agents/<id>/meta.json` with a PID the manager polls.
|
|
9
9
|
* - `cloud` — dispatched to Rush / Codex Cloud / Factory, tracked in
|
|
10
|
-
* the SQLite cache at `~/.agents
|
|
10
|
+
* the SQLite cache at `~/.agents/cloud/tasks.db`.
|
|
11
11
|
* - `headless` — bare `claude` / `codex` / `gemini` / `cursor-agent` /
|
|
12
12
|
* `opencode` processes that don't belong to any of the above. Detected
|
|
13
13
|
* by `ps` minus the PIDs we've already attributed.
|
|
@@ -23,10 +23,10 @@ import { execFile } from 'child_process';
|
|
|
23
23
|
import { promisify } from 'util';
|
|
24
24
|
import { listActiveTasks } from '../cloud/store.js';
|
|
25
25
|
import { AgentManager } from '../teams/agents.js';
|
|
26
|
-
import {
|
|
26
|
+
import { getUserAgentsDir } from '../state.js';
|
|
27
27
|
const execFileAsync = promisify(execFile);
|
|
28
28
|
const HOME = os.homedir();
|
|
29
|
-
const LIVE_TERMINALS_FILE = path.join(
|
|
29
|
+
const LIVE_TERMINALS_FILE = path.join(getUserAgentsDir(), 'runtime', 'live-terminals.json');
|
|
30
30
|
/**
|
|
31
31
|
* A process is classified `running` if its session file was touched in the
|
|
32
32
|
* last 2 minutes. Every Claude/Codex tool-call appends an event, so a
|
package/dist/lib/session/db.js
CHANGED
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
import * as fs from 'fs';
|
|
10
10
|
import * as path from 'path';
|
|
11
11
|
import Database from '../sqlite.js';
|
|
12
|
-
import {
|
|
13
|
-
const SESSIONS_DIR = path.join(
|
|
12
|
+
import { getUserAgentsDir } from '../state.js';
|
|
13
|
+
const SESSIONS_DIR = path.join(getUserAgentsDir(), 'sessions');
|
|
14
14
|
const DB_PATH = path.join(SESSIONS_DIR, 'sessions.db');
|
|
15
15
|
/** Current schema version; bumped when migrations are added. */
|
|
16
16
|
const SCHEMA_VERSION = 5;
|
|
@@ -18,7 +18,7 @@ const SCHEMA_VERSION = 5;
|
|
|
18
18
|
* Canonicalize a file path for use as a scan_ledger key. The same physical
|
|
19
19
|
* session file is reachable via multiple aliases — `~/.claude/projects/x.jsonl`
|
|
20
20
|
* (when `~/.claude` is a symlink to a versioned home) and
|
|
21
|
-
* `~/.agents
|
|
21
|
+
* `~/.agents/versions/claude/<v>/home/.claude/projects/x.jsonl`. Keying the
|
|
22
22
|
* ledger by the raw path means switching between these aliases (e.g. via
|
|
23
23
|
* `agents use`) misses the cache and forces a full re-parse. Realpath collapses
|
|
24
24
|
* all aliases to one stable key.
|
|
@@ -213,7 +213,7 @@ export function getScanStampsForPaths(filePaths) {
|
|
|
213
213
|
return result;
|
|
214
214
|
const db = getDB();
|
|
215
215
|
// Multiple input paths can resolve to the same canonical key (e.g. the same
|
|
216
|
-
// session JSONL reachable via `~/.claude/...` and `~/.agents
|
|
216
|
+
// session JSONL reachable via `~/.claude/...` and `~/.agents/versions/...`).
|
|
217
217
|
// We query DB by canonical key, then fan results back out to every original
|
|
218
218
|
// alias so callers can `.get(filePath)` with the path they passed in.
|
|
219
219
|
const canonicalToOriginals = new Map();
|