@ouro.bot/cli 0.1.0-alpha.556 → 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,22 @@
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
+ },
12
+ {
13
+ "version": "0.1.0-alpha.557",
14
+ "changes": [
15
+ "`ouro vault unlock` now validates the typed unlock secret against the agent vault before replacing this machine's saved local unlock material, so a failed unlock attempt cannot poison a previously working Keychain/DPAPI/Secret Service/plaintext entry.",
16
+ "The Bitwarden adapter now rebuilds stale per-agent local `bw` profiles when they point at the wrong vault account/server or reject a saved unlock during unlock, reducing restart-time false locked-vault failures while keeping wrong saved secrets explicit.",
17
+ "`@ouro.bot/cli` and the `ouro.bot` wrapper are version-synced for the vault unlock hardening release."
18
+ ]
19
+ },
4
20
  {
5
21
  "version": "0.1.0-alpha.556",
6
22
  "changes": [
@@ -1453,15 +1453,13 @@ async function executeVaultUnlock(command, deps) {
1453
1453
  const progress = createHumanCommandProgress(deps, "vault unlock");
1454
1454
  let store;
1455
1455
  try {
1456
+ await runCommandProgressPhase(progress, "checking vault access", () => (0, credential_access_1.probeCredentialVaultAccess)(command.agent, unlockSecret, { homeDir: deps.homeDir }), () => "ok");
1456
1457
  store = await runCommandProgressPhase(progress, "saving local unlock", () => (0, vault_unlock_1.storeVaultUnlockSecret)({
1457
1458
  agentName: command.agent,
1458
1459
  email: vault.email,
1459
1460
  serverUrl: vault.serverUrl,
1460
1461
  }, unlockSecret, { homeDir: deps.homeDir, store: command.store }), (saved) => saved.kind);
1461
- await runCommandProgressPhase(progress, "checking vault access", async () => {
1462
- (0, credential_access_1.resetCredentialStore)();
1463
- await (0, credential_access_1.getCredentialStore)(command.agent).get("__ouro_vault_probe__");
1464
- }, () => "ok");
1462
+ (0, credential_access_1.resetCredentialStore)();
1465
1463
  }
1466
1464
  finally {
1467
1465
  progress.end();
@@ -88,7 +88,9 @@ function isBwSessionUnavailableMessage(message) {
88
88
  /local bitwarden session/i.test(message));
89
89
  }
90
90
  function isBwInvalidUnlockSecretMessage(message) {
91
- return /invalid master password/i.test(message) || /saved vault unlock secret/i.test(message);
91
+ return (/invalid master password/i.test(message) ||
92
+ /saved vault unlock secret/i.test(message) ||
93
+ /username or password is incorrect/i.test(message));
92
94
  }
93
95
  function isBwTimeoutError(err) {
94
96
  const timeoutErr = err;
@@ -135,6 +137,13 @@ function isBwConfigLogoutRequired(err) {
135
137
  function isBwAlreadyLoggedInError(err) {
136
138
  return err.message.toLowerCase().includes("already logged in");
137
139
  }
140
+ function isBwLoggedOutOrUnauthenticatedError(err) {
141
+ const message = err.message.toLowerCase();
142
+ return (message.includes("not logged in") ||
143
+ message.includes("not authenticated") ||
144
+ message.includes("unauthenticated") ||
145
+ message.includes("local bitwarden session because it is locked, missing, or expired"));
146
+ }
138
147
  function shouldUseStructuredItemLookup(domain) {
139
148
  return domain.includes("/");
140
149
  }
@@ -392,7 +401,10 @@ class BitwardenCredentialStore {
392
401
  email;
393
402
  masterPassword;
394
403
  appDataDir;
404
+ onInvalidUnlockSecret;
405
+ onLoginSuccess;
395
406
  sessionToken = null;
407
+ terminalLoginError = null;
396
408
  bwBinaryPath = "bw";
397
409
  structuredItemCache = null;
398
410
  constructor(serverUrl, email, masterPassword, options = {}) {
@@ -400,6 +412,8 @@ class BitwardenCredentialStore {
400
412
  this.email = email;
401
413
  this.masterPassword = masterPassword;
402
414
  this.appDataDir = options.appDataDir;
415
+ this.onInvalidUnlockSecret = options.onInvalidUnlockSecret;
416
+ this.onLoginSuccess = options.onLoginSuccess;
403
417
  }
404
418
  isReady() {
405
419
  return true;
@@ -410,12 +424,36 @@ class BitwardenCredentialStore {
410
424
  execBwWithPasswordEnv(args) {
411
425
  return execBw([...args, "--passwordenv", BW_PASSWORD_ENV], undefined, this.appDataDir, undefined, this.bwBinaryPath, { [BW_PASSWORD_ENV]: this.masterPassword });
412
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
+ }
413
448
  /**
414
449
  * Ensure the bw CLI is authenticated and unlocked.
415
450
  * Handles three states: logged out → login, locked → unlock, already unlocked → no-op.
416
451
  * Retries transient failures (network/timeout) up to MAX_RETRIES with exponential backoff.
417
452
  */
418
453
  async login() {
454
+ if (this.terminalLoginError) {
455
+ throw this.terminalLoginError;
456
+ }
419
457
  // Ensure bw CLI is installed before any bw commands
420
458
  this.bwBinaryPath = await (0, bw_installer_1.ensureBwCli)();
421
459
  if (this.appDataDir) {
@@ -425,6 +463,7 @@ class BitwardenCredentialStore {
425
463
  for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
426
464
  try {
427
465
  await this.loginAttempt();
466
+ await this.onLoginSuccess?.();
428
467
  return;
429
468
  }
430
469
  catch (err) {
@@ -432,6 +471,10 @@ class BitwardenCredentialStore {
432
471
  lastError = err instanceof Error ? err : new Error(String(err));
433
472
  // Don't retry non-transient errors (auth failures, bw not installed)
434
473
  if (!isTransientError(lastError)) {
474
+ this.terminalLoginError = lastError;
475
+ if (isBwInvalidUnlockSecretMessage(lastError.message)) {
476
+ await this.notifyInvalidUnlockSecret(lastError);
477
+ }
435
478
  throw lastError;
436
479
  }
437
480
  // Don't retry after final attempt
@@ -447,22 +490,19 @@ class BitwardenCredentialStore {
447
490
  await delay(backoffMs);
448
491
  }
449
492
  }
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
+ }
450
498
  throw lastError;
451
499
  }
452
500
  /** Single login attempt — called by login() retry loop. */
453
501
  async loginAttempt() {
454
- // Check current status
455
- let status = {};
456
- try {
457
- const raw = await this.execBw(["status"]);
458
- status = JSON.parse(raw);
459
- }
460
- catch (err) {
461
- // If bw CLI is not installed or a transient error, propagate it for retry
462
- if (err instanceof Error && (isBwNotInstalled(err) || isTransientError(err))) {
463
- throw err;
464
- }
465
- // CLI not configured or broken — proceed with full setup
502
+ let status = await this.readStatus();
503
+ if (this.shouldRebuildLocalProfile(status)) {
504
+ await this.rebuildLocalProfile("local bw profile did not match requested vault", status);
505
+ status = { status: "unlocked", serverUrl: this.serverUrl, userEmail: this.email };
466
506
  }
467
507
  // Configure server URL if needed (only works when logged out)
468
508
  if (status.status === "unauthenticated" || !status.serverUrl) {
@@ -471,43 +511,28 @@ class BitwardenCredentialStore {
471
511
  }
472
512
  catch (error) {
473
513
  const err = error;
474
- // "Logout required" means already logged in — that's fine, skip config.
475
- if (!isBwConfigLogoutRequired(err)) {
514
+ if (!isBwConfigLogoutRequired(err))
476
515
  throw err;
477
- }
516
+ // "Logout required" means bw already has local auth state; keep the
517
+ // existing behavior and proceed to login/unlock below.
478
518
  }
479
519
  }
480
- if (status.status === "locked") {
481
- // Already logged in, just needs unlock
482
- const unlockOutput = await this.execBwWithPasswordEnv(["unlock", "--raw"]);
483
- this.sessionToken = unlockOutput.trim();
484
- }
485
- else if (status.status === "unauthenticated" || !status.status) {
486
- // Not logged in — full login
487
- let loginOutput;
488
- try {
489
- loginOutput = await this.execBwWithPasswordEnv(["login", this.email, "--raw"]);
490
- }
491
- catch (error) {
492
- const err = error;
493
- if (!isBwAlreadyLoggedInError(err)) {
494
- throw err;
495
- }
496
- loginOutput = await this.execBwWithPasswordEnv(["unlock", "--raw"]);
520
+ if (!this.sessionToken) {
521
+ if (status.status === "locked") {
522
+ // Already logged in, just needs unlock.
523
+ const unlockOutput = await this.execWithLocalProfileRebuild("unlock rejected saved local unlock material", status, () => this.execBwWithPasswordEnv(["unlock", "--raw"]));
524
+ this.sessionToken = unlockOutput.trim();
497
525
  }
498
- try {
499
- const parsed = JSON.parse(loginOutput);
500
- this.sessionToken = parsed.access_token ?? loginOutput.trim();
526
+ else if (status.status === "unauthenticated" || !status.status) {
527
+ // Not logged in -- full login.
528
+ this.sessionToken = this.sessionTokenFromLoginOutput(await this.loginWithPassword());
501
529
  }
502
- catch {
503
- this.sessionToken = loginOutput.trim();
530
+ else {
531
+ // Status is "unlocked" -- already good, just need the session token.
532
+ const unlockOutput = await this.execWithLocalProfileRebuild("unlocked bw profile rejected saved local unlock material", status, () => this.execBwWithPasswordEnv(["unlock", "--raw"]));
533
+ this.sessionToken = unlockOutput.trim();
504
534
  }
505
535
  }
506
- else {
507
- // Status is "unlocked" — already good, just need the session token
508
- const unlockOutput = await this.execBwWithPasswordEnv(["unlock", "--raw"]);
509
- this.sessionToken = unlockOutput.trim();
510
- }
511
536
  if (this.shouldSyncVaultAfterSession(status)) {
512
537
  /* v8 ignore next -- defensive: loginAttempt always sets sessionToken before sync @preserve */
513
538
  await this.execBw(["sync"], this.sessionToken ?? undefined);
@@ -521,6 +546,128 @@ class BitwardenCredentialStore {
521
546
  meta: { email: this.email, serverUrl: this.serverUrl, freshnessWindowMs: BW_SYNC_FRESH_MS },
522
547
  });
523
548
  }
549
+ this.terminalLoginError = null;
550
+ }
551
+ async readStatus() {
552
+ try {
553
+ const raw = await this.execBw(["status"]);
554
+ const parsed = JSON.parse(raw);
555
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
556
+ return {};
557
+ const record = parsed;
558
+ return {
559
+ ...(typeof record.status === "string" ? { status: record.status } : {}),
560
+ ...(typeof record.serverUrl === "string" ? { serverUrl: record.serverUrl } : {}),
561
+ ...(typeof record.userEmail === "string" ? { userEmail: record.userEmail } : {}),
562
+ };
563
+ }
564
+ catch (err) {
565
+ // If bw CLI is not installed or a transient error, propagate it for retry.
566
+ if (err instanceof Error && (isBwNotInstalled(err) || isTransientError(err))) {
567
+ throw err;
568
+ }
569
+ // CLI not configured or broken -- proceed with full setup.
570
+ return {};
571
+ }
572
+ }
573
+ normalizedServerUrl(value) {
574
+ return value.trim().replace(/\/+$/, "").toLowerCase();
575
+ }
576
+ shouldRebuildLocalProfile(status) {
577
+ if (status.status !== "locked" && status.status !== "unlocked")
578
+ return false;
579
+ if (status.serverUrl && this.normalizedServerUrl(status.serverUrl) !== this.normalizedServerUrl(this.serverUrl))
580
+ return true;
581
+ if (status.userEmail && status.userEmail.trim().toLowerCase() !== this.email.trim().toLowerCase())
582
+ return true;
583
+ return false;
584
+ }
585
+ sessionTokenFromLoginOutput(loginOutput) {
586
+ try {
587
+ const parsed = JSON.parse(loginOutput);
588
+ return typeof parsed.access_token === "string" && parsed.access_token.trim()
589
+ ? parsed.access_token.trim()
590
+ : loginOutput.trim();
591
+ }
592
+ catch {
593
+ return loginOutput.trim();
594
+ }
595
+ }
596
+ async loginWithPassword() {
597
+ try {
598
+ return await this.execBwWithPasswordEnv(["login", this.email, "--raw"]);
599
+ }
600
+ catch (error) {
601
+ const err = error;
602
+ if (!isBwAlreadyLoggedInError(err))
603
+ throw err;
604
+ return this.execBwWithPasswordEnv(["unlock", "--raw"]);
605
+ }
606
+ }
607
+ forgetLocalSyncMarker() {
608
+ if (!this.appDataDir)
609
+ return;
610
+ try {
611
+ fs.rmSync(path.join(this.appDataDir, BW_SYNC_MARKER_FILENAME), { force: true });
612
+ }
613
+ catch {
614
+ // A stale sync marker is only a cache hint; failure to remove it should not block auth repair.
615
+ }
616
+ }
617
+ async logoutLocalProfile(reason, status, cause) {
618
+ this.sessionToken = null;
619
+ this.structuredItemCache = null;
620
+ this.forgetLocalSyncMarker();
621
+ (0, runtime_1.emitNervesEvent)({
622
+ level: "warn",
623
+ event: "repertoire.bw_local_profile_rebuild",
624
+ component: "repertoire",
625
+ message: "rebuilding local bw profile for agent vault",
626
+ meta: {
627
+ email: this.email,
628
+ serverUrl: this.serverUrl,
629
+ reason,
630
+ /* v8 ignore next -- defensive: every profile rebuild path starts from a locked/unlocked bw status @preserve */
631
+ previousStatus: status.status ?? "unknown",
632
+ previousServerUrl: status.serverUrl ?? null,
633
+ previousUserEmail: status.userEmail ?? null,
634
+ /* v8 ignore next -- defensive: internal rebuild callers pass Error or undefined causes @preserve */
635
+ error: cause instanceof Error ? cause.message : cause ? String(cause) : undefined,
636
+ },
637
+ });
638
+ try {
639
+ await this.execBw(["logout"]);
640
+ }
641
+ catch (error) {
642
+ const err = error;
643
+ if (isBwLoggedOutOrUnauthenticatedError(err))
644
+ return;
645
+ (0, runtime_1.emitNervesEvent)({
646
+ level: "warn",
647
+ event: "repertoire.bw_local_profile_logout_failed",
648
+ component: "repertoire",
649
+ message: "failed to logout local bw profile before rebuild",
650
+ meta: { email: this.email, serverUrl: this.serverUrl, error: err.message },
651
+ });
652
+ }
653
+ }
654
+ async rebuildLocalProfile(reason, status, cause) {
655
+ await this.logoutLocalProfile(reason, status, cause);
656
+ await this.execBw(["config", "server", this.serverUrl]);
657
+ this.sessionToken = this.sessionTokenFromLoginOutput(await this.loginWithPassword());
658
+ }
659
+ async execWithLocalProfileRebuild(reason, status, operation) {
660
+ try {
661
+ return await operation();
662
+ }
663
+ catch (error) {
664
+ const err = error;
665
+ if (!isBwInvalidUnlockSecretMessage(err.message))
666
+ throw err;
667
+ await this.rebuildLocalProfile(reason, status, err);
668
+ /* v8 ignore next -- defensive: rebuildLocalProfile always stores a string session token on success @preserve */
669
+ return this.sessionToken ?? "";
670
+ }
524
671
  }
525
672
  async ensureSession() {
526
673
  if (!this.sessionToken) {
@@ -44,6 +44,7 @@ var __importStar = (this && this.__importStar) || (function () {
44
44
  })();
45
45
  Object.defineProperty(exports, "__esModule", { value: true });
46
46
  exports.getCredentialStore = getCredentialStore;
47
+ exports.probeCredentialVaultAccess = probeCredentialVaultAccess;
47
48
  exports.resetCredentialStore = resetCredentialStore;
48
49
  const crypto = __importStar(require("node:crypto"));
49
50
  const fs = __importStar(require("node:fs"));
@@ -64,13 +65,13 @@ function loadVaultSectionForAgent(agentName) {
64
65
  return { configPath };
65
66
  }
66
67
  }
67
- function bitwardenAppDataDir(agentName, vaultConfig) {
68
+ function bitwardenAppDataDir(agentName, vaultConfig, homeDir = os.homedir()) {
68
69
  const digest = crypto
69
70
  .createHash("sha256")
70
71
  .update(`${agentName}:${vaultConfig.serverUrl}:${vaultConfig.email}`)
71
72
  .digest("hex")
72
73
  .slice(0, 24);
73
- return path.join(os.homedir(), ".ouro-cli", "bitwarden", digest);
74
+ return path.join(homeDir, ".ouro-cli", "bitwarden", digest);
74
75
  }
75
76
  function getCredentialStore(agentNameInput) {
76
77
  const agentName = agentNameInput ?? identity.getAgentName();
@@ -91,7 +92,63 @@ function getCredentialStore(agentNameInput) {
91
92
  email: vaultConfig.email,
92
93
  serverUrl: vaultConfig.serverUrl,
93
94
  });
94
- 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
+ });
95
152
  stores.set(cacheKey, store);
96
153
  (0, runtime_1.emitNervesEvent)({
97
154
  event: "repertoire.credential_store_init",
@@ -106,6 +163,16 @@ function getCredentialStore(agentNameInput) {
106
163
  });
107
164
  return store;
108
165
  }
166
+ async function probeCredentialVaultAccess(agentNameInput, unlockSecret, options = {}) {
167
+ const agentName = agentNameInput;
168
+ const { configPath, vault } = loadVaultSectionForAgent(agentName);
169
+ if (!vault || typeof vault.email !== "string" || vault.email.trim().length === 0) {
170
+ throw new Error((0, vault_unlock_1.credentialVaultNotConfiguredError)(agentName, configPath));
171
+ }
172
+ const vaultConfig = identity.resolveVaultConfig(agentName, vault);
173
+ const store = new bitwarden_store_1.BitwardenCredentialStore(vaultConfig.serverUrl, vaultConfig.email, unlockSecret, { appDataDir: bitwardenAppDataDir(agentName, vaultConfig, options.homeDir) });
174
+ await store.get("__ouro_vault_probe__");
175
+ }
109
176
  function resetCredentialStore() {
110
177
  stores = new Map();
111
178
  }
@@ -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.556",
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",