@phnx-labs/agents-cli 1.20.18 → 1.20.19

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
  }
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.19",
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",