bwx 0.2.2 → 0.4.0

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
@@ -1,15 +1,27 @@
1
1
  # bwx
2
2
 
3
- Bitwarden CLI wrapper for AI agents. Auto-unlock, session caching, structured output.
3
+ Bitwarden CLI wrapper for AI agents (`bwx` = `bw extended`). Auto-unlock, session caching, structured output.
4
+
5
+ > Currently macOS-only: `bwx` uses macOS Keychain for secure master password storage.
6
+
7
+ ## Platform support
8
+
9
+ - ✅ macOS
10
+ - ❌ Linux (not supported yet)
11
+ - ❌ Windows (not supported yet)
12
+
13
+ `bwx` relies on macOS Keychain for secure master-password storage and retrieval.
4
14
 
5
15
  ## Why
6
16
 
7
17
  The native `bw` CLI requires interactive prompts and manual session management. `bwx` wraps it with:
8
18
 
9
19
  - **Auto-unlock** — first vault command authenticates automatically via macOS Keychain
10
- - **Session caching** — token persisted to `~/.config/bwx/session` (mode 600), reused across calls
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
11
21
  - **Structured output** — `--json` and `--plain` flags for machine-parseable output
22
+ - **Leak-resistant** — listings never print secrets, `bwx run` hands them to a child process instead of stdout
12
23
  - **No interaction** — credentials from config + Keychain, never prompts
24
+ - **Bounded** — every `bw` call has a deadline, so a wedged vault operation cannot hang a caller
13
25
 
14
26
  ## Install
15
27
 
@@ -54,14 +66,53 @@ bwx doctor
54
66
  |---------|-------------|
55
67
  | `bwx get <field> <item>` | Get field (`password`, `username`, `totp`, `notes`, `uri`, `item`) |
56
68
  | `bwx field <item> <name>` | Get custom field by name |
57
- | `bwx search <query>` | Search items (`--type`, `--folder`, `--limit`) |
69
+ | `bwx search <query>` | Search items (`--type`, `--folder`, `--limit`, `--full-items`) |
70
+ | `bwx list [type]` | List items (`--folder`, `--limit`, `--full-items`) |
71
+ | `bwx run --env NAME=<field>:<item> -- <cmd>` | Run a command with secrets in its environment |
72
+ | `bwx attach list <item>` | List attachments for an item |
73
+ | `bwx attach get <item> <attachment>` | Download attachment by ID or filename |
74
+
75
+ `search` and `list` print the same reduced projection — id, type, name, username — in
76
+ human, `--plain`, and `--json` form, so asking for machine-readable output never widens
77
+ what is exposed. `--full-items` is the explicit opt-in to complete item objects, which
78
+ carry passwords, notes, and custom fields.
79
+
80
+ ### Secrets into a child process
81
+
82
+ `bwx run` resolves secrets and places them only in the child's environment: nothing is
83
+ printed to stdout, and nothing lands in a command line where other processes could read it.
84
+
85
+ ```bash
86
+ bwx run --env KEENETIC_PASS=password:'http://my.keenetic.net' -- expect ./recovery.exp
87
+ bwx run --env DB_PASS=password:'Prod DB' --env TOKEN='API Key:Acme' -- ./deploy.sh
88
+ ```
89
+
90
+ - `--env NAME=<field>:<item>` is repeatable; the field resolves exactly as in `bwx get`
91
+ (built-ins first, otherwise a custom field name).
92
+ - The item may contain colons — only the first one separates field from item.
93
+ - Use `--` before the command whenever it takes its own flags.
94
+ - The child inherits stdio and bwx exits with the child's status (`128 + signal` when killed).
95
+ - `-v` logs the injected variable *names*; values are never logged.
96
+
97
+ ### Vault freshness
98
+
99
+ `bw` answers reads from the local vault copy, so a vault that has not synced in a while
100
+ returns stale secrets. Reads warn on stderr when the last sync is over 24h old, and
101
+ `--sync-if-older-than <duration>` syncs first instead of warning:
102
+
103
+ ```bash
104
+ bwx get password "Router" # warns if the vault is stale
105
+ bwx --sync-if-older-than 15m get password "Router" # syncs first when needed
106
+ ```
107
+
108
+ The check is cached in `~/.config/bwx/state.json`, so a fresh vault costs no extra `bw` call.
58
109
 
59
110
  ### Write
60
111
 
61
112
  | Command | Description |
62
113
  |---------|-------------|
63
- | `bwx create` | Create item (`--type`, `--name`, `--username`, `--password`, `--uri`, `--field k=v`) |
64
- | `bwx edit <item>` | Edit item (`--name`, `--password`, `--add-field k=v`, `--rm-field name`) |
114
+ | `bwx create` | Create item (`--type`, `--name`, `--username`, `--password-stdin`, `--field-file k=path`) |
115
+ | `bwx edit <item>` | Edit item (`--name`, `--password-env`, `--add-field-file k=path`, `--rm-field name`) |
65
116
  | `bwx delete <item>` | Delete item (`--permanent`, `--force`) |
66
117
 
67
118
  ### Config
@@ -72,18 +123,54 @@ bwx doctor
72
123
  | `bwx config master-password` | Store master password in Keychain |
73
124
  | `bwx config list` / `bwx cfg ls` | Show config path + redacted content |
74
125
 
126
+ ### Attachments
127
+
128
+ ```bash
129
+ bwx attach list "Apple Developer"
130
+ bwx attach get "Apple Developer" "AuthKey.p8"
131
+ bwx attach get "Apple Developer" "AuthKey.p8" --output ./secrets/
132
+ ```
133
+
134
+ Aliases: `attach`, `attachment`, `attachments`; `list` also has `ls`.
135
+
136
+ ### Safe input
137
+
138
+ Prefer stdin, files, or environment variables for secrets so values do not land in shell history.
139
+
140
+ ```bash
141
+ printf '%s' "$TOKEN" | bwx create --type login --name "GitHub" --username user --password-stdin
142
+ bwx edit "GitHub" --password-env GITHUB_TOKEN
143
+ bwx create --name "Deploy key" --notes-file ./deploy-key.txt
144
+ bwx create --name "Acme" --field-file '!API Key=./token.txt' --field-env account=ACME_ACCOUNT
145
+ ```
146
+
147
+ Supported source flags:
148
+
149
+ - Passwords: `--password-stdin`, `--password-file <path>`, `--password-env <name>`
150
+ - Notes: `--notes-file <path>`
151
+ - Create fields: `--field-file k=path`, `--field-env k=ENV`
152
+ - Edit fields: `--add-field-file k=path`, `--add-field-env k=ENV`
153
+ - Use `-` as the file path to read that source from stdin.
154
+
75
155
  ### Global flags
76
156
 
77
157
  | Flag | Effect |
78
158
  |------|--------|
79
159
  | `--json` | JSON output: `{ "data": ... }` / `{ "error": ... }` |
80
160
  | `--plain` | Tab-separated, one item per line |
81
- | `-q, --quiet` | Suppress logs |
161
+ | `-q, --quiet` | Suppress logs and warnings |
82
162
  | `-v, --verbose` | Include stack traces |
163
+ | `--timeout <duration>` | Deadline for each `bw` call (default `1m`, `0` disables) |
164
+ | `--sync-if-older-than <duration>` | Sync before vault access when the last sync is older |
165
+
166
+ Durations accept `250ms`, `30s`, `15m`, `2h`, `1d`; a bare number means seconds.
167
+
168
+ Warnings ("vault is stale", "ignoring cached session") go to stderr even under `--json`,
169
+ because they say the returned data may be wrong. `-q` silences them.
83
170
 
84
171
  ## Custom fields
85
172
 
86
- Field syntax for `--field` / `--add-field`:
173
+ Field syntax for `--field`, `--field-file`, `--field-env`, and edit variants:
87
174
 
88
175
  - `key=value` — text field
89
176
  - `!key=value` — hidden field
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bwx",
3
- "version": "0.2.2",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "keywords": [
6
6
  "bitwarden",
@@ -0,0 +1,58 @@
1
+ import { CliError, ExitCode } from "../cli/errors.ts";
2
+
3
+ export interface AttachmentInfo {
4
+ id: string;
5
+ fileName: string;
6
+ size?: number;
7
+ sizeName?: string;
8
+ }
9
+
10
+ export function listAttachments(item: Record<string, unknown>): AttachmentInfo[] {
11
+ const raw = item.attachments;
12
+ if (!Array.isArray(raw)) return [];
13
+
14
+ return raw
15
+ .map((attachment) => normalizeAttachment(attachment))
16
+ .filter((attachment): attachment is AttachmentInfo => attachment !== null);
17
+ }
18
+
19
+ export function resolveAttachment(
20
+ attachments: AttachmentInfo[],
21
+ query: string,
22
+ ): AttachmentInfo {
23
+ const matches = attachments.filter(
24
+ (attachment) => attachment.id === query || attachment.fileName === query,
25
+ );
26
+
27
+ if (matches.length === 1) return matches[0]!;
28
+
29
+ if (matches.length > 1) {
30
+ throw new CliError(
31
+ `Multiple attachments match "${query}". Use an attachment ID instead.`,
32
+ ExitCode.BadArgs,
33
+ );
34
+ }
35
+
36
+ const available = attachments.map((attachment) => attachment.fileName).join(", ");
37
+ const suffix = available ? ` Available: ${available}` : "";
38
+ throw new CliError(`Attachment "${query}" not found.${suffix}`, ExitCode.NotFound);
39
+ }
40
+
41
+ function normalizeAttachment(value: unknown): AttachmentInfo | null {
42
+ if (!value || typeof value !== "object") return null;
43
+
44
+ const record = value as Record<string, unknown>;
45
+ if (typeof record.id !== "string" || typeof record.fileName !== "string") {
46
+ return null;
47
+ }
48
+
49
+ const attachment: AttachmentInfo = {
50
+ id: record.id,
51
+ fileName: record.fileName,
52
+ };
53
+
54
+ if (typeof record.size === "number") attachment.size = record.size;
55
+ if (typeof record.sizeName === "string") attachment.sizeName = record.sizeName;
56
+
57
+ return attachment;
58
+ }
@@ -0,0 +1,133 @@
1
+ import pc from "picocolors";
2
+ import { CliError, ExitCode } from "../cli/errors.ts";
3
+ import type { GlobalOptions } from "../cli/types.ts";
4
+ import { summarizeItem, type ItemSummary } from "./items.ts";
5
+ import { runBwOrThrow } from "./runner.ts";
6
+ import { withSession } from "./session.ts";
7
+ import { BwItemSchema } from "./types.ts";
8
+
9
+ /** Fields `bw get` resolves natively; anything else is a custom field name. */
10
+ const BUILTIN_FIELDS = [
11
+ "password",
12
+ "username",
13
+ "totp",
14
+ "notes",
15
+ "uri",
16
+ "item",
17
+ ] as const;
18
+
19
+ function isBuiltinField(field: string): boolean {
20
+ return (BUILTIN_FIELDS as readonly string[]).includes(field);
21
+ }
22
+
23
+ export interface ReadFieldOptions {
24
+ /**
25
+ * Always resolve `field` as a custom field name, so an item whose custom
26
+ * field is called e.g. `password` is still reachable.
27
+ */
28
+ customOnly?: boolean;
29
+ }
30
+
31
+ /**
32
+ * Reads one field from a vault item and returns it verbatim (`item` yields the
33
+ * item JSON), leaving the caller to decide whether the value is printed at all.
34
+ * Ambiguous item lookups fail with the candidate list instead of a raw bw error.
35
+ */
36
+ export async function readItemField(
37
+ field: string,
38
+ item: string,
39
+ opts: GlobalOptions,
40
+ options?: ReadFieldOptions,
41
+ ): Promise<string> {
42
+ try {
43
+ return isBuiltinField(field) && !options?.customOnly
44
+ ? await readBuiltinField(field, item, opts)
45
+ : await readCustomField(field, item, opts);
46
+ } catch (err) {
47
+ if (err instanceof CliError && /more than one result/i.test(err.message)) {
48
+ const matches = await findMatches(item);
49
+ throw new CliError(
50
+ formatAmbiguousError(item, matches, opts),
51
+ ExitCode.BadArgs,
52
+ );
53
+ }
54
+ throw err;
55
+ }
56
+ }
57
+
58
+ async function readBuiltinField(
59
+ field: string,
60
+ item: string,
61
+ opts: GlobalOptions,
62
+ ): Promise<string> {
63
+ const result = await withSession(opts, () =>
64
+ runBwOrThrow(["get", field, item]),
65
+ );
66
+
67
+ if (!result) {
68
+ throw new CliError(
69
+ `No ${field} found for item: ${item}`,
70
+ ExitCode.NotFound,
71
+ );
72
+ }
73
+
74
+ return result;
75
+ }
76
+
77
+ async function readCustomField(
78
+ fieldName: string,
79
+ item: string,
80
+ opts: GlobalOptions,
81
+ ): Promise<string> {
82
+ const json = await withSession(opts, () =>
83
+ runBwOrThrow(["get", "item", item]),
84
+ );
85
+
86
+ const parsed = BwItemSchema.parse(JSON.parse(json));
87
+ const fields = parsed.fields ?? [];
88
+ const match = fields.find((f) => f.name === fieldName);
89
+
90
+ if (!match) {
91
+ const available = fields.map((f) => f.name).join(", ");
92
+ const msg = available
93
+ ? `Field "${fieldName}" not found. Available: ${available}`
94
+ : `Field "${fieldName}" not found (item has no custom fields)`;
95
+ throw new CliError(msg, ExitCode.NotFound);
96
+ }
97
+
98
+ return match.value ?? "";
99
+ }
100
+
101
+ async function findMatches(query: string): Promise<ItemSummary[]> {
102
+ try {
103
+ const json = await runBwOrThrow(["list", "items", "--search", query]);
104
+ const items: Array<Record<string, unknown>> = JSON.parse(json);
105
+ return items.map(summarizeItem);
106
+ } catch {
107
+ return [];
108
+ }
109
+ }
110
+
111
+ function formatAmbiguousError(
112
+ query: string,
113
+ matches: ItemSummary[],
114
+ opts: GlobalOptions,
115
+ ): string {
116
+ if (matches.length === 0) {
117
+ return `Multiple items match "${query}". Use a specific ID instead.`;
118
+ }
119
+
120
+ if (opts.json) {
121
+ return `Multiple items match "${query}": ${JSON.stringify(matches)}`;
122
+ }
123
+
124
+ const lines = [`Multiple items match "${query}":\n`];
125
+ for (const m of matches) {
126
+ const user = m.username ? ` (${m.username})` : "";
127
+ lines.push(
128
+ ` ${pc.dim(m.id)} ${pc.cyan(m.type)} ${m.name}${pc.dim(user)}`,
129
+ );
130
+ }
131
+ lines.push(`\nUse a specific ID: ${pc.dim("bwx get <field> <id>")}`);
132
+ return lines.join("\n");
133
+ }
@@ -0,0 +1,161 @@
1
+ import { formatDuration } from "../cli/duration.ts";
2
+ import { emitLog, emitWarn } from "../cli/io.ts";
3
+ import type { GlobalOptions } from "../cli/types.ts";
4
+ import { STATE_FILE } from "../config/paths.ts";
5
+ import { readPrivateFile, writePrivateFile } from "../config/secure-file.ts";
6
+ import { getStatus } from "./status.ts";
7
+ import type { BwStatus } from "./types.ts";
8
+
9
+ /**
10
+ * `bw` serves reads from the local vault copy, so a vault that has not synced in
11
+ * days silently returns rotated-away secrets. Reads therefore check freshness —
12
+ * but `bw status` costs a full `bw` spawn (~2s), far too much to pay on every
13
+ * read, so the answer is cached here and only re-polled when it might matter.
14
+ */
15
+
16
+ /** Warn when the vault has not synced within this window. */
17
+ export const STALE_AFTER_MS = 24 * 60 * 60 * 1_000;
18
+
19
+ /** While the vault looks stale, ask `bw status` again at most this often. */
20
+ const RECHECK_INTERVAL_MS = 5 * 60 * 1_000;
21
+
22
+ export interface FreshnessSnapshot {
23
+ /** `lastSync` as reported by `bw status`; null when the vault never synced. */
24
+ lastSync: string | null;
25
+ /** When bwx last asked `bw status` for that value. */
26
+ checkedAt: string;
27
+ }
28
+
29
+ export type FreshnessPlan =
30
+ /** Known fresh — no `bw` call, no warning. */
31
+ | "skip"
32
+ /** Known stale recently enough to trust the cached answer. */
33
+ | "stale"
34
+ /** Cached answer is missing, unusable, or too old to trust. */
35
+ | "poll";
36
+
37
+ /**
38
+ * Decides what can be concluded from the cached snapshot alone. Pure so the
39
+ * caching policy stays testable without a vault.
40
+ */
41
+ export function planFreshnessCheck(
42
+ snapshot: FreshnessSnapshot | null,
43
+ thresholdMs: number,
44
+ now: number,
45
+ ): FreshnessPlan {
46
+ if (!snapshot) return "poll";
47
+
48
+ if (isFresh(snapshot, thresholdMs, now)) return "skip";
49
+
50
+ const checkedAgeMs = now - Date.parse(snapshot.checkedAt);
51
+ const checkedRecently =
52
+ Number.isFinite(checkedAgeMs) &&
53
+ checkedAgeMs >= 0 &&
54
+ checkedAgeMs < RECHECK_INTERVAL_MS;
55
+
56
+ // lastSync only ever moves forward, so a stale reading stays stale unless
57
+ // another client synced — worth re-checking, just not on every read.
58
+ return checkedRecently ? "stale" : "poll";
59
+ }
60
+
61
+ /**
62
+ * Warns (or syncs, with `--sync-if-older-than`) when the local vault is stale.
63
+ * Advisory only: any failure to determine freshness is ignored rather than
64
+ * failing the operation the caller actually asked for.
65
+ */
66
+ export async function checkVaultFreshness(
67
+ opts: GlobalOptions,
68
+ sync: () => Promise<unknown>,
69
+ ): Promise<void> {
70
+ const syncOnStale = opts.syncIfOlderThanMs !== undefined;
71
+ const thresholdMs = opts.syncIfOlderThanMs ?? STALE_AFTER_MS;
72
+
73
+ let snapshot = loadSnapshot();
74
+ const plan = planFreshnessCheck(snapshot, thresholdMs, Date.now());
75
+ if (plan === "skip") return;
76
+
77
+ if (plan === "poll") {
78
+ try {
79
+ snapshot = recordStatus(await getStatus());
80
+ } catch {
81
+ return; // Freshness is advisory — never block the real operation.
82
+ }
83
+ if (isFresh(snapshot, thresholdMs, Date.now())) return;
84
+ }
85
+
86
+ const ageMs = snapshotAgeMs(snapshot, Date.now());
87
+ const age = ageMs === null ? "never" : `${formatDuration(ageMs)} ago`;
88
+
89
+ if (!syncOnStale) {
90
+ emitWarn(
91
+ `Vault last synced ${age} — reads may be stale. Run 'bwx sync' or pass --sync-if-older-than <duration>.`,
92
+ opts,
93
+ );
94
+ return;
95
+ }
96
+
97
+ emitLog(`Vault last synced ${age} — syncing...`, opts);
98
+ await sync();
99
+ recordSyncNow();
100
+ }
101
+
102
+ /** Caches what `bw status` just reported. */
103
+ export function recordStatus(status: BwStatus): FreshnessSnapshot {
104
+ return saveSnapshot({
105
+ lastSync: status.lastSync,
106
+ checkedAt: new Date().toISOString(),
107
+ });
108
+ }
109
+
110
+ /**
111
+ * Records a sync that just completed. The stored `lastSync` is our own clock
112
+ * rather than the server's, which is close enough to keep later reads quiet.
113
+ */
114
+ export function recordSyncNow(): void {
115
+ const now = new Date().toISOString();
116
+ saveSnapshot({ lastSync: now, checkedAt: now });
117
+ }
118
+
119
+ function isFresh(
120
+ snapshot: FreshnessSnapshot | null,
121
+ thresholdMs: number,
122
+ now: number,
123
+ ): boolean {
124
+ const ageMs = snapshotAgeMs(snapshot, now);
125
+ return ageMs !== null && ageMs < thresholdMs;
126
+ }
127
+
128
+ function snapshotAgeMs(
129
+ snapshot: FreshnessSnapshot | null,
130
+ now: number,
131
+ ): number | null {
132
+ if (!snapshot?.lastSync) return null;
133
+ const syncedAt = Date.parse(snapshot.lastSync);
134
+ if (!Number.isFinite(syncedAt)) return null;
135
+ return Math.max(0, now - syncedAt);
136
+ }
137
+
138
+ function loadSnapshot(): FreshnessSnapshot | null {
139
+ const read = readPrivateFile(STATE_FILE);
140
+ if (read.kind !== "ok") return null;
141
+
142
+ try {
143
+ const parsed = JSON.parse(read.text) as Partial<FreshnessSnapshot>;
144
+ if (typeof parsed.checkedAt !== "string") return null;
145
+ return {
146
+ lastSync: typeof parsed.lastSync === "string" ? parsed.lastSync : null,
147
+ checkedAt: parsed.checkedAt,
148
+ };
149
+ } catch {
150
+ return null;
151
+ }
152
+ }
153
+
154
+ function saveSnapshot(snapshot: FreshnessSnapshot): FreshnessSnapshot {
155
+ try {
156
+ writePrivateFile(STATE_FILE, JSON.stringify(snapshot) + "\n");
157
+ } catch {
158
+ // A read-only config dir must not break vault access.
159
+ }
160
+ return snapshot;
161
+ }
@@ -0,0 +1,25 @@
1
+ import { BW_TYPE_LABELS } from "./types.ts";
2
+
3
+ /**
4
+ * The only shape of a vault item bwx prints when the caller did not ask for a
5
+ * specific secret. Enough to identify an item and follow up with a narrow `get`,
6
+ * with no room for passwords, notes, custom fields, or URIs to leak into logs.
7
+ */
8
+ export interface ItemSummary {
9
+ id: string;
10
+ type: string;
11
+ name: string;
12
+ username: string | null;
13
+ }
14
+
15
+ export function summarizeItem(item: Record<string, unknown>): ItemSummary {
16
+ const login = item.login as Record<string, unknown> | null | undefined;
17
+ const username = login?.username;
18
+
19
+ return {
20
+ id: String(item.id ?? ""),
21
+ type: BW_TYPE_LABELS[item.type as number] ?? `type:${item.type}`,
22
+ name: String(item.name ?? ""),
23
+ username: typeof username === "string" && username ? username : null,
24
+ };
25
+ }
package/src/bw/runner.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { CliError, ExitCode, classifyBwError } from "../cli/errors.ts";
2
+ import { formatDuration } from "../cli/duration.ts";
2
3
 
3
4
  export interface BwResult {
4
5
  stdout: string;
@@ -6,7 +7,20 @@ export interface BwResult {
6
7
  exitCode: number;
7
8
  }
8
9
 
10
+ export interface RunBwOptions {
11
+ /** Explicit session, or `null` to run without one (login/unlock/status). */
12
+ session?: string | null;
13
+ env?: Record<string, string>;
14
+ }
15
+
16
+ /** Deadline for a single `bw` call; overridable with `--timeout`. */
17
+ export const DEFAULT_TIMEOUT_MS = 60_000;
18
+
19
+ /** Grace period between SIGTERM and SIGKILL for a `bw` process past its deadline. */
20
+ const KILL_GRACE_MS = 2_000;
21
+
9
22
  let sessionToken: string | null = null;
23
+ let timeoutMs: number = DEFAULT_TIMEOUT_MS;
10
24
 
11
25
  export function setSession(token: string | null): void {
12
26
  sessionToken = token;
@@ -16,33 +30,51 @@ export function getSession(): string | null {
16
30
  return sessionToken;
17
31
  }
18
32
 
33
+ /** Applies the `--timeout` value for the rest of the process. */
34
+ export function setBwTimeout(ms: number): void {
35
+ timeoutMs = ms;
36
+ }
37
+
19
38
  export async function runBw(
20
39
  args: string[],
21
- options?: { session?: string | null; env?: Record<string, string> },
40
+ options?: RunBwOptions,
22
41
  ): Promise<BwResult> {
23
42
  const session = options?.session ?? sessionToken;
24
- const fullArgs = session ? [...args, "--session", session] : args;
25
43
 
26
- const proc = Bun.spawn(["bw", ...fullArgs], {
44
+ const proc = Bun.spawn(["bw", ...args], {
27
45
  stdout: "pipe",
28
46
  stderr: "pipe",
29
47
  stdin: "ignore",
30
- env: options?.env ? { ...process.env, ...options.env } : undefined,
48
+ // The session goes in the environment, never in argv: command lines are
49
+ // readable by any same-user process. Nothing here is ever logged.
50
+ env: buildEnv(session, options?.env),
31
51
  });
32
52
 
33
- const [stdout, stderr] = await Promise.all([
34
- new Response(proc.stdout).text(),
35
- new Response(proc.stderr).text(),
36
- ]);
53
+ const guard = timeoutMs > 0 ? armDeadline(proc, timeoutMs) : null;
54
+
55
+ try {
56
+ const [stdout, stderr] = await Promise.all([
57
+ new Response(proc.stdout).text(),
58
+ new Response(proc.stderr).text(),
59
+ ]);
60
+ const exitCode = await proc.exited;
37
61
 
38
- const exitCode = await proc.exited;
62
+ if (guard?.expired) {
63
+ throw new CliError(
64
+ `bw ${args[0] ?? ""} timed out after ${formatDuration(timeoutMs)}`.trim(),
65
+ ExitCode.Timeout,
66
+ );
67
+ }
39
68
 
40
- return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode };
69
+ return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode };
70
+ } finally {
71
+ guard?.disarm();
72
+ }
41
73
  }
42
74
 
43
75
  export async function runBwOrThrow(
44
76
  args: string[],
45
- options?: { session?: string | null; env?: Record<string, string> },
77
+ options?: RunBwOptions,
46
78
  ): Promise<string> {
47
79
  const result = await runBw(args, options);
48
80
  if (result.exitCode !== 0) {
@@ -55,6 +87,70 @@ export async function runBwOrThrow(
55
87
  return result.stdout;
56
88
  }
57
89
 
58
- export function isAuthError(result: BwResult): boolean {
59
- return /not logged in|vault is locked|unauthenticated/i.test(result.stderr);
90
+ /**
91
+ * Child environment for `bw`. `BW_SESSION` is set from our session, or removed
92
+ * outright so an ambient one from the caller's shell cannot silently substitute
93
+ * for it (`status`, `login`, and `unlock` rely on seeing the real vault state).
94
+ *
95
+ * `BW_NOINTERACTION` is mandatory: bwx runs `bw` with stdin ignored, so a prompt
96
+ * can only ever produce a hang or garbled output — a locked vault must fail
97
+ * loudly instead, which is what lets callers auto-unlock and retry.
98
+ */
99
+ function buildEnv(
100
+ session: string | null,
101
+ extra?: Record<string, string>,
102
+ ): Record<string, string | undefined> {
103
+ const env: Record<string, string | undefined> = {
104
+ ...process.env,
105
+ BW_NOINTERACTION: "true",
106
+ ...extra,
107
+ };
108
+ if (session) {
109
+ env.BW_SESSION = session;
110
+ } else {
111
+ delete env.BW_SESSION;
112
+ }
113
+ return env;
114
+ }
115
+
116
+ interface Deadline {
117
+ expired: boolean;
118
+ disarm(): void;
119
+ }
120
+
121
+ /**
122
+ * Kills `bw` once the deadline passes so a wedged vault operation (stuck Keychain
123
+ * prompt, unreachable self-hosted server) cannot block the caller forever.
124
+ */
125
+ function armDeadline(proc: Bun.Subprocess, ms: number): Deadline {
126
+ const timers: ReturnType<typeof setTimeout>[] = [];
127
+ const deadline: Deadline = {
128
+ expired: false,
129
+ disarm: () => timers.forEach(clearTimeout),
130
+ };
131
+
132
+ const arm = (delay: number, fn: () => void) => {
133
+ const timer = setTimeout(fn, delay);
134
+ timer.unref?.();
135
+ timers.push(timer);
136
+ };
137
+
138
+ // A timer can still fire in the moment between exit and disarm, where the
139
+ // signal has nowhere to go.
140
+ const kill = (signal: NodeJS.Signals) => {
141
+ try {
142
+ proc.kill(signal);
143
+ } catch {
144
+ // Already gone.
145
+ }
146
+ };
147
+
148
+ arm(ms, () => {
149
+ deadline.expired = true;
150
+ kill("SIGTERM");
151
+ // Escalate if bw ignores SIGTERM.
152
+ arm(KILL_GRACE_MS, () => kill("SIGKILL"));
153
+ });
154
+
155
+ return deadline;
60
156
  }