@phnx-labs/agents-cli 1.20.18 → 1.20.20

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.
@@ -7,24 +7,21 @@
7
7
  * (common on server-class Linux — no graphical login means the keyring
8
8
  * passphrase never enters the daemon, so `secret-tool store` fails with
9
9
  * "Cannot create an item in a locked collection"), we transparently switch
10
- * to a file-based AES-256-GCM encrypted store under
11
- * `~/.agents/.cache/secrets/`. The encryption key is scrypt-derived from a
12
- * passphrase read from `AGENTS_SECRETS_PASSPHRASE` (preferred) or a TTY
13
- * prompt. The decision is cached per process; one stderr line is emitted
14
- * the first time the fallback activates.
10
+ * to the AES-256-GCM encrypted-file store in ./filestore.ts. The decision is
11
+ * cached per process; one stderr line is emitted the first time the fallback
12
+ * activates.
15
13
  *
16
14
  * Secrets stored via secret-tool use:
17
15
  * service = "agents-cli"
18
16
  * account = username
19
17
  * item = the secret identifier
20
- *
21
- * File-fallback layout: one `<item>.enc` JSON file per item, mode 0600.
22
18
  */
23
- import { spawnSync, execSync } from 'child_process';
24
- import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'crypto';
25
- import * as fs from 'fs';
19
+ import { spawnSync } from 'child_process';
26
20
  import * as os from 'os';
27
- import * as path from 'path';
21
+ import { fileStore, fileDir, fileStoreHasItems, machinePassphraseExists, _resetFileStoreForTest, } from './filestore.js';
22
+ // Re-exported so existing importers (and tests) can keep reaching these via
23
+ // './linux.js'. The implementations live in ./filestore.ts.
24
+ export { encryptForFallback, decryptForFallback, fileBackend, } from './filestore.js';
28
25
  const SERVICE = 'agents-cli';
29
26
  // ---------- secret-tool availability ----------
30
27
  function secretToolAvailable() {
@@ -38,12 +35,6 @@ let isAvailable = false;
38
35
  // ---------- file fallback state ----------
39
36
  let useFileFallback = false;
40
37
  let warnedFallback = false;
41
- let warnedAutoPassphrase = false;
42
- let fileDirOverride = null;
43
- let cachedPassphrase = null;
44
- function fileDir() {
45
- return fileDirOverride ?? path.join(os.homedir(), '.agents', '.cache', 'secrets');
46
- }
47
38
  function activateFileFallback() {
48
39
  if (useFileFallback)
49
40
  return;
@@ -57,18 +48,6 @@ function isLockedCollectionError(stderr) {
57
48
  return /locked collection/i.test(stderr) ||
58
49
  /Prompt was dismissed/i.test(stderr);
59
50
  }
60
- /** True if the fallback dir has any committed encrypted items. Means an
61
- * earlier process (this one or another) already routed writes to the file
62
- * store, so this process must keep reading/writing from the same store —
63
- * otherwise `list` / `get` / `has` would silently miss them. */
64
- function fileFallbackPreviouslyActivated() {
65
- try {
66
- return fs.readdirSync(fileDir()).some((e) => e.endsWith('.enc'));
67
- }
68
- catch {
69
- return false;
70
- }
71
- }
72
51
  /**
73
52
  * Decide which backend a given op should use. Activates file fallback if
74
53
  * `secret-tool` is missing and `AGENTS_SECRETS_PASSPHRASE` is set, OR if a
@@ -79,7 +58,7 @@ function fileFallbackPreviouslyActivated() {
79
58
  function preflight() {
80
59
  if (useFileFallback)
81
60
  return 'file';
82
- if (fileFallbackPreviouslyActivated()) {
61
+ if (fileStoreHasItems()) {
83
62
  activateFileFallback();
84
63
  return 'file';
85
64
  }
@@ -107,233 +86,12 @@ function preflight() {
107
86
  }
108
87
  return 'secret-tool';
109
88
  }
110
- // ---------- passphrase ----------
111
- function readPassphraseFromTty() {
112
- const fd = fs.openSync('/dev/tty', 'r+');
113
- let echoDisabled = false;
114
- try {
115
- fs.writeSync(fd, 'Enter AGENTS_SECRETS_PASSPHRASE: ');
116
- try {
117
- execSync('stty -echo < /dev/tty', { stdio: 'ignore' });
118
- echoDisabled = true;
119
- }
120
- catch {
121
- // stty not available — fall through; passphrase will echo. Better
122
- // than refusing to function.
123
- }
124
- let pass = '';
125
- const buf = Buffer.alloc(1);
126
- while (true) {
127
- const n = fs.readSync(fd, buf, 0, 1, null);
128
- if (n === 0)
129
- break;
130
- const ch = buf.toString('utf8', 0, n);
131
- if (ch === '\n' || ch === '\r')
132
- break;
133
- pass += ch;
134
- }
135
- return pass;
136
- }
137
- finally {
138
- if (echoDisabled) {
139
- try {
140
- execSync('stty echo < /dev/tty', { stdio: 'ignore' });
141
- }
142
- catch { /* best effort */ }
143
- }
144
- try {
145
- fs.writeSync(fd, '\n');
146
- }
147
- catch { /* best effort */ }
148
- fs.closeSync(fd);
149
- }
150
- }
151
- /** Path of the auto-provisioned machine-local passphrase. Lives alongside the
152
- * encrypted items but is never itself an item (no `.enc` suffix, so it's
153
- * excluded from list/has/get and from fileFallbackPreviouslyActivated). */
154
- function passphraseFilePath() {
155
- return path.join(fileDir(), '.passphrase');
156
- }
157
- /** True if a machine-local passphrase has already been provisioned. */
158
- function machinePassphraseExists() {
159
- try {
160
- return fs.readFileSync(passphraseFilePath(), 'utf8').trim().length > 0;
161
- }
162
- catch {
163
- return false;
164
- }
165
- }
166
- function readMachinePassphrase() {
167
- try {
168
- const p = fs.readFileSync(passphraseFilePath(), 'utf8').trim();
169
- return p.length > 0 ? p : null;
170
- }
171
- catch {
172
- return null;
173
- }
174
- }
175
- /**
176
- * Provision (or read back) a stable machine-local passphrase for the encrypted
177
- * file store, so `agents secrets` works out of the box on a headless box where
178
- * the keyring is locked and no AGENTS_SECRETS_PASSPHRASE is set.
179
- *
180
- * Security model: this is encryption-at-rest with the key held in a 0600 file —
181
- * the same posture as an SSH private key, and identical to the common
182
- * "export AGENTS_SECRETS_PASSPHRASE=… in ~/.zshenv (chmod 600)" workaround. The
183
- * keyring (key in a daemon's locked memory) is stronger but is unavailable
184
- * without a graphical/unlocked session. For an off-disk key, set
185
- * AGENTS_SECRETS_PASSPHRASE (it always takes precedence) or unlock the keyring.
186
- */
187
- function provisionMachinePassphrase() {
188
- const existing = readMachinePassphrase();
189
- if (existing)
190
- return existing;
191
- ensureFileDir();
192
- const generated = randomBytes(32).toString('base64');
193
- const fp = passphraseFilePath();
194
- try {
195
- // wx: fail if a concurrent process created it first (then we read theirs).
196
- fs.writeFileSync(fp, generated, { mode: 0o600, flag: 'wx' });
197
- }
198
- catch {
199
- const raced = readMachinePassphrase();
200
- if (raced)
201
- return raced;
202
- throw new Error(`Failed to provision machine-local passphrase at ${fp}.`);
203
- }
204
- if (!warnedAutoPassphrase) {
205
- warnedAutoPassphrase = true;
206
- process.stderr.write(`[agents] keyring locked and no AGENTS_SECRETS_PASSPHRASE set; provisioned a ` +
207
- `machine-local passphrase at ${fp} (mode 0600). Set AGENTS_SECRETS_PASSPHRASE ` +
208
- `for a key held off disk.\n`);
209
- }
210
- return generated;
211
- }
212
- function getPassphrase() {
213
- if (cachedPassphrase !== null)
214
- return cachedPassphrase;
215
- const env = process.env.AGENTS_SECRETS_PASSPHRASE;
216
- if (env && env.length > 0) {
217
- cachedPassphrase = env;
218
- return env;
219
- }
220
- // A previously-provisioned machine-local passphrase is this machine's stable
221
- // file-store key — prefer it for both interactive and headless runs so they
222
- // always agree (a TTY run won't re-prompt once the file exists).
223
- const onDisk = readMachinePassphrase();
224
- if (onDisk) {
225
- cachedPassphrase = onDisk;
226
- return onDisk;
227
- }
228
- // First run, no env, no provisioned key: prompt when interactive, otherwise
229
- // (headless — the reported bug) auto-provision instead of hard-failing.
230
- if (process.stdin.isTTY) {
231
- const p = readPassphraseFromTty();
232
- if (!p)
233
- throw new Error('No passphrase entered.');
234
- cachedPassphrase = p;
235
- return p;
236
- }
237
- cachedPassphrase = provisionMachinePassphrase();
238
- return cachedPassphrase;
239
- }
240
- function deriveKey(passphrase, salt) {
241
- return scryptSync(passphrase, salt, 32);
242
- }
243
- /** Encrypt plaintext under a passphrase using AES-256-GCM with a random
244
- * scrypt salt and a random 96-bit IV. Exported for tests. */
245
- export function encryptForFallback(plaintext, passphrase) {
246
- const salt = randomBytes(16);
247
- const iv = randomBytes(12);
248
- const key = deriveKey(passphrase, salt);
249
- const cipher = createCipheriv('aes-256-gcm', key, iv);
250
- const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
251
- return {
252
- salt: salt.toString('hex'),
253
- iv: iv.toString('hex'),
254
- authTag: cipher.getAuthTag().toString('hex'),
255
- ciphertext: ciphertext.toString('hex'),
256
- };
257
- }
258
- /** Decrypt an EncFile under a passphrase. Throws on wrong key or tampered
259
- * ciphertext (auth-tag mismatch). Exported for tests. */
260
- export function decryptForFallback(enc, passphrase) {
261
- const salt = Buffer.from(enc.salt, 'hex');
262
- const iv = Buffer.from(enc.iv, 'hex');
263
- const authTag = Buffer.from(enc.authTag, 'hex');
264
- const ciphertext = Buffer.from(enc.ciphertext, 'hex');
265
- const key = deriveKey(passphrase, salt);
266
- const decipher = createDecipheriv('aes-256-gcm', key, iv);
267
- decipher.setAuthTag(authTag);
268
- const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
269
- return plaintext.toString('utf8');
270
- }
271
- // ---------- file backend ----------
272
- function fileFor(item) {
273
- return path.join(fileDir(), `${item}.enc`);
274
- }
275
- function ensureFileDir() {
276
- fs.mkdirSync(fileDir(), { recursive: true, mode: 0o700 });
277
- }
278
- function fileHas(item) {
279
- return fs.existsSync(fileFor(item));
280
- }
281
- function fileGet(item) {
282
- const fp = fileFor(item);
283
- if (!fs.existsSync(fp)) {
284
- throw new Error(`Secret '${item}' not found in encrypted store.`);
285
- }
286
- const raw = fs.readFileSync(fp, 'utf8');
287
- let parsed;
288
- try {
289
- parsed = JSON.parse(raw);
290
- }
291
- catch {
292
- throw new Error(`Encrypted secret file ${fp} is corrupt (not valid JSON).`);
293
- }
294
- try {
295
- return decryptForFallback(parsed, getPassphrase());
296
- }
297
- catch {
298
- throw new Error(`Failed to decrypt '${item}'. Wrong AGENTS_SECRETS_PASSPHRASE or tampered file.`);
299
- }
300
- }
301
- function fileSet(item, value) {
302
- ensureFileDir();
303
- const enc = encryptForFallback(value, getPassphrase());
304
- fs.writeFileSync(fileFor(item), JSON.stringify(enc), { mode: 0o600 });
305
- }
306
- function fileDelete(item) {
307
- const fp = fileFor(item);
308
- if (!fs.existsSync(fp))
309
- return true; // idempotent, matches secret-tool clear
310
- fs.unlinkSync(fp);
311
- return true;
312
- }
313
- function fileList(prefix) {
314
- const dir = fileDir();
315
- if (!fs.existsSync(dir))
316
- return [];
317
- return fs.readdirSync(dir)
318
- .filter((f) => f.endsWith('.enc'))
319
- .map((f) => f.slice(0, -'.enc'.length))
320
- .filter((name) => name.startsWith(prefix));
321
- }
322
- /** File-only KeychainBackend (exported for tests; the public surface uses
323
- * the secret-tool-with-fallback `linuxBackend` below). */
324
- export const fileBackend = {
325
- has: fileHas,
326
- get: fileGet,
327
- set: fileSet,
328
- delete: fileDelete,
329
- list: fileList,
330
- };
331
89
  // ---------- secret-tool ops with fallback ----------
332
90
  /** secret-tool lookup attributes:
333
91
  * service=agents-cli account=<user> item=<itemName> */
334
92
  export function hasSecretToolToken(item) {
335
93
  if (preflight() === 'file')
336
- return fileHas(item);
94
+ return fileStore.has(item);
337
95
  const user = os.userInfo().username;
338
96
  const result = spawnSync('secret-tool', [
339
97
  'lookup',
@@ -347,13 +105,13 @@ export function hasSecretToolToken(item) {
347
105
  const stderr = result.stderr?.toString() ?? '';
348
106
  if (isLockedCollectionError(stderr)) {
349
107
  activateFileFallback();
350
- return fileHas(item);
108
+ return fileStore.has(item);
351
109
  }
352
110
  return false;
353
111
  }
354
112
  export function getSecretToolToken(item) {
355
113
  if (preflight() === 'file')
356
- return fileGet(item);
114
+ return fileStore.get(item);
357
115
  const user = os.userInfo().username;
358
116
  const result = spawnSync('secret-tool', [
359
117
  'lookup',
@@ -370,7 +128,7 @@ export function getSecretToolToken(item) {
370
128
  const stderr = result.stderr?.toString() ?? '';
371
129
  if (isLockedCollectionError(stderr)) {
372
130
  activateFileFallback();
373
- return fileGet(item);
131
+ return fileStore.get(item);
374
132
  }
375
133
  throw new Error(`Secret '${item}' not found in keyring.`);
376
134
  }
@@ -378,7 +136,7 @@ export function setSecretToolToken(item, value) {
378
136
  if (!value || !value.trim())
379
137
  throw new Error('Secret value is empty.');
380
138
  if (preflight() === 'file')
381
- return fileSet(item, value);
139
+ return fileStore.set(item, value);
382
140
  const user = os.userInfo().username;
383
141
  const label = `agents-cli: ${item}`;
384
142
  const result = spawnSync('secret-tool', [
@@ -393,7 +151,7 @@ export function setSecretToolToken(item, value) {
393
151
  const stderr = result.stderr?.toString().trim() ?? '';
394
152
  if (isLockedCollectionError(stderr)) {
395
153
  activateFileFallback();
396
- fileSet(item, value);
154
+ fileStore.set(item, value);
397
155
  return;
398
156
  }
399
157
  throw new Error(`Failed to store secret '${item}': ${stderr || 'unknown error'}\n` +
@@ -402,7 +160,7 @@ export function setSecretToolToken(item, value) {
402
160
  }
403
161
  export function deleteSecretToolToken(item) {
404
162
  if (preflight() === 'file')
405
- return fileDelete(item);
163
+ return fileStore.delete(item);
406
164
  const user = os.userInfo().username;
407
165
  const result = spawnSync('secret-tool', [
408
166
  'clear',
@@ -415,7 +173,7 @@ export function deleteSecretToolToken(item) {
415
173
  const stderr = result.stderr?.toString() ?? '';
416
174
  if (isLockedCollectionError(stderr)) {
417
175
  activateFileFallback();
418
- return fileDelete(item);
176
+ return fileStore.delete(item);
419
177
  }
420
178
  // secret-tool clear returns 0 whether the item existed or not.
421
179
  // A non-zero exit that isn't a locked-collection error is a real failure;
@@ -456,7 +214,7 @@ export function parseSecretToolItems(output, prefix) {
456
214
  */
457
215
  export function listSecretToolItems(prefix) {
458
216
  if (preflight() === 'file')
459
- return fileList(prefix);
217
+ return fileStore.list(prefix);
460
218
  const result = spawnSync('secret-tool', [
461
219
  'search',
462
220
  '--all',
@@ -466,7 +224,7 @@ export function listSecretToolItems(prefix) {
466
224
  const stderr = result.stderr?.toString() ?? '';
467
225
  if (isLockedCollectionError(stderr)) {
468
226
  activateFileFallback();
469
- return fileList(prefix);
227
+ return fileStore.list(prefix);
470
228
  }
471
229
  return [];
472
230
  }
@@ -495,13 +253,12 @@ export const linuxBackend = {
495
253
  },
496
254
  };
497
255
  /** Test-only: reset module state so independent test cases don't bleed
498
- * passphrase / fallback decisions across each other. */
256
+ * passphrase / fallback decisions across each other. File-store state (file
257
+ * dir + cached passphrase) lives in ./filestore.ts and is reset there. */
499
258
  export function _resetForTest(opts = {}) {
500
- fileDirOverride = opts.fileDir ?? null;
259
+ _resetFileStoreForTest({ fileDir: opts.fileDir ?? null, passphrase: opts.passphrase ?? null });
501
260
  useFileFallback = opts.forceFileFallback ?? false;
502
261
  warnedFallback = false;
503
- warnedAutoPassphrase = false;
504
- cachedPassphrase = opts.passphrase ?? null;
505
262
  checkedAvailability = false;
506
263
  isAvailable = false;
507
264
  }
@@ -176,6 +176,26 @@ export declare function installVersion(agent: AgentId, version: string, onProgre
176
176
  installedVersion: string;
177
177
  error?: string;
178
178
  }>;
179
+ /**
180
+ * Fold a stale literal `latest` version dir into the real resolved version.
181
+ *
182
+ * Script-installed agents (droid, grok) have no npm package to read a version
183
+ * from, so the installer resolves the version by probing `<cli> --version`
184
+ * after the install script runs. When that probe failed (3s timeout, or the
185
+ * freshly-dropped binary not yet resolvable on PATH) the installer fell back to
186
+ * the literal string `latest`, creating a `versions/<agent>/latest/` dir. A
187
+ * later install where the probe succeeded then created a SECOND dir at the real
188
+ * semver, orphaning `latest` — and because these agents' getBinaryPath points
189
+ * at a single global binary regardless of version dir, `latest` keeps showing
190
+ * up in `agents view` next to the real version forever.
191
+ *
192
+ * Call this once the install path has resolved a real version: if a stale
193
+ * `latest` dir exists, rename it onto the real version (preserving `home/`), or
194
+ * if the real dir already exists, soft-delete the `latest` dir to trash. No-op
195
+ * when nothing was resolved or no stale dir is present, so it is safe to call
196
+ * on every script-based install. Returns the action taken (for tests/logging).
197
+ */
198
+ export declare function reconcileStaleLatestDir(agent: AgentId, installedVersion: string): Promise<'none' | 'renamed' | 'trashed'>;
179
199
  /**
180
200
  * Soft-delete a version directory by moving it to ~/.agents/.system/trash/versions/.
181
201
  * Returns the trash path on success or null on failure / no source.
@@ -996,6 +996,9 @@ export async function installVersion(agent, version, onProgress) {
996
996
  await execAsync(script, { timeout: 120000 });
997
997
  if (version === 'latest') {
998
998
  installedVersion = await getCliVersionFromPath(agent) || version;
999
+ // Fold any stale literal `latest` dir from an earlier probe-failed
1000
+ // install into the real version so it stops shadowing `agents view`.
1001
+ await reconcileStaleLatestDir(agent, installedVersion);
999
1002
  }
1000
1003
  onProgress?.(`${agentConfig.name} installed. Setting up agents-cli version home for isolation...`);
1001
1004
  }
@@ -1158,6 +1161,51 @@ function removeInstallArtifacts(versionDir) {
1158
1161
  fs.rmSync(path.join(versionDir, entry), { recursive: true, force: true });
1159
1162
  }
1160
1163
  }
1164
+ /**
1165
+ * Fold a stale literal `latest` version dir into the real resolved version.
1166
+ *
1167
+ * Script-installed agents (droid, grok) have no npm package to read a version
1168
+ * from, so the installer resolves the version by probing `<cli> --version`
1169
+ * after the install script runs. When that probe failed (3s timeout, or the
1170
+ * freshly-dropped binary not yet resolvable on PATH) the installer fell back to
1171
+ * the literal string `latest`, creating a `versions/<agent>/latest/` dir. A
1172
+ * later install where the probe succeeded then created a SECOND dir at the real
1173
+ * semver, orphaning `latest` — and because these agents' getBinaryPath points
1174
+ * at a single global binary regardless of version dir, `latest` keeps showing
1175
+ * up in `agents view` next to the real version forever.
1176
+ *
1177
+ * Call this once the install path has resolved a real version: if a stale
1178
+ * `latest` dir exists, rename it onto the real version (preserving `home/`), or
1179
+ * if the real dir already exists, soft-delete the `latest` dir to trash. No-op
1180
+ * when nothing was resolved or no stale dir is present, so it is safe to call
1181
+ * on every script-based install. Returns the action taken (for tests/logging).
1182
+ */
1183
+ export async function reconcileStaleLatestDir(agent, installedVersion) {
1184
+ if (installedVersion === 'latest')
1185
+ return 'none';
1186
+ const staleLatestDir = getVersionDir(agent, 'latest');
1187
+ const realVersionDir = getVersionDir(agent, installedVersion);
1188
+ if (staleLatestDir === realVersionDir || !fs.existsSync(staleLatestDir)) {
1189
+ return 'none';
1190
+ }
1191
+ if (!fs.existsSync(realVersionDir)) {
1192
+ fs.renameSync(staleLatestDir, realVersionDir);
1193
+ return 'renamed';
1194
+ }
1195
+ // Both dirs exist. Stripping install artifacts would not hide `latest` for
1196
+ // global-binary agents (getBinaryPath ignores dir contents), so the whole
1197
+ // dir must go. Soft-delete to trash so any `home/` data stays recoverable
1198
+ // via `agents restore <agent>@latest`, then rewrite session file paths to
1199
+ // point at the trashed location so history stays readable. The session-db
1200
+ // module is imported lazily — it carries a top-level await that the CJS test
1201
+ // harness can't statically transform, so it must stay out of the eager graph.
1202
+ const trashPath = softDeleteVersionDir(agent, 'latest');
1203
+ if (trashPath) {
1204
+ const { updateSessionFilePaths } = await import('./session/db.js');
1205
+ updateSessionFilePaths(staleLatestDir, trashPath);
1206
+ }
1207
+ return 'trashed';
1208
+ }
1161
1209
  /**
1162
1210
  * Soft-delete a version directory by moving it to ~/.agents/.system/trash/versions/.
1163
1211
  * Returns the trash path on success or null on failure / no source.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.20.18",
3
+ "version": "1.20.20",
4
4
  "description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",