@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
|
-
|
|
288
|
+
if (!deps.skipQueueSummary) {
|
|
289
|
+
writeRepairQueueSummary(degraded, deps);
|
|
290
|
+
}
|
|
198
291
|
for (const entry of degraded) {
|
|
199
|
-
const
|
|
200
|
-
if (
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
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) {
|