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

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,20 @@
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.557",
6
+ "changes": [
7
+ "`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.",
8
+ "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.",
9
+ "`@ouro.bot/cli` and the `ouro.bot` wrapper are version-synced for the vault unlock hardening release."
10
+ ]
11
+ },
12
+ {
13
+ "version": "0.1.0-alpha.556",
14
+ "changes": [
15
+ "`ouro up` runtime replacement now captures the daemon boot timestamp before starting the replacement process, so a fast healthy daemon is not rejected for publishing health before the monitor begins polling."
16
+ ]
17
+ },
4
18
  {
5
19
  "version": "0.1.0-alpha.555",
6
20
  "changes": [
@@ -889,9 +889,10 @@ async function ensureDaemonRunning(deps, options = {}) {
889
889
  startDaemonProcess: deps.startDaemonProcess,
890
890
  checkSocketAlive: deps.checkSocketAlive,
891
891
  onProgress: deps.reportDaemonStartupPhase,
892
- waitForDaemonStartup: async ({ pid }) => {
892
+ now: deps.now,
893
+ waitForDaemonStartup: async ({ pid, bootStartedAtMs }) => {
893
894
  const startupFailure = await waitForDaemonStartup(deps, {
894
- bootStartedAtMs: (deps.now ?? Date.now)(),
895
+ bootStartedAtMs,
895
896
  pid,
896
897
  serviceLabel: "replacement background service",
897
898
  readLatestDaemonEvent: readLatestDaemonStartupEvent,
@@ -1452,15 +1453,13 @@ async function executeVaultUnlock(command, deps) {
1452
1453
  const progress = createHumanCommandProgress(deps, "vault unlock");
1453
1454
  let store;
1454
1455
  try {
1456
+ await runCommandProgressPhase(progress, "checking vault access", () => (0, credential_access_1.probeCredentialVaultAccess)(command.agent, unlockSecret, { homeDir: deps.homeDir }), () => "ok");
1455
1457
  store = await runCommandProgressPhase(progress, "saving local unlock", () => (0, vault_unlock_1.storeVaultUnlockSecret)({
1456
1458
  agentName: command.agent,
1457
1459
  email: vault.email,
1458
1460
  serverUrl: vault.serverUrl,
1459
1461
  }, unlockSecret, { homeDir: deps.homeDir, store: command.store }), (saved) => saved.kind);
1460
- await runCommandProgressPhase(progress, "checking vault access", async () => {
1461
- (0, credential_access_1.resetCredentialStore)();
1462
- await (0, credential_access_1.getCredentialStore)(command.agent).get("__ouro_vault_probe__");
1463
- }, () => "ok");
1462
+ (0, credential_access_1.resetCredentialStore)();
1464
1463
  }
1465
1464
  finally {
1466
1465
  progress.end();
@@ -164,10 +164,11 @@ async function ensureCurrentDaemonRuntime(deps) {
164
164
  }
165
165
  deps.cleanupStaleSocket(deps.socketPath);
166
166
  deps.onProgress?.("starting the replacement background service");
167
+ const bootStartedAtMs = (deps.now ?? Date.now)();
167
168
  const started = await deps.startDaemonProcess(deps.socketPath);
168
169
  const pid = started.pid ?? "unknown";
169
170
  const startupCheck = deps.waitForDaemonStartup
170
- ? await deps.waitForDaemonStartup({ pid: started.pid ?? null })
171
+ ? await deps.waitForDaemonStartup({ pid: started.pid ?? null, bootStartedAtMs })
171
172
  : { ok: await verifyDaemonStarted(deps) };
172
173
  const verified = startupCheck.ok;
173
174
  /* v8 ignore next -- daemon liveness failure: requires real daemon crash timing @preserve */
@@ -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
  }
@@ -393,6 +402,7 @@ class BitwardenCredentialStore {
393
402
  masterPassword;
394
403
  appDataDir;
395
404
  sessionToken = null;
405
+ terminalLoginError = null;
396
406
  bwBinaryPath = "bw";
397
407
  structuredItemCache = null;
398
408
  constructor(serverUrl, email, masterPassword, options = {}) {
@@ -416,6 +426,9 @@ class BitwardenCredentialStore {
416
426
  * Retries transient failures (network/timeout) up to MAX_RETRIES with exponential backoff.
417
427
  */
418
428
  async login() {
429
+ if (this.terminalLoginError) {
430
+ throw this.terminalLoginError;
431
+ }
419
432
  // Ensure bw CLI is installed before any bw commands
420
433
  this.bwBinaryPath = await (0, bw_installer_1.ensureBwCli)();
421
434
  if (this.appDataDir) {
@@ -432,6 +445,7 @@ class BitwardenCredentialStore {
432
445
  lastError = err instanceof Error ? err : new Error(String(err));
433
446
  // Don't retry non-transient errors (auth failures, bw not installed)
434
447
  if (!isTransientError(lastError)) {
448
+ this.terminalLoginError = lastError;
435
449
  throw lastError;
436
450
  }
437
451
  // Don't retry after final attempt
@@ -447,22 +461,15 @@ class BitwardenCredentialStore {
447
461
  await delay(backoffMs);
448
462
  }
449
463
  }
464
+ this.terminalLoginError = lastError;
450
465
  throw lastError;
451
466
  }
452
467
  /** Single login attempt — called by login() retry loop. */
453
468
  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
469
+ let status = await this.readStatus();
470
+ if (this.shouldRebuildLocalProfile(status)) {
471
+ await this.rebuildLocalProfile("local bw profile did not match requested vault", status);
472
+ status = { status: "unlocked", serverUrl: this.serverUrl, userEmail: this.email };
466
473
  }
467
474
  // Configure server URL if needed (only works when logged out)
468
475
  if (status.status === "unauthenticated" || !status.serverUrl) {
@@ -471,43 +478,28 @@ class BitwardenCredentialStore {
471
478
  }
472
479
  catch (error) {
473
480
  const err = error;
474
- // "Logout required" means already logged in — that's fine, skip config.
475
- if (!isBwConfigLogoutRequired(err)) {
481
+ if (!isBwConfigLogoutRequired(err))
476
482
  throw err;
477
- }
483
+ // "Logout required" means bw already has local auth state; keep the
484
+ // existing behavior and proceed to login/unlock below.
478
485
  }
479
486
  }
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"]);
487
+ if (!this.sessionToken) {
488
+ if (status.status === "locked") {
489
+ // Already logged in, just needs unlock.
490
+ const unlockOutput = await this.execWithLocalProfileRebuild("unlock rejected saved local unlock material", status, () => this.execBwWithPasswordEnv(["unlock", "--raw"]));
491
+ this.sessionToken = unlockOutput.trim();
497
492
  }
498
- try {
499
- const parsed = JSON.parse(loginOutput);
500
- this.sessionToken = parsed.access_token ?? loginOutput.trim();
493
+ else if (status.status === "unauthenticated" || !status.status) {
494
+ // Not logged in -- full login.
495
+ this.sessionToken = this.sessionTokenFromLoginOutput(await this.loginWithPassword());
501
496
  }
502
- catch {
503
- this.sessionToken = loginOutput.trim();
497
+ else {
498
+ // Status is "unlocked" -- already good, just need the session token.
499
+ const unlockOutput = await this.execWithLocalProfileRebuild("unlocked bw profile rejected saved local unlock material", status, () => this.execBwWithPasswordEnv(["unlock", "--raw"]));
500
+ this.sessionToken = unlockOutput.trim();
504
501
  }
505
502
  }
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
503
  if (this.shouldSyncVaultAfterSession(status)) {
512
504
  /* v8 ignore next -- defensive: loginAttempt always sets sessionToken before sync @preserve */
513
505
  await this.execBw(["sync"], this.sessionToken ?? undefined);
@@ -521,6 +513,128 @@ class BitwardenCredentialStore {
521
513
  meta: { email: this.email, serverUrl: this.serverUrl, freshnessWindowMs: BW_SYNC_FRESH_MS },
522
514
  });
523
515
  }
516
+ this.terminalLoginError = null;
517
+ }
518
+ async readStatus() {
519
+ try {
520
+ const raw = await this.execBw(["status"]);
521
+ const parsed = JSON.parse(raw);
522
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
523
+ return {};
524
+ const record = parsed;
525
+ return {
526
+ ...(typeof record.status === "string" ? { status: record.status } : {}),
527
+ ...(typeof record.serverUrl === "string" ? { serverUrl: record.serverUrl } : {}),
528
+ ...(typeof record.userEmail === "string" ? { userEmail: record.userEmail } : {}),
529
+ };
530
+ }
531
+ catch (err) {
532
+ // If bw CLI is not installed or a transient error, propagate it for retry.
533
+ if (err instanceof Error && (isBwNotInstalled(err) || isTransientError(err))) {
534
+ throw err;
535
+ }
536
+ // CLI not configured or broken -- proceed with full setup.
537
+ return {};
538
+ }
539
+ }
540
+ normalizedServerUrl(value) {
541
+ return value.trim().replace(/\/+$/, "").toLowerCase();
542
+ }
543
+ shouldRebuildLocalProfile(status) {
544
+ if (status.status !== "locked" && status.status !== "unlocked")
545
+ return false;
546
+ if (status.serverUrl && this.normalizedServerUrl(status.serverUrl) !== this.normalizedServerUrl(this.serverUrl))
547
+ return true;
548
+ if (status.userEmail && status.userEmail.trim().toLowerCase() !== this.email.trim().toLowerCase())
549
+ return true;
550
+ return false;
551
+ }
552
+ sessionTokenFromLoginOutput(loginOutput) {
553
+ try {
554
+ const parsed = JSON.parse(loginOutput);
555
+ return typeof parsed.access_token === "string" && parsed.access_token.trim()
556
+ ? parsed.access_token.trim()
557
+ : loginOutput.trim();
558
+ }
559
+ catch {
560
+ return loginOutput.trim();
561
+ }
562
+ }
563
+ async loginWithPassword() {
564
+ try {
565
+ return await this.execBwWithPasswordEnv(["login", this.email, "--raw"]);
566
+ }
567
+ catch (error) {
568
+ const err = error;
569
+ if (!isBwAlreadyLoggedInError(err))
570
+ throw err;
571
+ return this.execBwWithPasswordEnv(["unlock", "--raw"]);
572
+ }
573
+ }
574
+ forgetLocalSyncMarker() {
575
+ if (!this.appDataDir)
576
+ return;
577
+ try {
578
+ fs.rmSync(path.join(this.appDataDir, BW_SYNC_MARKER_FILENAME), { force: true });
579
+ }
580
+ catch {
581
+ // A stale sync marker is only a cache hint; failure to remove it should not block auth repair.
582
+ }
583
+ }
584
+ async logoutLocalProfile(reason, status, cause) {
585
+ this.sessionToken = null;
586
+ this.structuredItemCache = null;
587
+ this.forgetLocalSyncMarker();
588
+ (0, runtime_1.emitNervesEvent)({
589
+ level: "warn",
590
+ event: "repertoire.bw_local_profile_rebuild",
591
+ component: "repertoire",
592
+ message: "rebuilding local bw profile for agent vault",
593
+ meta: {
594
+ email: this.email,
595
+ serverUrl: this.serverUrl,
596
+ reason,
597
+ /* v8 ignore next -- defensive: every profile rebuild path starts from a locked/unlocked bw status @preserve */
598
+ previousStatus: status.status ?? "unknown",
599
+ previousServerUrl: status.serverUrl ?? null,
600
+ previousUserEmail: status.userEmail ?? null,
601
+ /* v8 ignore next -- defensive: internal rebuild callers pass Error or undefined causes @preserve */
602
+ error: cause instanceof Error ? cause.message : cause ? String(cause) : undefined,
603
+ },
604
+ });
605
+ try {
606
+ await this.execBw(["logout"]);
607
+ }
608
+ catch (error) {
609
+ const err = error;
610
+ if (isBwLoggedOutOrUnauthenticatedError(err))
611
+ return;
612
+ (0, runtime_1.emitNervesEvent)({
613
+ level: "warn",
614
+ event: "repertoire.bw_local_profile_logout_failed",
615
+ component: "repertoire",
616
+ message: "failed to logout local bw profile before rebuild",
617
+ meta: { email: this.email, serverUrl: this.serverUrl, error: err.message },
618
+ });
619
+ }
620
+ }
621
+ async rebuildLocalProfile(reason, status, cause) {
622
+ await this.logoutLocalProfile(reason, status, cause);
623
+ await this.execBw(["config", "server", this.serverUrl]);
624
+ this.sessionToken = this.sessionTokenFromLoginOutput(await this.loginWithPassword());
625
+ }
626
+ async execWithLocalProfileRebuild(reason, status, operation) {
627
+ try {
628
+ return await operation();
629
+ }
630
+ catch (error) {
631
+ const err = error;
632
+ if (!isBwInvalidUnlockSecretMessage(err.message))
633
+ throw err;
634
+ await this.rebuildLocalProfile(reason, status, err);
635
+ /* v8 ignore next -- defensive: rebuildLocalProfile always stores a string session token on success @preserve */
636
+ return this.sessionToken ?? "";
637
+ }
524
638
  }
525
639
  async ensureSession() {
526
640
  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();
@@ -106,6 +107,16 @@ function getCredentialStore(agentNameInput) {
106
107
  });
107
108
  return store;
108
109
  }
110
+ async function probeCredentialVaultAccess(agentNameInput, unlockSecret, options = {}) {
111
+ const agentName = agentNameInput;
112
+ const { configPath, vault } = loadVaultSectionForAgent(agentName);
113
+ if (!vault || typeof vault.email !== "string" || vault.email.trim().length === 0) {
114
+ throw new Error((0, vault_unlock_1.credentialVaultNotConfiguredError)(agentName, configPath));
115
+ }
116
+ const vaultConfig = identity.resolveVaultConfig(agentName, vault);
117
+ const store = new bitwarden_store_1.BitwardenCredentialStore(vaultConfig.serverUrl, vaultConfig.email, unlockSecret, { appDataDir: bitwardenAppDataDir(agentName, vaultConfig, options.homeDir) });
118
+ await store.get("__ouro_vault_probe__");
119
+ }
109
120
  function resetCredentialStore() {
110
121
  stores = new Map();
111
122
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.555",
3
+ "version": "0.1.0-alpha.557",
4
4
  "main": "dist/heart/daemon/ouro-entry.js",
5
5
  "bin": {
6
6
  "cli": "dist/heart/daemon/ouro-bot-entry.js",