bwx 0.6.0 → 0.6.1

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/README.md CHANGED
@@ -17,7 +17,7 @@ Bitwarden CLI wrapper for AI agents (`bwx` = `bw extended`). Auto-unlock, sessio
17
17
  The native `bw` CLI requires interactive prompts and manual session management. `bwx` wraps it with:
18
18
 
19
19
  - **Auto-unlock** — first vault command authenticates automatically via macOS Keychain
20
- - **Session caching** — token written atomically as mode 600 to `~/.config/bwx/session`, passed to `bw` through `BW_SESSION` so it never appears in a process command line
20
+ - **Session caching** — token written atomically as mode 600 to `~/.config/bwx/session`; concurrent callers share one bounded login/unlock attempt, and the token reaches `bw` through `BW_SESSION` rather than a process command line
21
21
  - **Structured output** — `--json` and `--plain` flags for machine-parseable output
22
22
  - **Leak-resistant** — listings never print secrets, `bwx run` hands them to a child process instead of stdout
23
23
  - **No interaction** — credentials from config + Keychain, never prompts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bwx",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "type": "module",
5
5
  "keywords": [
6
6
  "bitwarden",
package/src/bw/session.ts CHANGED
@@ -2,9 +2,16 @@ import { unlinkSync } from "node:fs";
2
2
  import { CliError, ExitCode } from "../cli/errors.ts";
3
3
  import type { GlobalOptions } from "../cli/types.ts";
4
4
  import { emitLog, emitWarn } from "../cli/io.ts";
5
- import { SESSION_FILE } from "../config/paths.ts";
5
+ import { SESSION_FILE, SESSION_LOCK_DIR } from "../config/paths.ts";
6
+ import { withProcessLock } from "../config/process-lock.ts";
6
7
  import { readPrivateFile, writePrivateFile } from "../config/secure-file.ts";
7
- import { runBw, runBwOrThrow, setSession, getSession } from "./runner.ts";
8
+ import {
9
+ DEFAULT_TIMEOUT_MS,
10
+ runBw,
11
+ runBwOrThrow,
12
+ setSession,
13
+ getSession,
14
+ } from "./runner.ts";
8
15
  import { checkVaultFreshness } from "./freshness.ts";
9
16
  import { getStatus } from "./status.ts";
10
17
  import { BwStatusSchema } from "./types.ts";
@@ -96,13 +103,36 @@ export async function probeSessionState(
96
103
  }
97
104
 
98
105
  export async function ensureUnlocked(opts: GlobalOptions): Promise<void> {
99
- // Try cached session first
106
+ // Keep the ordinary valid-session path entirely outside the process lock.
100
107
  const cached = getSession() || loadCachedSession(opts);
101
108
  if (cached) {
102
109
  setSession(cached);
103
110
  if (await sessionWorks(cached)) return;
111
+ setSession(null);
104
112
  }
105
113
 
114
+ await withProcessLock(
115
+ {
116
+ path: SESSION_LOCK_DIR,
117
+ label: "Bitwarden session establishment",
118
+ waitMs: sessionLockWaitMs(opts),
119
+ },
120
+ async () => {
121
+ // A process that waited must use the winner's disk cache, not the stale
122
+ // token it may still have held in memory before entering the lock.
123
+ const winner = loadCachedSession(opts);
124
+ if (winner) {
125
+ setSession(winner);
126
+ if (await sessionWorks(winner)) return;
127
+ setSession(null);
128
+ }
129
+
130
+ await establishSession(opts);
131
+ },
132
+ );
133
+ }
134
+
135
+ async function establishSession(opts: GlobalOptions): Promise<void> {
106
136
  const status = await getStatus();
107
137
 
108
138
  if (status.status === "unlocked") {
@@ -152,6 +182,13 @@ export async function ensureUnlocked(opts: GlobalOptions): Promise<void> {
152
182
  saveCachedSession(token);
153
183
  }
154
184
 
185
+ /** Four sequential `bw` calls cover stale validation plus status/login/unlock. */
186
+ function sessionLockWaitMs(opts: GlobalOptions): number {
187
+ const perCall =
188
+ opts.timeoutMs && opts.timeoutMs > 0 ? opts.timeoutMs : DEFAULT_TIMEOUT_MS;
189
+ return Math.min(Math.max(perCall * 4 + 10_000, 10_000), 5 * 60_000);
190
+ }
191
+
155
192
  export async function lockVault(): Promise<void> {
156
193
  await runBw(["lock"], { session: null });
157
194
  setSession(null);
@@ -197,9 +234,9 @@ async function withAuthRetry<T>(
197
234
  return await fn();
198
235
  } catch (err) {
199
236
  if (err instanceof CliError && err.exitCode === ExitCode.AuthFailed) {
200
- // Clear stale session and re-auth
237
+ // Do not unlink the shared cache here: another process may already have
238
+ // atomically replaced our stale token with a freshly unlocked session.
201
239
  setSession(null);
202
- clearCachedSession();
203
240
  await ensureUnlocked(opts);
204
241
  return await fn();
205
242
  }
@@ -8,5 +8,7 @@ export const CONFIG_DIR = `${CONFIG_HOME}/bwx`;
8
8
  export const CONFIG_FILE = `${CONFIG_DIR}/config.json`;
9
9
  /** Cached `bw` session token (owner-only). */
10
10
  export const SESSION_FILE = `${CONFIG_DIR}/session`;
11
+ /** Cross-process gate used only while establishing a session. */
12
+ export const SESSION_LOCK_DIR = `${CONFIG_DIR}/session.lock`;
11
13
  /** Cached vault-freshness snapshot — see bw/freshness.ts. */
12
14
  export const STATE_FILE = `${CONFIG_DIR}/state.json`;
@@ -0,0 +1,255 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import {
3
+ chmodSync,
4
+ lstatSync,
5
+ mkdirSync,
6
+ renameSync,
7
+ rmSync,
8
+ } from "node:fs";
9
+ import { dirname } from "node:path";
10
+ import { CliError, ExitCode } from "../cli/errors.ts";
11
+ import { formatDuration } from "../cli/duration.ts";
12
+ import { readPrivateFile, writePrivateFile } from "./secure-file.ts";
13
+
14
+ const OWNER_FILE = "owner.json";
15
+ const DEFAULT_POLL_MS = 50;
16
+ const SHARED_BITS = 0o077;
17
+ const UUID_RE =
18
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
19
+
20
+ interface LockOwner {
21
+ pid: number;
22
+ token: string;
23
+ acquiredAt: string;
24
+ }
25
+
26
+ interface ProcessLockOptions {
27
+ path: string;
28
+ label: string;
29
+ waitMs: number;
30
+ pollMs?: number;
31
+ }
32
+
33
+ interface Ownership {
34
+ path: string;
35
+ owner: LockOwner;
36
+ }
37
+
38
+ /**
39
+ * Runs a critical section under a cross-process lock. Publishing a populated
40
+ * directory by rename keeps contenders from observing a half-created lock;
41
+ * live owners are waited on, while dead-owner generations are safely detached.
42
+ */
43
+ export async function withProcessLock<T>(
44
+ options: ProcessLockOptions,
45
+ fn: () => Promise<T>,
46
+ ): Promise<T> {
47
+ const ownership = await acquire(options);
48
+ let bodyFailed = false;
49
+
50
+ try {
51
+ return await fn();
52
+ } catch (err) {
53
+ bodyFailed = true;
54
+ throw err;
55
+ } finally {
56
+ try {
57
+ release(ownership, options.label);
58
+ } catch (err) {
59
+ // Preserve the operation's useful error if cleanup also failed.
60
+ if (!bodyFailed) throw err;
61
+ }
62
+ }
63
+ }
64
+
65
+ async function acquire(options: ProcessLockOptions): Promise<Ownership> {
66
+ const startedAt = performance.now();
67
+ let holder: LockOwner | null = null;
68
+
69
+ while (true) {
70
+ const ownership = tryCreate(options.path);
71
+ if (ownership) return ownership;
72
+
73
+ holder = readOwner(options.path, options.label);
74
+ if (!holder) continue;
75
+
76
+ if (!isProcessAlive(holder.pid)) {
77
+ if (detachAbandonedLock(options.path, holder, options.label)) continue;
78
+ }
79
+
80
+ const elapsed = performance.now() - startedAt;
81
+ if (elapsed >= options.waitMs) {
82
+ throw new CliError(
83
+ `Timed out after ${formatDuration(options.waitMs)} waiting for ${options.label} held by pid ${holder.pid}`,
84
+ ExitCode.Timeout,
85
+ );
86
+ }
87
+
88
+ const remaining = options.waitMs - elapsed;
89
+ await Bun.sleep(Math.min(options.pollMs ?? DEFAULT_POLL_MS, remaining));
90
+ }
91
+ }
92
+
93
+ function tryCreate(path: string): Ownership | null {
94
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
95
+
96
+ const owner: LockOwner = {
97
+ pid: process.pid,
98
+ token: randomUUID(),
99
+ acquiredAt: new Date().toISOString(),
100
+ };
101
+ const candidate = `${path}.${owner.token}.tmp`;
102
+
103
+ try {
104
+ mkdirSync(candidate, { mode: 0o700 });
105
+ chmodSync(candidate, 0o700);
106
+ writePrivateFile(
107
+ `${candidate}/${OWNER_FILE}`,
108
+ JSON.stringify(owner) + "\n",
109
+ );
110
+ try {
111
+ renameSync(candidate, path);
112
+ return { path, owner };
113
+ } catch (err) {
114
+ if (pathExists(path)) return null;
115
+ throw err;
116
+ }
117
+ } finally {
118
+ if (pathExists(candidate)) {
119
+ rmSync(candidate, { recursive: true, force: true });
120
+ }
121
+ }
122
+ }
123
+
124
+ function readOwner(path: string, label: string): LockOwner | null {
125
+ let stat;
126
+ try {
127
+ stat = lstatSync(path);
128
+ } catch (err) {
129
+ if (errorCode(err) === "ENOENT") return null;
130
+ throw err;
131
+ }
132
+
133
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
134
+ throw invalidLock(label, `${path} is not a regular directory`);
135
+ }
136
+ if (typeof process.getuid === "function" && stat.uid !== process.getuid()) {
137
+ throw invalidLock(label, `${path} is owned by uid ${stat.uid}`);
138
+ }
139
+ if (stat.mode & SHARED_BITS) {
140
+ const mode = (stat.mode & 0o777).toString(8).padStart(3, "0");
141
+ throw invalidLock(label, `${path} has mode ${mode}, expected 700`);
142
+ }
143
+
144
+ const read = readPrivateFile(`${path}/${OWNER_FILE}`);
145
+ if (read.kind !== "ok") {
146
+ const reason =
147
+ read.kind === "missing"
148
+ ? `${path}/${OWNER_FILE} is missing`
149
+ : read.reason;
150
+ throw invalidLock(label, reason);
151
+ }
152
+
153
+ try {
154
+ const owner = JSON.parse(read.text) as Partial<LockOwner>;
155
+ if (
156
+ !Number.isSafeInteger(owner.pid) ||
157
+ (owner.pid ?? 0) <= 0 ||
158
+ typeof owner.token !== "string" ||
159
+ !UUID_RE.test(owner.token) ||
160
+ typeof owner.acquiredAt !== "string" ||
161
+ !Number.isFinite(Date.parse(owner.acquiredAt))
162
+ ) {
163
+ throw new Error("invalid owner fields");
164
+ }
165
+ return owner as LockOwner;
166
+ } catch {
167
+ throw invalidLock(label, `${path}/${OWNER_FILE} is malformed`);
168
+ }
169
+ }
170
+
171
+ function detachAbandonedLock(
172
+ path: string,
173
+ expected: LockOwner,
174
+ label: string,
175
+ ): boolean {
176
+ // The generation-specific recovery lock prevents two stale observations from
177
+ // detaching a successor that appeared between inspection and rename.
178
+ const recoveryPath = `${path}.reap.${expected.token}`;
179
+ const recovery = tryCreate(recoveryPath);
180
+ if (!recovery) {
181
+ const recoveryOwner = readOwner(recoveryPath, `${label} recovery lock`);
182
+ if (recoveryOwner && !isProcessAlive(recoveryOwner.pid)) {
183
+ detachAbandonedLock(
184
+ recoveryPath,
185
+ recoveryOwner,
186
+ `${label} recovery lock`,
187
+ );
188
+ }
189
+ return false;
190
+ }
191
+
192
+ let abandoned: string | null = null;
193
+ try {
194
+ const current = readOwner(path, label);
195
+ if (
196
+ !current ||
197
+ current.token !== expected.token ||
198
+ isProcessAlive(current.pid)
199
+ ) {
200
+ return false;
201
+ }
202
+
203
+ abandoned = `${path}.${expected.token}.abandoned`;
204
+ try {
205
+ renameSync(path, abandoned);
206
+ } catch (err) {
207
+ if (errorCode(err) === "ENOENT") return false;
208
+ throw err;
209
+ }
210
+
211
+ // Cleanup names the detached generation, never a successor at the lock path.
212
+ rmSync(abandoned, { recursive: true, force: true });
213
+ return true;
214
+ } finally {
215
+ release(recovery, `${label} recovery lock`);
216
+ }
217
+ }
218
+
219
+ function release(ownership: Ownership, label: string): void {
220
+ const current = readOwner(ownership.path, label);
221
+ if (!current || current.token !== ownership.owner.token) {
222
+ throw invalidLock(label, "ownership changed before release");
223
+ }
224
+
225
+ const released = `${ownership.path}.${ownership.owner.token}.released`;
226
+ renameSync(ownership.path, released);
227
+ rmSync(released, { recursive: true, force: true });
228
+ }
229
+
230
+ function isProcessAlive(pid: number): boolean {
231
+ try {
232
+ process.kill(pid, 0);
233
+ return true;
234
+ } catch (err) {
235
+ return errorCode(err) !== "ESRCH";
236
+ }
237
+ }
238
+
239
+ function pathExists(path: string): boolean {
240
+ try {
241
+ lstatSync(path);
242
+ return true;
243
+ } catch (err) {
244
+ if (errorCode(err) === "ENOENT") return false;
245
+ throw err;
246
+ }
247
+ }
248
+
249
+ function errorCode(err: unknown): string | undefined {
250
+ return (err as NodeJS.ErrnoException)?.code;
251
+ }
252
+
253
+ function invalidLock(label: string, reason: string): CliError {
254
+ return new CliError(`Unsafe or corrupt ${label}: ${reason}`, ExitCode.Config);
255
+ }