bwx 0.3.0 → 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 +49 -3
- package/package.json +1 -1
- package/src/bw/fields.ts +133 -0
- package/src/bw/freshness.ts +161 -0
- package/src/bw/items.ts +25 -0
- package/src/bw/runner.ts +109 -13
- package/src/bw/session.ts +57 -62
- package/src/bw/status.ts +19 -0
- package/src/cli/commands/doctor.ts +2 -3
- package/src/cli/commands/get.ts +5 -129
- package/src/cli/commands/list.ts +5 -48
- package/src/cli/commands/run.ts +138 -0
- package/src/cli/commands/search.ts +5 -49
- package/src/cli/commands/status.ts +4 -2
- package/src/cli/commands/sync.ts +6 -1
- package/src/cli/duration.ts +34 -0
- package/src/cli/io.ts +7 -1
- package/src/cli/items.ts +78 -0
- package/src/cli/program.ts +32 -1
- package/src/cli/types.ts +7 -4
- package/src/config/paths.ts +12 -0
- package/src/config/secure-file.ts +83 -0
- package/src/config/store.ts +6 -10
package/README.md
CHANGED
|
@@ -17,9 +17,11 @@ 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
|
|
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
|
|
21
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
|
|
22
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
|
|
23
25
|
|
|
24
26
|
## Install
|
|
25
27
|
|
|
@@ -64,10 +66,47 @@ bwx doctor
|
|
|
64
66
|
|---------|-------------|
|
|
65
67
|
| `bwx get <field> <item>` | Get field (`password`, `username`, `totp`, `notes`, `uri`, `item`) |
|
|
66
68
|
| `bwx field <item> <name>` | Get custom field by name |
|
|
67
|
-
| `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 |
|
|
68
72
|
| `bwx attach list <item>` | List attachments for an item |
|
|
69
73
|
| `bwx attach get <item> <attachment>` | Download attachment by ID or filename |
|
|
70
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.
|
|
109
|
+
|
|
71
110
|
### Write
|
|
72
111
|
|
|
73
112
|
| Command | Description |
|
|
@@ -119,8 +158,15 @@ Supported source flags:
|
|
|
119
158
|
|------|--------|
|
|
120
159
|
| `--json` | JSON output: `{ "data": ... }` / `{ "error": ... }` |
|
|
121
160
|
| `--plain` | Tab-separated, one item per line |
|
|
122
|
-
| `-q, --quiet` | Suppress logs |
|
|
161
|
+
| `-q, --quiet` | Suppress logs and warnings |
|
|
123
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.
|
|
124
170
|
|
|
125
171
|
## Custom fields
|
|
126
172
|
|
package/package.json
CHANGED
package/src/bw/fields.ts
ADDED
|
@@ -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
|
+
}
|
package/src/bw/items.ts
ADDED
|
@@ -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?:
|
|
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", ...
|
|
44
|
+
const proc = Bun.spawn(["bw", ...args], {
|
|
27
45
|
stdout: "pipe",
|
|
28
46
|
stderr: "pipe",
|
|
29
47
|
stdin: "ignore",
|
|
30
|
-
|
|
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
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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?:
|
|
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
|
-
|
|
59
|
-
|
|
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
|
}
|