@ouro.bot/cli 0.1.0-alpha.557 → 0.1.0-alpha.558

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/changelog.json CHANGED
@@ -1,6 +1,14 @@
1
1
  {
2
2
  "_note": "This changelog is maintained as part of the PR/version-bump workflow. Agent-curated, not auto-generated. Agents read this file directly via read_file to understand what changed between versions.",
3
3
  "versions": [
4
+ {
5
+ "version": "0.1.0-alpha.558",
6
+ "changes": [
7
+ "Rejected local vault unlock material is now cleared after Bitwarden proves it is wrong, so a stale Keychain/DPAPI/Secret Service/plaintext entry cannot keep retrying across fresh CLI processes and rate-limit the agent vault.",
8
+ "Legacy vault unlock entries are no longer copied to canonical coordinates during read. Ouro now canonicalizes them only after a successful vault login, preventing unvalidated local material from poisoning the primary unlock slot.",
9
+ "`@ouro.bot/cli` and the `ouro.bot` wrapper are version-synced for the invalid local unlock quarantine release."
10
+ ]
11
+ },
4
12
  {
5
13
  "version": "0.1.0-alpha.557",
6
14
  "changes": [
@@ -401,6 +401,8 @@ class BitwardenCredentialStore {
401
401
  email;
402
402
  masterPassword;
403
403
  appDataDir;
404
+ onInvalidUnlockSecret;
405
+ onLoginSuccess;
404
406
  sessionToken = null;
405
407
  terminalLoginError = null;
406
408
  bwBinaryPath = "bw";
@@ -410,6 +412,8 @@ class BitwardenCredentialStore {
410
412
  this.email = email;
411
413
  this.masterPassword = masterPassword;
412
414
  this.appDataDir = options.appDataDir;
415
+ this.onInvalidUnlockSecret = options.onInvalidUnlockSecret;
416
+ this.onLoginSuccess = options.onLoginSuccess;
413
417
  }
414
418
  isReady() {
415
419
  return true;
@@ -420,6 +424,27 @@ class BitwardenCredentialStore {
420
424
  execBwWithPasswordEnv(args) {
421
425
  return execBw([...args, "--passwordenv", BW_PASSWORD_ENV], undefined, this.appDataDir, undefined, this.bwBinaryPath, { [BW_PASSWORD_ENV]: this.masterPassword });
422
426
  }
427
+ async notifyInvalidUnlockSecret(error) {
428
+ if (!this.onInvalidUnlockSecret)
429
+ return;
430
+ try {
431
+ await this.onInvalidUnlockSecret(error);
432
+ }
433
+ catch (callbackError) {
434
+ (0, runtime_1.emitNervesEvent)({
435
+ level: "warn",
436
+ event: "repertoire.bw_invalid_unlock_cleanup_failed",
437
+ component: "repertoire",
438
+ message: "failed to clean up rejected local vault unlock material",
439
+ meta: {
440
+ email: this.email,
441
+ serverUrl: this.serverUrl,
442
+ error: callbackError instanceof Error ? callbackError.message : String(callbackError),
443
+ originalError: error.message,
444
+ },
445
+ });
446
+ }
447
+ }
423
448
  /**
424
449
  * Ensure the bw CLI is authenticated and unlocked.
425
450
  * Handles three states: logged out → login, locked → unlock, already unlocked → no-op.
@@ -438,6 +463,7 @@ class BitwardenCredentialStore {
438
463
  for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
439
464
  try {
440
465
  await this.loginAttempt();
466
+ await this.onLoginSuccess?.();
441
467
  return;
442
468
  }
443
469
  catch (err) {
@@ -446,6 +472,9 @@ class BitwardenCredentialStore {
446
472
  // Don't retry non-transient errors (auth failures, bw not installed)
447
473
  if (!isTransientError(lastError)) {
448
474
  this.terminalLoginError = lastError;
475
+ if (isBwInvalidUnlockSecretMessage(lastError.message)) {
476
+ await this.notifyInvalidUnlockSecret(lastError);
477
+ }
449
478
  throw lastError;
450
479
  }
451
480
  // Don't retry after final attempt
@@ -462,6 +491,10 @@ class BitwardenCredentialStore {
462
491
  }
463
492
  }
464
493
  this.terminalLoginError = lastError;
494
+ /* v8 ignore next -- invalid unlock errors are non-transient and exit through the non-retry path above @preserve */
495
+ if (isBwInvalidUnlockSecretMessage(lastError.message)) {
496
+ await this.notifyInvalidUnlockSecret(lastError);
497
+ }
465
498
  throw lastError;
466
499
  }
467
500
  /** Single login attempt — called by login() retry loop. */
@@ -92,7 +92,63 @@ function getCredentialStore(agentNameInput) {
92
92
  email: vaultConfig.email,
93
93
  serverUrl: vaultConfig.serverUrl,
94
94
  });
95
- const store = new bitwarden_store_1.BitwardenCredentialStore(vaultConfig.serverUrl, vaultConfig.email, unlock.secret, { appDataDir: bitwardenAppDataDir(agentName, vaultConfig) });
95
+ const unlockConfig = { agentName, email: vaultConfig.email, serverUrl: vaultConfig.serverUrl };
96
+ const unlockSource = unlock.source ?? unlockConfig;
97
+ let invalidUnlockCleared = false;
98
+ let canonicalUnlockStored = false;
99
+ const store = new bitwarden_store_1.BitwardenCredentialStore(vaultConfig.serverUrl, vaultConfig.email, unlock.secret, {
100
+ appDataDir: bitwardenAppDataDir(agentName, vaultConfig),
101
+ onInvalidUnlockSecret: (error) => {
102
+ if (invalidUnlockCleared)
103
+ return;
104
+ invalidUnlockCleared = true;
105
+ (0, vault_unlock_1.clearVaultUnlockSecret)({
106
+ agentName,
107
+ email: unlockSource.email,
108
+ serverUrl: unlockSource.serverUrl,
109
+ });
110
+ stores.delete(cacheKey);
111
+ (0, runtime_1.emitNervesEvent)({
112
+ level: "warn",
113
+ event: "repertoire.credential_store_invalid_unlock_cleared",
114
+ component: "repertoire",
115
+ message: "cleared rejected local vault unlock material",
116
+ meta: {
117
+ agentName,
118
+ serverUrl: vaultConfig.serverUrl,
119
+ email: vaultConfig.email,
120
+ sourceServerUrl: unlockSource.serverUrl,
121
+ error: error.message,
122
+ },
123
+ });
124
+ },
125
+ onLoginSuccess: () => {
126
+ if (canonicalUnlockStored)
127
+ return;
128
+ if (unlockSource.serverUrl === vaultConfig.serverUrl && unlockSource.email === vaultConfig.email)
129
+ return;
130
+ canonicalUnlockStored = true;
131
+ try {
132
+ (0, vault_unlock_1.storeVaultUnlockSecret)(unlockConfig, unlock.secret);
133
+ (0, vault_unlock_1.noteVaultUnlockSelfHeal)(unlockConfig, unlock.store.kind, unlockSource.serverUrl);
134
+ }
135
+ catch (error) {
136
+ (0, runtime_1.emitNervesEvent)({
137
+ level: "warn",
138
+ event: "repertoire.vault_unlock_self_heal_failed",
139
+ component: "repertoire",
140
+ message: "failed to rewrite local unlock material using canonical vault coordinates",
141
+ meta: {
142
+ store: unlock.store.kind,
143
+ email: vaultConfig.email,
144
+ sourceServerUrl: unlockSource.serverUrl,
145
+ targetServerUrl: vaultConfig.serverUrl,
146
+ error: error instanceof Error ? error.message : String(error),
147
+ },
148
+ });
149
+ }
150
+ },
151
+ });
96
152
  stores.set(cacheKey, store);
97
153
  (0, runtime_1.emitNervesEvent)({
98
154
  event: "repertoire.credential_store_init",
@@ -39,6 +39,8 @@ exports.isCredentialVaultNotConfiguredError = isCredentialVaultNotConfiguredErro
39
39
  exports.vaultCreateRecoverFix = vaultCreateRecoverFix;
40
40
  exports.promptConfirmedVaultUnlockSecret = promptConfirmedVaultUnlockSecret;
41
41
  exports.resolveVaultUnlockStore = resolveVaultUnlockStore;
42
+ exports.noteVaultUnlockSelfHeal = noteVaultUnlockSelfHeal;
43
+ exports.clearVaultUnlockSecret = clearVaultUnlockSecret;
42
44
  exports.readVaultUnlockSecret = readVaultUnlockSecret;
43
45
  exports.storeVaultUnlockSecret = storeVaultUnlockSecret;
44
46
  exports.getVaultUnlockStatus = getVaultUnlockStatus;
@@ -82,6 +84,12 @@ function vaultConfigCandidates(config) {
82
84
  serverUrl,
83
85
  }));
84
86
  }
87
+ function exactVaultConfig(config) {
88
+ return {
89
+ ...config,
90
+ serverUrl: config.serverUrl.trim().replace(/\/+$/, ""),
91
+ };
92
+ }
85
93
  function plaintextUnlockPath(config, deps) {
86
94
  const digest = crypto.createHash("sha256").update(vaultKey(config)).digest("hex").slice(0, 24);
87
95
  return path.join(homeDir(deps), PLAINTEXT_UNLOCK_DIR, `${digest}.secret`);
@@ -265,37 +273,13 @@ function noteVaultUnlockSelfHeal(config, storeKind, sourceServerUrl) {
265
273
  },
266
274
  });
267
275
  }
268
- function warnVaultUnlockSelfHealFailure(config, storeKind, sourceServerUrl, error) {
269
- (0, runtime_1.emitNervesEvent)({
270
- level: "warn",
271
- component: "repertoire",
272
- event: "repertoire.vault_unlock_self_heal_failed",
273
- message: "failed to rewrite local unlock material using canonical vault coordinates",
274
- meta: {
275
- store: storeKind,
276
- email: config.email,
277
- sourceServerUrl,
278
- targetServerUrl: config.serverUrl,
279
- error: error instanceof Error ? error.message : String(error),
280
- },
281
- });
282
- }
283
276
  function readFromMacosKeychain(config, deps) {
284
277
  const candidates = vaultConfigCandidates(config);
285
- for (const [index, candidate] of candidates.entries()) {
278
+ for (const candidate of candidates) {
286
279
  const secret = readFromMacosKeychainExact(vaultKey(candidate), deps);
287
- if (!secret)
288
- continue;
289
- if (index > 0) {
290
- try {
291
- writeToMacosKeychain(config, secret, deps);
292
- noteVaultUnlockSelfHeal(config, "macos-keychain", candidate.serverUrl);
293
- }
294
- catch (error) {
295
- warnVaultUnlockSelfHealFailure(config, "macos-keychain", candidate.serverUrl, error);
296
- }
280
+ if (secret) {
281
+ return { secret, source: exactVaultConfig(candidate) };
297
282
  }
298
- return secret;
299
283
  }
300
284
  return null;
301
285
  }
@@ -337,20 +321,11 @@ function readFromLinuxSecretServiceExact(accountKey, deps) {
337
321
  }
338
322
  function readFromLinuxSecretService(config, deps) {
339
323
  const candidates = vaultConfigCandidates(config);
340
- for (const [index, candidate] of candidates.entries()) {
324
+ for (const candidate of candidates) {
341
325
  const secret = readFromLinuxSecretServiceExact(vaultKey(candidate), deps);
342
- if (!secret)
343
- continue;
344
- if (index > 0) {
345
- try {
346
- writeToLinuxSecretService(config, secret, deps);
347
- noteVaultUnlockSelfHeal(config, "linux-secret-service", candidate.serverUrl);
348
- }
349
- catch (error) {
350
- warnVaultUnlockSelfHealFailure(config, "linux-secret-service", candidate.serverUrl, error);
351
- }
326
+ if (secret) {
327
+ return { secret, source: exactVaultConfig(candidate) };
352
328
  }
353
- return secret;
354
329
  }
355
330
  return null;
356
331
  }
@@ -417,20 +392,11 @@ function readFromWindowsDpapiExact(config, deps) {
417
392
  }
418
393
  function readFromWindowsDpapi(config, deps) {
419
394
  const candidates = vaultConfigCandidates(config);
420
- for (const [index, candidate] of candidates.entries()) {
395
+ for (const candidate of candidates) {
421
396
  const secret = readFromWindowsDpapiExact(candidate, deps);
422
- if (!secret)
423
- continue;
424
- if (index > 0) {
425
- try {
426
- writeToWindowsDpapi(config, secret, deps);
427
- noteVaultUnlockSelfHeal(config, "windows-dpapi", candidate.serverUrl);
428
- }
429
- catch (error) {
430
- warnVaultUnlockSelfHealFailure(config, "windows-dpapi", candidate.serverUrl, error);
431
- }
397
+ if (secret) {
398
+ return { secret, source: exactVaultConfig(candidate) };
432
399
  }
433
- return secret;
434
400
  }
435
401
  return null;
436
402
  }
@@ -455,20 +421,11 @@ function readFromPlaintextFileExact(config, deps) {
455
421
  }
456
422
  function readFromPlaintextFile(config, deps) {
457
423
  const candidates = vaultConfigCandidates(config);
458
- for (const [index, candidate] of candidates.entries()) {
424
+ for (const candidate of candidates) {
459
425
  const secret = readFromPlaintextFileExact(candidate, deps);
460
- if (!secret)
461
- continue;
462
- if (index > 0) {
463
- try {
464
- writeToPlaintextFile(config, secret, deps);
465
- noteVaultUnlockSelfHeal(config, "plaintext-file", candidate.serverUrl);
466
- }
467
- catch (error) {
468
- warnVaultUnlockSelfHealFailure(config, "plaintext-file", candidate.serverUrl, error);
469
- }
426
+ if (secret) {
427
+ return { secret, source: exactVaultConfig(candidate) };
470
428
  }
471
- return secret;
472
429
  }
473
430
  return null;
474
431
  }
@@ -505,20 +462,96 @@ function writeToStore(config, store, secret, deps) {
505
462
  }
506
463
  writeToPlaintextFile(config, secret, deps);
507
464
  }
465
+ function deleteFromMacosKeychainExact(config, deps) {
466
+ const result = spawnSync(deps)("security", [
467
+ "delete-generic-password",
468
+ "-s",
469
+ VAULT_UNLOCK_SERVICE,
470
+ "-a",
471
+ vaultKey(config),
472
+ ], { encoding: "utf8" });
473
+ if (result.status === 0)
474
+ return true;
475
+ const stderr = typeof result.stderr === "string" ? result.stderr.trim() : "";
476
+ const error = result.error instanceof Error ? result.error.message : "";
477
+ const detail = stderr || error;
478
+ if (!detail || /could not be found in the keychain/i.test(detail))
479
+ return false;
480
+ throw new Error(`failed to clear vault unlock secret from macOS Keychain: ${detail}`);
481
+ }
482
+ function deleteFromLinuxSecretServiceExact(config, deps) {
483
+ const result = spawnSync(deps)("secret-tool", [
484
+ "clear",
485
+ "service",
486
+ VAULT_UNLOCK_SERVICE,
487
+ "account",
488
+ vaultKey(config),
489
+ ], { encoding: "utf8" });
490
+ if (result.status === 0)
491
+ return true;
492
+ const stderr = typeof result.stderr === "string" ? result.stderr.trim() : "";
493
+ const error = result.error instanceof Error ? result.error.message : "";
494
+ const detail = stderr || error;
495
+ if (!detail || /not found/i.test(detail))
496
+ return false;
497
+ throw new Error(`failed to clear vault unlock secret from Linux Secret Service: ${detail}`);
498
+ }
499
+ function deleteFromWindowsDpapiExact(config, deps) {
500
+ const filePath = windowsDpapiUnlockPath(config, deps);
501
+ const existed = fs.existsSync(filePath);
502
+ fs.rmSync(filePath, { force: true });
503
+ return existed;
504
+ }
505
+ function deleteFromPlaintextFileExact(config, deps) {
506
+ const filePath = plaintextUnlockPath(config, deps);
507
+ const existed = fs.existsSync(filePath);
508
+ fs.rmSync(filePath, { force: true });
509
+ return existed;
510
+ }
511
+ function deleteFromStoreExact(config, store, deps) {
512
+ if (store.kind === "macos-keychain")
513
+ return deleteFromMacosKeychainExact(config, deps);
514
+ if (store.kind === "linux-secret-service")
515
+ return deleteFromLinuxSecretServiceExact(config, deps);
516
+ if (store.kind === "windows-dpapi")
517
+ return deleteFromWindowsDpapiExact(config, deps);
518
+ return deleteFromPlaintextFileExact(config, deps);
519
+ }
520
+ function clearVaultUnlockSecret(config, deps = {}) {
521
+ const canonicalConfig = canonicalizeVaultUnlockConfig(config);
522
+ const store = resolveVaultUnlockStore(canonicalConfig, deps);
523
+ const deletedAccounts = new Set();
524
+ let deleted = false;
525
+ for (const candidate of vaultConfigCandidates(config)) {
526
+ const exactCandidate = exactVaultConfig(candidate);
527
+ const key = vaultKey(exactCandidate);
528
+ if (deletedAccounts.has(key))
529
+ continue;
530
+ deletedAccounts.add(key);
531
+ deleted = deleteFromStoreExact(exactCandidate, store, deps) || deleted;
532
+ }
533
+ (0, runtime_1.emitNervesEvent)({
534
+ component: "repertoire",
535
+ event: "repertoire.vault_unlock_cleared",
536
+ message: "cleared local vault unlock material",
537
+ meta: { store: store.kind, secure: store.secure, hasAgentName: !!config.agentName, deleted },
538
+ });
539
+ return store;
540
+ }
508
541
  function readVaultUnlockSecret(config, deps = {}) {
509
542
  const canonicalConfig = canonicalizeVaultUnlockConfig(config);
510
543
  const store = resolveVaultUnlockStore(canonicalConfig, deps);
511
- const secret = readFromStore(canonicalConfig, store, deps);
512
- if (!secret) {
544
+ const loaded = readFromStore(canonicalConfig, store, deps);
545
+ if (!loaded) {
513
546
  throw new Error(lockedMessage(canonicalConfig, store));
514
547
  }
515
548
  (0, runtime_1.emitNervesEvent)({
516
549
  component: "repertoire",
517
550
  event: "repertoire.vault_unlock_loaded",
518
551
  message: "loaded vault unlock material from local store",
519
- meta: { store: store.kind, secure: store.secure, hasAgentName: !!config.agentName },
552
+ meta: { store: store.kind, secure: store.secure, hasAgentName: !!config.agentName, sourceServerUrl: loaded.source.serverUrl },
520
553
  });
521
- return { secret, store };
554
+ return { secret: loaded.secret, store, source: loaded.source };
522
555
  }
523
556
  function storeVaultUnlockSecret(config, secret, deps = {}) {
524
557
  const canonicalConfig = canonicalizeVaultUnlockConfig(config);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.557",
3
+ "version": "0.1.0-alpha.558",
4
4
  "main": "dist/heart/daemon/ouro-entry.js",
5
5
  "bin": {
6
6
  "cli": "dist/heart/daemon/ouro-bot-entry.js",