From 928154e777a6e35af406592aebd00c9752e05833 Mon Sep 17 00:00:00 2001 From: Daniel Vieites Date: Thu, 24 Sep 2026 12:17:24 +0200 Subject: [PATCH 1/2] fix(bitwarden): reopen vault after unlocking --- extensions/bitwarden/src/browse-vault.tsx | 83 ++++++++++++++++++++--- 1 file changed, 73 insertions(+), 10 deletions(-) diff --git a/extensions/bitwarden/src/browse-vault.tsx b/extensions/bitwarden/src/browse-vault.tsx index 241d13b21..5d494d185 100644 --- a/extensions/bitwarden/src/browse-vault.tsx +++ b/extensions/bitwarden/src/browse-vault.tsx @@ -1,4 +1,8 @@ import { useState, useEffect, useCallback } from "react"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { basename, dirname } from "node:path"; + import { Action, ActionPanel, @@ -9,6 +13,7 @@ import { Toast, showToast, closeMainWindow, + environment, } from "@vicinae/api"; import { RbwError, @@ -24,6 +29,12 @@ import { runRbw, } from "./rbw"; +const execFileAsync = promisify(execFile); +const vicinaeCliPath = + process.platform === "darwin" + ? "/Applications/Vicinae.app/Contents/MacOS/vicinae-cli" + : "vicinae"; + type EntryState = { entries: VaultEntry[]; loaded: boolean }; type DetailedEntry = { @@ -86,23 +97,25 @@ export default function Command() { if (!unlocked) { setLocked(true); setState({ entries: [], loaded: true }); - return; + return false; } const entries = await listEntries(); entries.sort((a, b) => a.name.localeCompare(b.name)); setState({ entries, loaded: true }); + return true; } catch (error) { if (error instanceof RbwNotInstalledError) { setNotInstalled(true); setState({ entries: [], loaded: true }); - return; + return false; } setState({ entries: [], loaded: true }); showToast( Toast.Style.Failure, error instanceof RbwError ? error.message : "Failed to load entries", ); + return false; } }, []); @@ -114,7 +127,8 @@ export default function Command() { async (text: string) => { setSearchText(text); if (!text.trim()) { - return loadEntries(); + await loadEntries(); + return; } try { const results = await searchEntries(text); @@ -159,11 +173,8 @@ export default function Command() { title="Unlock Vault" icon={Icon.LockUnlocked} onAction={async () => { - closeMainWindow(); try { - await runRbw(["unlock"]) - setLocked(false); - await loadEntries(); + await runRbw(["unlock"], { timeout: 120_000 }); } catch (error) { showToast( Toast.Style.Failure, @@ -171,6 +182,58 @@ export default function Command() { ? error.message : "Failed to unlock vault", ); + return; + } + + setLocked(false); + if (!(await loadEntries())) return; + + const extensionId = basename(dirname(environment.assetsPath)); + const commandId = `@${environment.ownerOrAuthorName}/${extensionId}:${environment.commandName}`; + try { + await execFileAsync(vicinaeCliPath, ["version"]); + } catch (error) { + console.error("Vicinae CLI is unavailable", error); + await showToast( + Toast.Style.Failure, + "Vault unlocked, but Vicinae CLI is unavailable", + ); + return; + } + + try { + try { + await execFileAsync(vicinaeCliPath, [ + "deeplink", + "vicinae://close?popToRootType=suspended", + ]); + } catch (error) { + const stderr = + typeof error === "object" && error !== null && "stderr" in error + ? error.stderr + : undefined; + if ( + typeof stderr !== "string" || + stderr.trim() !== "Failed to execute deeplink: Already closed" + ) { + throw error; + } + } + await execFileAsync(vicinaeCliPath, ["cmd", "launch", commandId]); + } catch (error) { + console.error( + "Failed to reopen Bitwarden Vault with vicinae-cli", + error, + ); + try { + await execFileAsync(vicinaeCliPath, ["open"]); + } catch (restoreError) { + console.error("Failed to restore Vicinae window", restoreError); + } + await showToast( + Toast.Style.Failure, + "Vault unlocked, but failed to reopen Bitwarden Vault", + ); } }} /> @@ -254,7 +317,7 @@ export default function Command() { ); } -function SyncAction({ loadEntries }: { loadEntries: () => Promise }) { +function SyncAction({ loadEntries }: { loadEntries: () => Promise }) { return ( Promise }) { ); } -function RefreshAction({ loadEntries }: { loadEntries: () => Promise }) { +function RefreshAction({ loadEntries }: { loadEntries: () => Promise }) { return ( { await loadEntries(); }} /> ); } From 3382ac866ad9aa9107c59a349cc3dfa6d5d83b1a Mon Sep 17 00:00:00 2001 From: Daniel Vieites Date: Tue, 6 Oct 2026 11:18:00 +0200 Subject: [PATCH 2/2] fix(bitwarden): find rbw-agent in the configured binary directory --- extensions/bitwarden/package.json | 1 + extensions/bitwarden/src/rbw.ts | 10 +++- extensions/bitwarden/test/rbw.test.cjs | 80 ++++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 extensions/bitwarden/test/rbw.test.cjs diff --git a/extensions/bitwarden/package.json b/extensions/bitwarden/package.json index c111c5028..891501204 100644 --- a/extensions/bitwarden/package.json +++ b/extensions/bitwarden/package.json @@ -36,6 +36,7 @@ ], "scripts": { "build": "vici build", + "test": "node --test test/*.test.cjs", "dev": "vici develop", "lint": "vici lint", "format": "biome format --write src" diff --git a/extensions/bitwarden/src/rbw.ts b/extensions/bitwarden/src/rbw.ts index 0f79a4147..93160e742 100644 --- a/extensions/bitwarden/src/rbw.ts +++ b/extensions/bitwarden/src/rbw.ts @@ -1,5 +1,6 @@ import { getPreferenceValues } from "@vicinae/api"; import { execFile } from "node:child_process"; +import { delimiter, dirname, isAbsolute } from "node:path"; import { promisify } from "node:util"; interface Preferences { @@ -41,8 +42,15 @@ export async function runRbw( options?: { timeout?: number }, ): Promise { const { rbwPath } = getPreferenceValues(); + const binary = rbwPath.trim() ?? "rbw"; + const env = { ...process.env }; + if (isAbsolute(binary)) { + // rbw starts rbw-agent by name; GUI apps may not have this directory in PATH. + env.PATH = [dirname(binary), env.PATH].filter(Boolean).join(delimiter); + } try { - const { stdout } = await execFileAsync(rbwPath.trim() ?? "rbw", args, { + const { stdout } = await execFileAsync(binary, args, { + env, maxBuffer: 4 * 1024 * 1024, encoding: "utf-8", timeout: options?.timeout ?? 15_000, diff --git a/extensions/bitwarden/test/rbw.test.cjs b/extensions/bitwarden/test/rbw.test.cjs new file mode 100644 index 000000000..b7b300b14 --- /dev/null +++ b/extensions/bitwarden/test/rbw.test.cjs @@ -0,0 +1,80 @@ +const assert = require("node:assert/strict"); +const { mkdtempSync, readFileSync, rmSync, writeFileSync } = require("node:fs"); +const { tmpdir } = require("node:os"); +const { join } = require("node:path"); +const test = require("node:test"); +const vm = require("node:vm"); +const ts = require("typescript"); + +// Exercise runRbw with simulated GUI environment and Vicinae preferences. +// The fixture rbw launches a sibling rbw-agent by name, like the real executable. +const source = ts.transpileModule( + readFileSync(join(__dirname, "../src/rbw.ts"), "utf8"), + { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } }, +).outputText; + +function loadRbw(rbwPath, env) { + const exports = {}; + vm.runInNewContext(source, { + exports, + process: { env }, + require(name) { + if (name === "@vicinae/api") { + return { getPreferenceValues: () => ({ rbwPath }) }; + } + if (name === "node:child_process") { + // A real child inherits the caller's environment; use the simulated + // GUI environment unless runRbw explicitly supplies one. + function execFile(file, args, options, callback) { + return require(name).execFile(file, args, { env, ...options }, callback); + } + const { promisify } = require("node:util"); + execFile[promisify.custom] = (file, args, options) => + promisify(require(name).execFile)(file, args, { env, ...options }); + return { execFile }; + } + return require(name); + }, + }); + return exports; +} + +function fixture(t) { + const dir = mkdtempSync(join(tmpdir(), "rbw path ")); + t.after(() => rmSync(dir, { recursive: true, force: true })); + writeFileSync(join(dir, "rbw"), '#!/bin/sh\nexec rbw-agent "$@"\n', { mode: 0o755 }); + writeFileSync( + join(dir, "rbw-agent"), + '#!/bin/sh\nprintf "%s\\n" "$1" "$TEST_MARKER"\n', + { mode: 0o755 }, + ); + return dir; +} + +const guiPath = "/usr/bin:/bin:/usr/sbin:/sbin"; + +test("absolute rbw path finds its sibling agent with a macOS GUI PATH", async (t) => { + const dir = fixture(t); + const env = { PATH: guiPath, TEST_MARKER: "preserved" }; + const { runRbw } = loadRbw(join(dir, "rbw"), env); + assert.equal(await runRbw(["unlock"]), "unlock\npreserved\n"); + assert.equal(env.PATH, guiPath, "do not mutate the extension runtime environment"); +}); + +test("bare rbw command retains the existing PATH", async (t) => { + const dir = fixture(t); + const { runRbw } = loadRbw("rbw", { PATH: `${dir}:${guiPath}`, TEST_MARKER: "bare" }); + assert.equal(await runRbw(["unlock"]), "unlock\nbare\n"); +}); + +test("absolute rbw path finds its agent even when PATH is unset", async (t) => { + const dir = fixture(t); + const { runRbw } = loadRbw(join(dir, "rbw"), { TEST_MARKER: "no-path" }); + assert.equal(await runRbw(["unlock"]), "unlock\nno-path\n"); +}); + +test("missing rbw still reports the installation error", async (t) => { + const dir = fixture(t); + const { runRbw, RbwNotInstalledError } = loadRbw(join(dir, "missing"), { PATH: guiPath }); + await assert.rejects(runRbw(["unlock"]), RbwNotInstalledError); +});