@ouro.bot/cli 0.1.0-alpha.410 → 0.1.0-alpha.412

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,23 @@
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.412",
6
+ "changes": [
7
+ "Cross-process bw CLI lock prevents concurrent access to the same Bitwarden app data directory, fixing the root cause of intermittent vault timeout errors during `ouro up` provider checks on multi-agent machines.",
8
+ "Two-layer locking in `execBw`: in-process async mutex serializes within a Node.js process, cross-process `O_EXCL` file lock with PID stale detection serializes across processes.",
9
+ "`@ouro.bot/cli` and the `ouro.bot` wrapper are version-synced for the vault concurrency fix release."
10
+ ]
11
+ },
12
+ {
13
+ "version": "0.1.0-alpha.411",
14
+ "changes": [
15
+ "`ouro up` interactive repair now re-evaluates each agent after a successful vault-unlock or provider-auth, so users see immediate confirmation or an updated next step instead of stale context.",
16
+ "Repair prompts now include agent names (`Unlock slugger's vault now?`) instead of generic phrasing.",
17
+ "Terminal `Repair flow complete.` message printed when at least one repair was attempted, giving clear closure to the interactive flow.",
18
+ "Redundant repair queue summary suppressed in the post-daemon-start path where the status block already listed degraded agents."
19
+ ]
20
+ },
4
21
  {
5
22
  "version": "0.1.0-alpha.410",
6
23
  "changes": [
@@ -51,6 +51,7 @@ function makeInteractiveRepairDeps(deps) {
51
51
  /* v8 ignore next -- fallback no-op: tests always inject runAuthFlow; default is for production @preserve */
52
52
  runAuthFlow: deps.runAuthFlow ?? (async () => undefined),
53
53
  runVaultUnlock: deps.runVaultUnlock,
54
+ skipQueueSummary: deps.skipQueueSummary,
54
55
  };
55
56
  }
56
57
  async function runDeterministicRepair(degraded, deps) {
@@ -2677,6 +2677,7 @@ async function runOuroCli(args, deps = (0, cli_defaults_1.createDefaultOuroCliDe
2677
2677
  runVaultUnlock: async (agent) => {
2678
2678
  await executeVaultUnlock({ kind: "vault.unlock", agent }, deps);
2679
2679
  },
2680
+ skipQueueSummary: true,
2680
2681
  });
2681
2682
  if (repairResult.repairsAttempted) {
2682
2683
  const repairedAgents = daemonResult.stability.degraded
@@ -182,6 +182,97 @@ function writeRepairQueueSummary(degraded, deps) {
182
182
  if (lines.length > 0)
183
183
  deps.writeStdout(lines.join("\n"));
184
184
  }
185
+ async function attemptVaultUnlock(entry, action, deps) {
186
+ deps.writeStdout(renderActionPromptLines(entry.agent, action).join("\n"));
187
+ const answer = await deps.promptInput(`Unlock ${entry.agent}'s vault now? [y/N] `);
188
+ if (!isAffirmativeAnswer(answer)) {
189
+ writeDeclinedRepair(entry, action.command, deps);
190
+ return { succeeded: false, attempted: false };
191
+ }
192
+ try {
193
+ if (!deps.runVaultUnlock) {
194
+ deps.writeStdout(renderManualRepairHint(entry.agent, entry.fixHint));
195
+ return { succeeded: false, attempted: false };
196
+ }
197
+ await deps.runVaultUnlock(entry.agent);
198
+ return { succeeded: true, attempted: true };
199
+ }
200
+ catch (error) {
201
+ const msg = error instanceof Error ? error.message : String(error);
202
+ deps.writeStdout(`Vault unlock did not finish for ${entry.agent}.\n ${msg}`);
203
+ (0, runtime_1.emitNervesEvent)({
204
+ level: "error",
205
+ component: "daemon",
206
+ event: "daemon.interactive_repair_vault_unlock_error",
207
+ message: `vault unlock failed for ${entry.agent}`,
208
+ meta: { agent: entry.agent, error: msg },
209
+ });
210
+ return { succeeded: false, attempted: true };
211
+ }
212
+ }
213
+ async function attemptProviderAuth(entry, action, deps) {
214
+ deps.writeStdout(renderActionPromptLines(entry.agent, action).join("\n"));
215
+ const answer = await deps.promptInput(`Open the auth flow for ${entry.agent} now? [y/N] `);
216
+ if (!isAffirmativeAnswer(answer)) {
217
+ writeDeclinedRepair(entry, action.command, deps);
218
+ return { succeeded: false, attempted: false };
219
+ }
220
+ try {
221
+ if (action.provider) {
222
+ await deps.runAuthFlow(entry.agent, action.provider);
223
+ }
224
+ else {
225
+ await deps.runAuthFlow(entry.agent);
226
+ }
227
+ return { succeeded: true, attempted: true };
228
+ }
229
+ catch (error) {
230
+ const msg = error instanceof Error ? error.message : String(error);
231
+ deps.writeStdout(`Auth did not finish for ${entry.agent}.\n ${msg}`);
232
+ (0, runtime_1.emitNervesEvent)({
233
+ level: "error",
234
+ component: "daemon",
235
+ event: "daemon.interactive_repair_auth_error",
236
+ message: `auth flow failed for ${entry.agent}`,
237
+ meta: { agent: entry.agent, error: msg },
238
+ });
239
+ return { succeeded: false, attempted: true };
240
+ }
241
+ }
242
+ async function processEntry(entry, deps) {
243
+ let current = entry;
244
+ while (current) {
245
+ const action = runnableRepairActionFor(current);
246
+ let outcome;
247
+ if (action?.kind === "vault-unlock") {
248
+ outcome = await attemptVaultUnlock(current, action, deps);
249
+ }
250
+ else if (action?.kind === "provider-auth") {
251
+ outcome = await attemptProviderAuth(current, action, deps);
252
+ }
253
+ else if (isConfigError(current)) {
254
+ deps.writeStdout(renderManualRepairHint(current.agent, current.fixHint));
255
+ return { attempted: false };
256
+ }
257
+ else {
258
+ deps.writeStdout(renderUnknownRepair(current.agent, current.errorReason));
259
+ return { attempted: false };
260
+ }
261
+ if (!outcome.succeeded || !deps.recheckAgent) {
262
+ return { attempted: outcome.attempted };
263
+ }
264
+ // Re-evaluate the agent after successful repair
265
+ const recheckResult = await deps.recheckAgent(current.agent);
266
+ if (recheckResult === null) {
267
+ deps.writeStdout(`${current.agent} recovered.`);
268
+ return { attempted: true };
269
+ }
270
+ // Agent still degraded with a new error -- loop to present the new action
271
+ current = recheckResult;
272
+ }
273
+ /* v8 ignore next -- unreachable: loop always returns from within @preserve */
274
+ return { attempted: false };
275
+ }
185
276
  async function runInteractiveRepair(degraded, deps) {
186
277
  (0, runtime_1.emitNervesEvent)({
187
278
  level: "info",
@@ -194,76 +285,16 @@ async function runInteractiveRepair(degraded, deps) {
194
285
  return { repairsAttempted: false };
195
286
  }
196
287
  let repairsAttempted = false;
197
- writeRepairQueueSummary(degraded, deps);
288
+ if (!deps.skipQueueSummary) {
289
+ writeRepairQueueSummary(degraded, deps);
290
+ }
198
291
  for (const entry of degraded) {
199
- const action = runnableRepairActionFor(entry);
200
- if (action?.kind === "vault-unlock") {
201
- deps.writeStdout(renderActionPromptLines(entry.agent, action).join("\n"));
202
- const answer = await deps.promptInput("Unlock it now? [y/N] ");
203
- if (isAffirmativeAnswer(answer)) {
204
- try {
205
- if (!deps.runVaultUnlock) {
206
- deps.writeStdout(renderManualRepairHint(entry.agent, entry.fixHint));
207
- }
208
- else {
209
- await deps.runVaultUnlock(entry.agent);
210
- repairsAttempted = true;
211
- }
212
- }
213
- catch (error) {
214
- const msg = error instanceof Error ? error.message : String(error);
215
- deps.writeStdout(`Vault unlock did not finish for ${entry.agent}.\n ${msg}`);
216
- repairsAttempted = true;
217
- (0, runtime_1.emitNervesEvent)({
218
- level: "error",
219
- component: "daemon",
220
- event: "daemon.interactive_repair_vault_unlock_error",
221
- message: `vault unlock failed for ${entry.agent}`,
222
- meta: { agent: entry.agent, error: msg },
223
- });
224
- }
225
- }
226
- else {
227
- writeDeclinedRepair(entry, action.command, deps);
228
- }
229
- }
230
- else if (action?.kind === "provider-auth") {
231
- deps.writeStdout(renderActionPromptLines(entry.agent, action).join("\n"));
232
- const answer = await deps.promptInput("Open the auth flow now? [y/N] ");
233
- if (isAffirmativeAnswer(answer)) {
234
- try {
235
- if (action.provider) {
236
- await deps.runAuthFlow(entry.agent, action.provider);
237
- }
238
- else {
239
- await deps.runAuthFlow(entry.agent);
240
- }
241
- repairsAttempted = true;
242
- }
243
- catch (error) {
244
- const msg = error instanceof Error ? error.message : String(error);
245
- deps.writeStdout(`Auth did not finish for ${entry.agent}.\n ${msg}`);
246
- repairsAttempted = true;
247
- (0, runtime_1.emitNervesEvent)({
248
- level: "error",
249
- component: "daemon",
250
- event: "daemon.interactive_repair_auth_error",
251
- message: `auth flow failed for ${entry.agent}`,
252
- meta: { agent: entry.agent, error: msg },
253
- });
254
- }
255
- }
256
- else {
257
- writeDeclinedRepair(entry, action.command, deps);
258
- }
259
- }
260
- else if (isConfigError(entry)) {
261
- deps.writeStdout(renderManualRepairHint(entry.agent, entry.fixHint));
262
- }
263
- else {
264
- // Unknown error with no actionable fix hint
265
- deps.writeStdout(renderUnknownRepair(entry.agent, entry.errorReason));
266
- }
292
+ const result = await processEntry(entry, deps);
293
+ if (result.attempted)
294
+ repairsAttempted = true;
295
+ }
296
+ if (repairsAttempted) {
297
+ deps.writeStdout("Repair flow complete.");
267
298
  }
268
299
  (0, runtime_1.emitNervesEvent)({
269
300
  level: "info",
@@ -45,6 +45,7 @@ exports.BitwardenCredentialStore = void 0;
45
45
  exports.sanitizeCredentialErrorDetail = sanitizeCredentialErrorDetail;
46
46
  const node_child_process_1 = require("node:child_process");
47
47
  const fs = __importStar(require("node:fs"));
48
+ const path = __importStar(require("node:path"));
48
49
  const runtime_1 = require("../nerves/runtime");
49
50
  const bw_installer_1 = require("./bw-installer");
50
51
  const MAX_ERROR_DETAIL_LENGTH = 500;
@@ -130,13 +131,118 @@ function isBwConfigLogoutRequired(err) {
130
131
  const message = err.message.toLowerCase();
131
132
  return message.includes("logout") && message.includes("required");
132
133
  }
134
+ // ---------------------------------------------------------------------------
135
+ // Cross-process bw CLI lock
136
+ // ---------------------------------------------------------------------------
137
+ // The bw CLI cannot handle concurrent access to the same app data directory.
138
+ // Two processes (e.g. daemon worker + ouro up CLI) hitting the same dir
139
+ // simultaneously corrupt bw's local state, producing empty/garbled output.
140
+ //
141
+ // We use two layers:
142
+ // 1. In-process async mutex: a Map<string, Promise<void>> keyed by appDataDir
143
+ // serializes calls within a single Node.js process.
144
+ // 2. Cross-process file lock: fs.openSync(lockPath, 'wx') with PID stale
145
+ // detection serializes across processes.
146
+ // ---------------------------------------------------------------------------
147
+ const BW_LOCK_FILENAME = ".ouro-bw.lock";
148
+ const BW_LOCK_TIMEOUT_MS = 30_000;
149
+ const BW_LOCK_POLL_MS = 100;
150
+ /** In-process async mutex keyed by appDataDir. */
151
+ const inProcessLocks = new Map();
152
+ function isPidAlive(pid) {
153
+ try {
154
+ process.kill(pid, 0);
155
+ return true;
156
+ }
157
+ catch {
158
+ return false;
159
+ }
160
+ }
161
+ async function acquireFileLock(lockPath) {
162
+ const content = `${process.pid}\n`;
163
+ const deadline = Date.now() + BW_LOCK_TIMEOUT_MS;
164
+ while (true) {
165
+ try {
166
+ const fd = fs.openSync(lockPath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL);
167
+ fs.writeSync(fd, content);
168
+ fs.closeSync(fd);
169
+ return;
170
+ }
171
+ catch (err) {
172
+ if (err.code !== "EEXIST") {
173
+ throw err;
174
+ }
175
+ // Lock file exists -- check for stale lock
176
+ try {
177
+ const existing = fs.readFileSync(lockPath, "utf8").trim();
178
+ const pid = parseInt(existing, 10);
179
+ if (!isNaN(pid) && !isPidAlive(pid)) {
180
+ // Stale lock -- remove and retry immediately
181
+ try {
182
+ fs.unlinkSync(lockPath);
183
+ }
184
+ catch { /* race with another cleaner is fine */ }
185
+ continue;
186
+ }
187
+ }
188
+ catch { /* v8 ignore next -- race: lock file disappeared between openSync and readFileSync @preserve */
189
+ continue;
190
+ }
191
+ if (Date.now() >= deadline) {
192
+ throw new Error(`bw CLI lock timeout: could not acquire ${lockPath} within ${BW_LOCK_TIMEOUT_MS}ms`);
193
+ }
194
+ // Yield to the event loop before retrying
195
+ await delay(BW_LOCK_POLL_MS);
196
+ }
197
+ }
198
+ }
199
+ function releaseFileLock(lockPath) {
200
+ try {
201
+ fs.unlinkSync(lockPath);
202
+ }
203
+ catch {
204
+ // Already removed or never created -- safe to ignore
205
+ }
206
+ }
207
+ async function withBwLock(appDataDir, fn) {
208
+ if (!appDataDir) {
209
+ // No appDataDir means the default bw data location. Still need in-process
210
+ // serialization but cannot do cross-process file lock without a dir.
211
+ return fn();
212
+ }
213
+ const lockKey = appDataDir;
214
+ const lockPath = path.join(appDataDir, BW_LOCK_FILENAME);
215
+ // In-process serialization: chain onto the previous promise for this key
216
+ const previous = inProcessLocks.get(lockKey) ?? Promise.resolve();
217
+ let releaseLock;
218
+ const current = new Promise((resolve) => { releaseLock = resolve; });
219
+ inProcessLocks.set(lockKey, current);
220
+ await previous;
221
+ let fileLockAcquired = false;
222
+ try {
223
+ // Cross-process file lock
224
+ await acquireFileLock(lockPath);
225
+ fileLockAcquired = true;
226
+ return await fn();
227
+ }
228
+ finally {
229
+ if (fileLockAcquired) {
230
+ releaseFileLock(lockPath);
231
+ }
232
+ releaseLock();
233
+ // Clean up the map entry if we are the latest
234
+ if (inProcessLocks.get(lockKey) === current) {
235
+ inProcessLocks.delete(lockKey);
236
+ }
237
+ }
238
+ }
133
239
  function execBw(args, sessionToken, appDataDir, stdin) {
134
240
  const env = {
135
241
  ...process.env,
136
242
  ...(sessionToken ? { BW_SESSION: sessionToken } : {}),
137
243
  ...(appDataDir ? { BITWARDENCLI_APPDATA_DIR: appDataDir } : {}),
138
244
  };
139
- return new Promise((resolve, reject) => {
245
+ const runCommand = () => new Promise((resolve, reject) => {
140
246
  const child = (0, node_child_process_1.execFile)("bw", args, { timeout: 30_000, env }, (err, stdout, stderr) => {
141
247
  if (err) {
142
248
  if (isBwNotInstalled(err)) {
@@ -152,6 +258,7 @@ function execBw(args, sessionToken, appDataDir, stdin) {
152
258
  child?.stdin?.end(stdin);
153
259
  }
154
260
  });
261
+ return withBwLock(appDataDir, runCommand);
155
262
  }
156
263
  /** Check if the error indicates the bw CLI binary is not installed. */
157
264
  function isBwNotInstalled(err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.410",
3
+ "version": "0.1.0-alpha.412",
4
4
  "main": "dist/heart/daemon/ouro-entry.js",
5
5
  "bin": {
6
6
  "cli": "dist/heart/daemon/ouro-bot-entry.js",