Skip to content

Commit fb1c595

Browse files
authored
fix(bitwarden): fix agent lookup and reopen vault after unlocking (#407)
1 parent f4d49b3 commit fb1c595

4 files changed

Lines changed: 163 additions & 11 deletions

File tree

‎extensions/bitwarden/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
],
3737
"scripts": {
3838
"build": "vici build",
39+
"test": "node --test test/*.test.cjs",
3940
"dev": "vici develop",
4041
"lint": "vici lint",
4142
"format": "biome format --write src"

‎extensions/bitwarden/src/browse-vault.tsx‎

Lines changed: 73 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
import { useState, useEffect, useCallback } from "react";
2+
import { execFile } from "node:child_process";
3+
import { promisify } from "node:util";
4+
import { basename, dirname } from "node:path";
5+
26
import {
37
Action,
48
ActionPanel,
@@ -9,6 +13,7 @@ import {
913
Toast,
1014
showToast,
1115
closeMainWindow,
16+
environment,
1217
} from "@vicinae/api";
1318
import {
1419
RbwError,
@@ -24,6 +29,12 @@ import {
2429
runRbw,
2530
} from "./rbw";
2631

32+
const execFileAsync = promisify(execFile);
33+
const vicinaeCliPath =
34+
process.platform === "darwin"
35+
? "/Applications/Vicinae.app/Contents/MacOS/vicinae-cli"
36+
: "vicinae";
37+
2738
type EntryState = { entries: VaultEntry[]; loaded: boolean };
2839

2940
type DetailedEntry = {
@@ -86,23 +97,25 @@ export default function Command() {
8697
if (!unlocked) {
8798
setLocked(true);
8899
setState({ entries: [], loaded: true });
89-
return;
100+
return false;
90101
}
91102

92103
const entries = await listEntries();
93104
entries.sort((a, b) => a.name.localeCompare(b.name));
94105
setState({ entries, loaded: true });
106+
return true;
95107
} catch (error) {
96108
if (error instanceof RbwNotInstalledError) {
97109
setNotInstalled(true);
98110
setState({ entries: [], loaded: true });
99-
return;
111+
return false;
100112
}
101113
setState({ entries: [], loaded: true });
102114
showToast(
103115
Toast.Style.Failure,
104116
error instanceof RbwError ? error.message : "Failed to load entries",
105117
);
118+
return false;
106119
}
107120
}, []);
108121

@@ -114,7 +127,8 @@ export default function Command() {
114127
async (text: string) => {
115128
setSearchText(text);
116129
if (!text.trim()) {
117-
return loadEntries();
130+
await loadEntries();
131+
return;
118132
}
119133
try {
120134
const results = await searchEntries(text);
@@ -159,18 +173,67 @@ export default function Command() {
159173
title="Unlock Vault"
160174
icon={Icon.LockUnlocked}
161175
onAction={async () => {
162-
closeMainWindow();
163176
try {
164-
await runRbw(["unlock"])
165-
setLocked(false);
166-
await loadEntries();
177+
await runRbw(["unlock"], { timeout: 120_000 });
167178
} catch (error) {
168179
showToast(
169180
Toast.Style.Failure,
170181
error instanceof RbwError
171182
? error.message
172183
: "Failed to unlock vault",
173184
);
185+
return;
186+
}
187+
188+
setLocked(false);
189+
if (!(await loadEntries())) return;
190+
191+
const extensionId = basename(dirname(environment.assetsPath));
192+
const commandId = `@${environment.ownerOrAuthorName}/${extensionId}:${environment.commandName}`;
193+
try {
194+
await execFileAsync(vicinaeCliPath, ["version"]);
195+
} catch (error) {
196+
console.error("Vicinae CLI is unavailable", error);
197+
await showToast(
198+
Toast.Style.Failure,
199+
"Vault unlocked, but Vicinae CLI is unavailable",
200+
);
201+
return;
202+
}
203+
204+
try {
205+
try {
206+
await execFileAsync(vicinaeCliPath, [
207+
"deeplink",
208+
"vicinae://close?popToRootType=suspended",
209+
]);
210+
} catch (error) {
211+
const stderr =
212+
typeof error === "object" && error !== null && "stderr" in error
213+
? error.stderr
214+
: undefined;
215+
if (
216+
typeof stderr !== "string" ||
217+
stderr.trim() !== "Failed to execute deeplink: Already closed"
218+
) {
219+
throw error;
220+
}
221+
}
222+
await execFileAsync(vicinaeCliPath, ["cmd", "launch", commandId]);
223+
} catch (error) {
224+
console.error(
225+
"Failed to reopen Bitwarden Vault with vicinae-cli",
226+
error,
227+
);
228+
try {
229+
await execFileAsync(vicinaeCliPath, ["open"]);
230+
} catch (restoreError) {
231+
console.error("Failed to restore Vicinae window", restoreError);
232+
}
233+
await showToast(
234+
Toast.Style.Failure,
235+
"Vault unlocked, but failed to reopen Bitwarden Vault",
236+
);
174237
}
175238
}}
176239
/>
@@ -254,7 +317,7 @@ export default function Command() {
254317
);
255318
}
256319

257-
function SyncAction({ loadEntries }: { loadEntries: () => Promise<void> }) {
320+
function SyncAction({ loadEntries }: { loadEntries: () => Promise<boolean> }) {
258321
return (
259322
<Action
260323
title="Sync Vault"
@@ -277,12 +340,12 @@ function SyncAction({ loadEntries }: { loadEntries: () => Promise<void> }) {
277340
);
278341
}
279342

280-
function RefreshAction({ loadEntries }: { loadEntries: () => Promise<void> }) {
343+
function RefreshAction({ loadEntries }: { loadEntries: () => Promise<boolean> }) {
281344
return (
282345
<Action
283346
title="Refresh"
284347
icon={Icon.RotateClockwise}
285-
onAction={loadEntries}
348+
onAction={async () => { await loadEntries(); }}
286349
/>
287350
);
288351
}

‎extensions/bitwarden/src/rbw.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { getPreferenceValues } from "@vicinae/api";
22
import { execFile } from "node:child_process";
3+
import { delimiter, dirname, isAbsolute } from "node:path";
34
import { promisify } from "node:util";
45

56
interface Preferences {
@@ -41,8 +42,15 @@ export async function runRbw(
4142
options?: { timeout?: number },
4243
): Promise<string> {
4344
const { rbwPath } = getPreferenceValues<Preferences>();
45+
const binary = rbwPath.trim() ?? "rbw";
46+
const env = { ...process.env };
47+
if (isAbsolute(binary)) {
48+
// rbw starts rbw-agent by name; GUI apps may not have this directory in PATH.
49+
env.PATH = [dirname(binary), env.PATH].filter(Boolean).join(delimiter);
50+
}
4451
try {
45-
const { stdout } = await execFileAsync(rbwPath.trim() ?? "rbw", args, {
52+
const { stdout } = await execFileAsync(binary, args, {
53+
env,
4654
maxBuffer: 4 * 1024 * 1024,
4755
encoding: "utf-8",
4856
timeout: options?.timeout ?? 15_000,
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
const assert = require("node:assert/strict");
2+
const { mkdtempSync, readFileSync, rmSync, writeFileSync } = require("node:fs");
3+
const { tmpdir } = require("node:os");
4+
const { join } = require("node:path");
5+
const test = require("node:test");
6+
const vm = require("node:vm");
7+
const ts = require("typescript");
8+
9+
// Exercise runRbw with simulated GUI environment and Vicinae preferences.
10+
// The fixture rbw launches a sibling rbw-agent by name, like the real executable.
11+
const source = ts.transpileModule(
12+
readFileSync(join(__dirname, "../src/rbw.ts"), "utf8"),
13+
{ compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } },
14+
).outputText;
15+
16+
function loadRbw(rbwPath, env) {
17+
const exports = {};
18+
vm.runInNewContext(source, {
19+
exports,
20+
process: { env },
21+
require(name) {
22+
if (name === "@vicinae/api") {
23+
return { getPreferenceValues: () => ({ rbwPath }) };
24+
}
25+
if (name === "node:child_process") {
26+
// A real child inherits the caller's environment; use the simulated
27+
// GUI environment unless runRbw explicitly supplies one.
28+
function execFile(file, args, options, callback) {
29+
return require(name).execFile(file, args, { env, ...options }, callback);
30+
}
31+
const { promisify } = require("node:util");
32+
execFile[promisify.custom] = (file, args, options) =>
33+
promisify(require(name).execFile)(file, args, { env, ...options });
34+
return { execFile };
35+
}
36+
return require(name);
37+
},
38+
});
39+
return exports;
40+
}
41+
42+
function fixture(t) {
43+
const dir = mkdtempSync(join(tmpdir(), "rbw path "));
44+
t.after(() => rmSync(dir, { recursive: true, force: true }));
45+
writeFileSync(join(dir, "rbw"), '#!/bin/sh\nexec rbw-agent "$@"\n', { mode: 0o755 });
46+
writeFileSync(
47+
join(dir, "rbw-agent"),
48+
'#!/bin/sh\nprintf "%s\\n" "$1" "$TEST_MARKER"\n',
49+
{ mode: 0o755 },
50+
);
51+
return dir;
52+
}
53+
54+
const guiPath = "/usr/bin:/bin:/usr/sbin:/sbin";
55+
56+
test("absolute rbw path finds its sibling agent with a macOS GUI PATH", async (t) => {
57+
const dir = fixture(t);
58+
const env = { PATH: guiPath, TEST_MARKER: "preserved" };
59+
const { runRbw } = loadRbw(join(dir, "rbw"), env);
60+
assert.equal(await runRbw(["unlock"]), "unlock\npreserved\n");
61+
assert.equal(env.PATH, guiPath, "do not mutate the extension runtime environment");
62+
});
63+
64+
test("bare rbw command retains the existing PATH", async (t) => {
65+
const dir = fixture(t);
66+
const { runRbw } = loadRbw("rbw", { PATH: `${dir}:${guiPath}`, TEST_MARKER: "bare" });
67+
assert.equal(await runRbw(["unlock"]), "unlock\nbare\n");
68+
});
69+
70+
test("absolute rbw path finds its agent even when PATH is unset", async (t) => {
71+
const dir = fixture(t);
72+
const { runRbw } = loadRbw(join(dir, "rbw"), { TEST_MARKER: "no-path" });
73+
assert.equal(await runRbw(["unlock"]), "unlock\nno-path\n");
74+
});
75+
76+
test("missing rbw still reports the installation error", async (t) => {
77+
const dir = fixture(t);
78+
const { runRbw, RbwNotInstalledError } = loadRbw(join(dir, "missing"), { PATH: guiPath });
79+
await assert.rejects(runRbw(["unlock"]), RbwNotInstalledError);
80+
});

0 commit comments

Comments
 (0)