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 +1 -1
- package/package.json +1 -1
- package/src/bw/session.ts +42 -5
- package/src/config/paths.ts +2 -0
- package/src/config/process-lock.ts +255 -0
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
|
|
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
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 {
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
}
|
package/src/config/paths.ts
CHANGED
|
@@ -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
|
+
}
|