keyshroud 0.5.2 → 0.6.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/CHANGELOG.md +42 -0
- package/README.md +47 -7
- package/dist/cli.js +195 -28
- package/dist/core.d.ts +18 -4
- package/dist/core.js +268 -36
- package/dist/mcp.js +32 -17
- package/dist/redact.js +1 -1
- package/dist/refs.d.ts +2 -0
- package/dist/refs.js +26 -6
- package/package.json +1 -1
- package/skills/keyshroud/SKILL.md +6 -2
- package/src/cli.ts +140 -22
- package/src/core.ts +228 -34
- package/src/mcp.ts +24 -15
- package/src/redact.ts +1 -1
- package/src/refs.ts +23 -6
package/src/cli.ts
CHANGED
|
@@ -4,13 +4,14 @@ import { constants, homedir } from "node:os";
|
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
6
6
|
import { spawn } from "node:child_process";
|
|
7
|
+
import { randomBytes } from "node:crypto";
|
|
7
8
|
import process from "node:process";
|
|
8
9
|
import { BRAND } from "./brand.ts";
|
|
9
10
|
import * as core from "./core.ts";
|
|
10
11
|
import { runClaudeHook } from "./hooks.ts";
|
|
11
12
|
import { serveMcp } from "./mcp.ts";
|
|
12
13
|
import { buildRedactIndex, compileRedactIndex, createRedactor, createRedactStream, type RedactIndex } from "./redact.ts";
|
|
13
|
-
import { ENV_NAME_RE, isRef, looksSecret, parseDotenvStrict, parseRef, type DotenvEntry } from "./refs.ts";
|
|
14
|
+
import { ENV_NAME_RE, isLoaderEnvName, isRef, itemNameProblem, looksSecret, parseDotenvStrict, parseRef, type DotenvEntry } from "./refs.ts";
|
|
14
15
|
|
|
15
16
|
const usage = `Usage: ${BRAND.bin} <command> [options]
|
|
16
17
|
|
|
@@ -31,6 +32,11 @@ Secrets
|
|
|
31
32
|
unwire --env NAME [--target pi|omp|both]
|
|
32
33
|
|
|
33
34
|
Vault
|
|
35
|
+
setup --email EMAIL [--server URL] [--confirm-unlock] [--registered]
|
|
36
|
+
Give ${BRAND.name} its own bw login on a dedicated agent account
|
|
37
|
+
migrate [--item NAME]... [--env-file F]... [--yes]
|
|
38
|
+
Copy the wired items from your personal vault to that account
|
|
39
|
+
(dry run unless --yes; prints names only)
|
|
34
40
|
status | unlock | lock | doctor
|
|
35
41
|
|
|
36
42
|
Agents
|
|
@@ -56,17 +62,20 @@ function targetList(value: string | undefined): string[] {
|
|
|
56
62
|
if (value === "pi") return [targetPaths.pi];
|
|
57
63
|
fail("--target must be omp, pi, or both", 2);
|
|
58
64
|
}
|
|
59
|
-
// Terminal: masked prompt.
|
|
60
|
-
// dialog, so the agent can start the command and the user only pastes.
|
|
61
|
-
async function readSecret(args: string[], prompt: string,
|
|
62
|
-
|
|
63
|
-
if (
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
65
|
+
// --stdin: piped value. Terminal: masked prompt. Otherwise (an agent's shell):
|
|
66
|
+
// a native dialog, so the agent can start the command and the user only pastes.
|
|
67
|
+
async function readSecret(args: string[], prompt: string, itemName: string, envName?: string): Promise<string> {
|
|
68
|
+
if (args.includes("--stdin")) return core.readSecretFromInput({ prompt, stdin: true });
|
|
69
|
+
if (process.stdin.isTTY && !args.includes("--dialog")) return core.readSecretFromInput({ prompt, stdin: false });
|
|
70
|
+
const value = await core.readSecretFromDialog({ itemName, envName });
|
|
71
|
+
if (value) return value;
|
|
72
|
+
if (value === "") throw new Error("Empty secret");
|
|
73
|
+
fail("No terminal and no dialog here (SSH or no display). Run the command in a terminal, or pipe the value with --stdin.", 2);
|
|
74
|
+
}
|
|
75
|
+
function checkNames(envName: string, itemName: string): void {
|
|
76
|
+
if (isLoaderEnvName(envName)) fail(`${envName} changes what programs load or run; it cannot be wired to a secret`, 2);
|
|
77
|
+
const problem = itemNameProblem(itemName);
|
|
78
|
+
if (problem) fail(`Vault item name ${problem}`, 2);
|
|
70
79
|
}
|
|
71
80
|
function shellQuote(value: string): string { return `'${value.replaceAll("'", "'\\''")}'`; }
|
|
72
81
|
function failureCode(error: unknown): number {
|
|
@@ -143,9 +152,13 @@ async function inject(args: string[]): Promise<void> {
|
|
|
143
152
|
await core.audit("inject", refs, output ?? "stdout");
|
|
144
153
|
const rendered = template.replace(pattern, (_match, raw: string) => values.get(raw) ?? "");
|
|
145
154
|
if (!output) { process.stdout.write(rendered); return; }
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
await
|
|
155
|
+
// lstat, and a private temp file renamed over the target, so a planted
|
|
156
|
+
// symlink (even a dangling one) is replaced instead of followed.
|
|
157
|
+
const exists = await lstat(output).then(() => true, () => false);
|
|
158
|
+
if (exists && !args.includes("--force")) fail(`Refusing to overwrite ${output}; use --force`, 5);
|
|
159
|
+
const tmp = join(dirname(resolve(output)), `.${basename(output)}.tmp.${randomBytes(6).toString("hex")}`);
|
|
160
|
+
await writeFile(tmp, rendered, { mode: 0o600, flag: "wx" });
|
|
161
|
+
await rename(tmp, output);
|
|
149
162
|
console.error(`rendered ${refs.length} reference(s) into ${output} (mode 0600)`);
|
|
150
163
|
}
|
|
151
164
|
|
|
@@ -188,6 +201,103 @@ async function scan(paths: string[]): Promise<void> {
|
|
|
188
201
|
|
|
189
202
|
interface ImportRow { entry: DotenvEntry; kind: "new" | "reuse" | "conflict" | "literal" | "ref"; item?: string; note?: string }
|
|
190
203
|
|
|
204
|
+
// setup: keyshroud gets its own bw login (CONFIG_DIR/bw) on a separate agent
|
|
205
|
+
// account, so agents never get a session for the user's personal vault.
|
|
206
|
+
async function setupCommand(args: string[]): Promise<void> {
|
|
207
|
+
requireKnownFlags(args, ["--email", "--server"], ["--confirm-unlock", "--registered"]);
|
|
208
|
+
const email = valueAfter(args, "--email");
|
|
209
|
+
if (!email || !/^[^\s@]+@[^\s@]+$/.test(email)) fail("setup needs --email for a separate agent account (not your personal one)", 2);
|
|
210
|
+
let server = valueAfter(args, "--server");
|
|
211
|
+
if (!server) { try { server = (JSON.parse(await core.bw(["status"])) as { serverUrl?: string }).serverUrl ?? undefined; } catch {} }
|
|
212
|
+
if (!server || !/^https:\/\//.test(server)) fail("setup needs --server https://… (no bw server is configured)", 2);
|
|
213
|
+
const confirm = args.includes("--confirm-unlock");
|
|
214
|
+
if (confirm && process.platform !== "darwin") fail("--confirm-unlock needs the macOS keychain; Linux secret-tool cannot ask before each read", 2);
|
|
215
|
+
core.useOwnBwLogin();
|
|
216
|
+
await mkdir(core.OWN_BW_DIR, { recursive: true, mode: 0o700 });
|
|
217
|
+
const status = JSON.parse(await core.bw(["status"]).catch(() => "{}")) as { status?: string; userEmail?: string };
|
|
218
|
+
if (status.userEmail && status.userEmail.toLowerCase() !== email.toLowerCase()) fail(`${BRAND.name}'s bw login already belongs to ${status.userEmail}; remove ${core.OWN_BW_DIR} to start over`, 5);
|
|
219
|
+
const loggedOut = !status.status || status.status === "unauthenticated";
|
|
220
|
+
if (loggedOut) await core.bw(["config", "server", server]);
|
|
221
|
+
await unlink(core.SESSION_FILE).catch(() => {});
|
|
222
|
+
console.log(`${BRAND.name} now keeps its own bw login in ${core.OWN_BW_DIR}; your personal bw login is untouched.`);
|
|
223
|
+
const signupUrl = `${server.replace(/\/$/, "")}/#/signup?email=${encodeURIComponent(email)}`;
|
|
224
|
+
if (!process.stdin.isTTY) {
|
|
225
|
+
// An agent's shell: nothing can be typed, so keyshroud picks the password.
|
|
226
|
+
const hasEntry = Boolean(await core.readKeychain(core.KEYCHAIN_MASTER_SERVICE));
|
|
227
|
+
if (!loggedOut && (confirm || !hasEntry)) fail(`The agent account is already logged in; run \`${BRAND.bin} setup\` in a terminal to store its password`, 2);
|
|
228
|
+
if (loggedOut && !args.includes("--registered")) {
|
|
229
|
+
if (!hasEntry && !(await core.generateKeychainSecret(core.KEYCHAIN_MASTER_SERVICE, `${BRAND.name} agent account`, confirm))) fail("Could not store a generated password (needs macOS security or Linux secret-tool)", 3);
|
|
230
|
+
const master = await core.readKeychain(core.KEYCHAIN_MASTER_SERVICE);
|
|
231
|
+
if (!master || !(await core.copyToClipboard(master))) fail(`No clipboard tool; run \`${BRAND.bin} setup\` in a terminal instead`, 3);
|
|
232
|
+
spawn(process.platform === "darwin" ? "open" : "xdg-open", [signupUrl], { stdio: "ignore" }).on("error", () => {});
|
|
233
|
+
console.log(`A random master password for ${email} is in the keychain (${core.KEYCHAIN_MASTER_SERVICE}) and on the clipboard.`);
|
|
234
|
+
console.log(`Create the account at ${signupUrl} and paste it into both password fields.`);
|
|
235
|
+
console.log(`Then: ${BRAND.bin} setup --email ${email} --registered`);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (loggedOut && !args.includes("--registered")) {
|
|
240
|
+
const url = signupUrl;
|
|
241
|
+
console.log(`1. Create the agent account ${email}: ${url}`);
|
|
242
|
+
console.log(" Pick a new master password, not your personal one.");
|
|
243
|
+
spawn(process.platform === "darwin" ? "open" : "xdg-open", [url], { stdio: "ignore" }).on("error", () => {});
|
|
244
|
+
process.stdout.write(" Press Enter once the account exists. ");
|
|
245
|
+
await new Promise((resolve) => process.stdin.once("data", resolve)); process.stdin.pause();
|
|
246
|
+
}
|
|
247
|
+
if (confirm || !(await core.readKeychain(core.KEYCHAIN_MASTER_SERVICE))) {
|
|
248
|
+
console.log(`2. Type the agent account's master password for the keychain entry ${core.KEYCHAIN_MASTER_SERVICE}${confirm ? " (macOS will ask before every unlock)" : ""}:`);
|
|
249
|
+
if (!(await core.storeKeychainInteractive(core.KEYCHAIN_MASTER_SERVICE, `${BRAND.name} agent account`, confirm))) fail("Could not store it (needs macOS security or Linux secret-tool)", 3);
|
|
250
|
+
}
|
|
251
|
+
const master = await core.readKeychain(core.KEYCHAIN_MASTER_SERVICE);
|
|
252
|
+
if (!master) fail(`The keychain entry ${core.KEYCHAIN_MASTER_SERVICE} is missing or access was denied`, 3);
|
|
253
|
+
if (loggedOut) await core.loginOwnAccount(email, master); else await core.ensureSession();
|
|
254
|
+
await core.clearClipboardIf(master);
|
|
255
|
+
await core.audit("setup", [], "cli");
|
|
256
|
+
console.log(`ready: bw login=${email} (own). Next: \`${BRAND.bin} migrate\` copies the wired secrets from your personal vault.`);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// migrate: copy the items that wired names (and env files) reference from the
|
|
260
|
+
// personal vault into the agent account. Same names, so references keep working.
|
|
261
|
+
async function migrateCommand(args: string[]): Promise<void> {
|
|
262
|
+
requireKnownFlags(args, ["--item", "--env-file"], ["--yes"]);
|
|
263
|
+
if (!core.OWN_BW_LOGIN) fail(`Run \`${BRAND.bin} setup\` first: migrate copies items into ${BRAND.name}'s own agent account`, 2);
|
|
264
|
+
const entries = [...await core.authEnvEntries()];
|
|
265
|
+
for (const file of valuesAfter(args, "--env-file")) entries.push(...await core.envFileEntries(file));
|
|
266
|
+
const names = new Map<string, string>();
|
|
267
|
+
for (const entry of entries) { try { const ref = parseRef(entry.raw); if (ref) names.set(ref.item.toLowerCase(), ref.item); } catch {} }
|
|
268
|
+
for (const name of valuesAfter(args, "--item")) names.set(name.toLowerCase(), name);
|
|
269
|
+
await core.ensureSession();
|
|
270
|
+
const own = await core.findItems("");
|
|
271
|
+
const personal = await core.readPersonalItems();
|
|
272
|
+
type Row = { name: string; state: "copy" | "present" | "missing" | "ambiguous"; item?: Record<string, unknown> };
|
|
273
|
+
const rows: Row[] = [...names.values()].sort().map((name) => {
|
|
274
|
+
if (own.some((item) => item.name.toLowerCase() === name.toLowerCase() || item.id === name)) return { name, state: "present" };
|
|
275
|
+
const matches = personal.filter((item) => String(item.name ?? "").toLowerCase() === name.toLowerCase() || item.id === name);
|
|
276
|
+
return matches.length === 1 ? { name, state: "copy", item: matches[0] } : { name, state: matches.length ? "ambiguous" : "missing" };
|
|
277
|
+
});
|
|
278
|
+
const width = Math.max(4, ...rows.map((row) => row.name.length));
|
|
279
|
+
console.log("migrate plan (personal vault -> agent account):");
|
|
280
|
+
for (const row of rows) console.log(` ${row.name.padEnd(width)} ${row.state === "present" ? "already in the agent account" : row.state}`);
|
|
281
|
+
const count = (state: Row["state"]) => rows.filter((row) => row.state === state).length;
|
|
282
|
+
console.log(`${count("copy")} to copy, ${count("present")} already there, ${count("missing")} missing, ${count("ambiguous")} ambiguous`);
|
|
283
|
+
if (!args.includes("--yes")) { console.log("Dry run: nothing copied. Re-run with --yes."); return; }
|
|
284
|
+
const copied: string[] = [];
|
|
285
|
+
for (const row of rows.filter((entry) => entry.state === "copy")) {
|
|
286
|
+
try { await core.copyItemToOwnVault(row.item!); copied.push(row.name); }
|
|
287
|
+
catch { if (copied.length) console.error(`Copied before the failure: ${copied.join(", ")}`); fail(`Failed to copy ${row.name}`, 3); }
|
|
288
|
+
}
|
|
289
|
+
await core.syncOwnVault();
|
|
290
|
+
const after = await core.findItems("");
|
|
291
|
+
const unverified = copied.filter((name) => !after.some((item) => item.name.toLowerCase() === name.toLowerCase()));
|
|
292
|
+
await core.audit("migrate", copied, "cli");
|
|
293
|
+
await core.refreshRedactIndex();
|
|
294
|
+
console.log(`copied ${copied.length} item(s) into the agent account; references are unchanged.`);
|
|
295
|
+
const cut = process.platform === "darwin" ? `security delete-generic-password -s ${core.PERSONAL_KEYCHAIN_MASTER_SERVICE}` : `secret-tool clear service ${core.PERSONAL_KEYCHAIN_MASTER_SERVICE} account "$USER"`;
|
|
296
|
+
console.log(`Your personal master password is still in the keychain, where any program running as you can read it. Once you no longer need it there: ${cut}`);
|
|
297
|
+
if (unverified.length) fail(`Not found after copying: ${unverified.join(", ")}`, 4);
|
|
298
|
+
if (count("missing") || count("ambiguous")) process.exitCode = 4;
|
|
299
|
+
}
|
|
300
|
+
|
|
191
301
|
async function importCommand(args: string[]): Promise<void> {
|
|
192
302
|
requireKnownFlags(args, ["--from", "--out", "--prefix", "--only", "--skip"], ["--yes", "--replace", "--force"]);
|
|
193
303
|
const from = valueAfter(args, "--from") ?? ".env";
|
|
@@ -261,9 +371,9 @@ async function importCommand(args: string[]): Promise<void> {
|
|
|
261
371
|
const eol = text.includes("\r\n") ? "\r\n" : "\n";
|
|
262
372
|
const lines = text.split(/\r?\n/);
|
|
263
373
|
for (const row of toStore) lines[row.entry.line - 1] = `${row.entry.exported ? "export " : ""}${row.entry.name}=bw://${row.item}`;
|
|
264
|
-
const mode = replace ? (await stat(out)).mode & 0o777 :
|
|
265
|
-
const tmp = `${out}.tmp.${
|
|
266
|
-
await writeFile(tmp, lines.join(eol), { mode });
|
|
374
|
+
const mode = replace ? (await stat(out)).mode & 0o777 : 0o600;
|
|
375
|
+
const tmp = `${out}.tmp.${randomBytes(6).toString("hex")}`;
|
|
376
|
+
await writeFile(tmp, lines.join(eol), { mode, flag: "wx" });
|
|
267
377
|
await rename(tmp, out);
|
|
268
378
|
await core.audit("import", toStore.map((row) => row.entry.name), "cli");
|
|
269
379
|
console.log(`imported ${toStore.length} secret(s) (${created.length} new); wrote ${out}`);
|
|
@@ -328,29 +438,37 @@ async function main(): Promise<void> {
|
|
|
328
438
|
requireKnownFlags(args, ["--env", "--item", "--notes", "--target"], ["--stdin", "--dialog"]);
|
|
329
439
|
const envName = valueAfter(args, "--env"); const itemName = valueAfter(args, "--item"); const notes = valueAfter(args, "--notes");
|
|
330
440
|
if (!envName || !itemName || !ENV_NAME_RE.test(envName)) fail(usage, 2);
|
|
441
|
+
checkNames(envName, itemName);
|
|
331
442
|
const reference = core.referenceFor(itemName);
|
|
332
443
|
const targets = targetList(valueAfter(args, "--target"));
|
|
333
444
|
await core.ensureSession();
|
|
334
|
-
|
|
445
|
+
if ((await core.findItems(itemName)).some((item) => item.name.toLowerCase() === itemName.toLowerCase())) fail(`Vault item already exists: ${itemName}`, 5);
|
|
446
|
+
const secret = await readSecret(args, `Paste secret for ${envName}: `, itemName, envName);
|
|
335
447
|
await core.createSecretItem({ itemName, secret, envName, notes });
|
|
448
|
+
await core.audit("add", [envName], "cli");
|
|
336
449
|
let conflict = false;
|
|
337
450
|
for (const path of targets) { const result = await core.writeAuthEntry(path, envName, reference); if (!result.ok) conflict = true; }
|
|
338
451
|
const verified = (await core.resolveShellValue(reference)) !== null;
|
|
452
|
+
await core.refreshRedactIndex();
|
|
339
453
|
console.log(`created item=${itemName} env=${envName} targets=${targets.join(",")} verified=${verified ? "yes" : "no"}`);
|
|
340
454
|
if (conflict) { console.error("Item created, but one or more auth targets already contained the environment name."); process.exitCode = 5; }
|
|
341
455
|
return;
|
|
342
456
|
}
|
|
343
457
|
if (command === "import") { await importCommand(args); return; }
|
|
458
|
+
if (command === "setup") { await setupCommand(args); return; }
|
|
459
|
+
if (command === "migrate") { await migrateCommand(args); return; }
|
|
344
460
|
if (command === "rotate") {
|
|
345
461
|
requireKnownFlags(args, ["--item"], ["--stdin", "--dialog"]);
|
|
346
462
|
const itemName = valueAfter(args, "--item"); if (!itemName) fail(usage, 2);
|
|
347
|
-
await core.ensureSession(); const secret = await readSecret(args, "Paste replacement secret: ",
|
|
348
|
-
await core.rotateSecretItem({ itemName, secret });
|
|
463
|
+
await core.ensureSession(); const secret = await readSecret(args, "Paste replacement secret: ", itemName);
|
|
464
|
+
await core.rotateSecretItem({ itemName, secret }); await core.audit("rotate", [itemName], "cli"); await core.refreshRedactIndex();
|
|
465
|
+
console.log(`rotated item=${itemName}`); return;
|
|
349
466
|
}
|
|
350
467
|
if (command === "wire") {
|
|
351
468
|
requireKnownFlags(args, ["--env", "--item", "--target"], ["--force"]);
|
|
352
469
|
const envName = valueAfter(args, "--env"); const itemName = valueAfter(args, "--item");
|
|
353
470
|
if (!envName || !itemName || !ENV_NAME_RE.test(envName)) fail(usage, 2);
|
|
471
|
+
if (isLoaderEnvName(envName)) fail(`${envName} changes what programs load or run; it cannot be wired to a secret`, 2);
|
|
354
472
|
const reference = core.referenceFor(itemName);
|
|
355
473
|
const targets = targetList(valueAfter(args, "--target"));
|
|
356
474
|
for (const path of targets) { const result = await core.writeAuthEntry(path, envName, reference, { overwrite: args.includes("--force") }); if (!result.ok) fail(result.message, 5); }
|
|
@@ -388,7 +506,7 @@ async function main(): Promise<void> {
|
|
|
388
506
|
if (command === "doctor") {
|
|
389
507
|
requireKnownFlags(args, []);
|
|
390
508
|
try { await core.ensureSession(); } catch {}
|
|
391
|
-
const s = await core.getStatus(); console.log(`bw version=${s.version ?? "missing (install @bitwarden/cli)"}`); console.log(`bw server=${s.serverUrl ?? "unconfigured (run: bw config server <url>)"}`); console.log(`session=${s.status}`);
|
|
509
|
+
const s = await core.getStatus(); console.log(`bw login=${core.OWN_BW_LOGIN ? `own agent account (${core.OWN_BW_DIR})` : `shared with your personal bw (run: ${BRAND.bin} setup)`}`); console.log(`bw version=${s.version ?? "missing (install @bitwarden/cli)"}`); console.log(`bw server=${s.serverUrl ?? "unconfigured (run: bw config server <url>)"}`); console.log(`session=${s.status}`);
|
|
392
510
|
for (const [service, present] of Object.entries(await core.keychainPresence())) console.log(`keychain ${service}=${present ? "present" : "missing"}`);
|
|
393
511
|
for (const path of [targetPaths.omp, targetPaths.pi]) for (const name of Object.keys(await core.readAuthEntries(path))) console.log(`${path} ${name} resolved=${(await authValue(name)) ? "yes" : "no"}`);
|
|
394
512
|
console.log(`redact index=${(await core.readRedactIndex()) ? "present" : "missing"}`);
|
package/src/core.ts
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
// Runtime-neutral core: no pi/omp/Claude Code imports. Every caller (agent
|
|
2
2
|
// extensions, CLI, MCP server, hooks) goes through these functions.
|
|
3
|
-
import { execFile } from "node:child_process";
|
|
3
|
+
import { execFile, spawn } from "node:child_process";
|
|
4
|
+
import { randomBytes } from "node:crypto";
|
|
5
|
+
import { existsSync } from "node:fs";
|
|
4
6
|
import { appendFile, chmod, mkdir, readFile, realpath, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
|
|
5
7
|
import { homedir } from "node:os";
|
|
6
8
|
import { join, dirname } from "node:path";
|
|
7
9
|
import process from "node:process";
|
|
8
10
|
import { BRAND } from "./brand.ts";
|
|
9
11
|
import { buildRedactIndex, type RedactIndex } from "./redact.ts";
|
|
10
|
-
import { ENV_NAME_RE, legacyRefFor, parseEnvFile, parseRef, type SecretRef } from "./refs.ts";
|
|
12
|
+
import { ENV_NAME_RE, isLoaderEnvName, itemNameProblem, legacyRefFor, parseEnvFile, parseRef, type SecretRef } from "./refs.ts";
|
|
11
13
|
|
|
12
14
|
const P = BRAND.envPrefix;
|
|
13
15
|
function setting(name: string): string | undefined {
|
|
@@ -20,9 +22,25 @@ export const SESSION_FILE = join(CONFIG_DIR, "session");
|
|
|
20
22
|
export const MASTER_PASSWORD_FILE = join(CONFIG_DIR, "master-password");
|
|
21
23
|
export const REDACT_INDEX_FILE = join(CONFIG_DIR, "redact-index.json");
|
|
22
24
|
export const AUDIT_FILE = join(CONFIG_DIR, "audit.jsonl");
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
25
|
+
|
|
26
|
+
// After `setup`, keyshroud keeps its own bw login (a dedicated agent account) in
|
|
27
|
+
// CONFIG_DIR/bw, with its own keychain entries, so the user's personal bw login
|
|
28
|
+
// and vault are never unlocked. Without it, keyshroud shares the default bw login.
|
|
29
|
+
export const OWN_BW_DIR = join(CONFIG_DIR, "bw");
|
|
30
|
+
export const PERSONAL_KEYCHAIN_MASTER_SERVICE = setting("PERSONAL_KEYCHAIN_MASTER") ?? "vaultwarden-master-password";
|
|
31
|
+
export let OWN_BW_LOGIN = false;
|
|
32
|
+
export let KEYCHAIN_MASTER_SERVICE = setting("KEYCHAIN_MASTER") ?? PERSONAL_KEYCHAIN_MASTER_SERVICE;
|
|
33
|
+
export let KEYCHAIN_CLIENT_ID_SERVICE = setting("KEYCHAIN_CLIENT_ID") ?? "vaultwarden-api-client-id";
|
|
34
|
+
export let KEYCHAIN_CLIENT_SECRET_SERVICE = setting("KEYCHAIN_CLIENT_SECRET") ?? "vaultwarden-api-client-secret";
|
|
35
|
+
|
|
36
|
+
export function useOwnBwLogin(): void {
|
|
37
|
+
OWN_BW_LOGIN = true;
|
|
38
|
+
process.env.BITWARDENCLI_APPDATA_DIR = OWN_BW_DIR;
|
|
39
|
+
KEYCHAIN_MASTER_SERVICE = setting("KEYCHAIN_MASTER") ?? `${BRAND.bin}-master-password`;
|
|
40
|
+
KEYCHAIN_CLIENT_ID_SERVICE = setting("KEYCHAIN_CLIENT_ID") ?? `${BRAND.bin}-api-client-id`;
|
|
41
|
+
KEYCHAIN_CLIENT_SECRET_SERVICE = setting("KEYCHAIN_CLIENT_SECRET") ?? `${BRAND.bin}-api-client-secret`;
|
|
42
|
+
}
|
|
43
|
+
if (!process.env.BITWARDENCLI_APPDATA_DIR && existsSync(join(OWN_BW_DIR, "data.json"))) useOwnBwLogin();
|
|
26
44
|
|
|
27
45
|
export const PROVIDER_KEYS: ReadonlySet<string> = new Set([
|
|
28
46
|
"anthropic", "openai", "azure-openai-responses", "deepseek", "google", "mistral",
|
|
@@ -66,12 +84,13 @@ export const referenceFor = legacyRefFor;
|
|
|
66
84
|
|
|
67
85
|
// Vault credentials never reach commands run on the agent's behalf: with the
|
|
68
86
|
// session token a child could dump the whole vault, defeating `--only`.
|
|
69
|
-
export const VAULT_ENV_KEYS: readonly string[] = ["BW_SESSION", "BW_PASSWORD", "BW_CLIENTID", "BW_CLIENTSECRET"];
|
|
87
|
+
export const VAULT_ENV_KEYS: readonly string[] = ["BW_SESSION", "BW_PASSWORD", "BW_CLIENTID", "BW_CLIENTSECRET", "BITWARDENCLI_APPDATA_DIR"];
|
|
70
88
|
|
|
71
89
|
export function childEnv(extra: Record<string, string> = {}): Record<string, string> {
|
|
72
90
|
const env: Record<string, string> = {};
|
|
73
91
|
for (const [name, value] of Object.entries(process.env)) if (value !== undefined && !VAULT_ENV_KEYS.includes(name)) env[name] = value;
|
|
74
|
-
|
|
92
|
+
for (const [name, value] of Object.entries(extra)) if (!isLoaderEnvName(name)) env[name] = value;
|
|
93
|
+
return env;
|
|
75
94
|
}
|
|
76
95
|
|
|
77
96
|
export async function bw(args: string[], opts: { input?: string; session?: string } = {}): Promise<string> {
|
|
@@ -203,10 +222,12 @@ export async function resolveRef(ref: SecretRef): Promise<string | null> {
|
|
|
203
222
|
}
|
|
204
223
|
}
|
|
205
224
|
|
|
206
|
-
|
|
225
|
+
// The timeout leaves room for a macOS "allow access" prompt when the entry was
|
|
226
|
+
// stored with `setup --confirm-unlock`; a missing entry still fails at once.
|
|
227
|
+
export async function readKeychain(service: string): Promise<string | null> {
|
|
207
228
|
const user = process.env.USER || process.env.LOGNAME || "";
|
|
208
229
|
try {
|
|
209
|
-
const result = await runFile("security", ["find-generic-password", "-a", user, "-s", service, "-w"], { timeout:
|
|
230
|
+
const result = await runFile("security", ["find-generic-password", "-a", user, "-s", service, "-w"], { timeout: 120000, maxBuffer: 1024 * 1024 });
|
|
210
231
|
return result.stdout.trim() || null;
|
|
211
232
|
} catch {}
|
|
212
233
|
try {
|
|
@@ -251,8 +272,8 @@ export function ensureSession(): Promise<string> {
|
|
|
251
272
|
|
|
252
273
|
async function writePrivateFile(path: string, content: string): Promise<void> {
|
|
253
274
|
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
254
|
-
const tmp = `${path}.tmp.${
|
|
255
|
-
await writeFile(tmp, content, { encoding: "utf8", mode: 0o600 });
|
|
275
|
+
const tmp = `${path}.tmp.${randomBytes(6).toString("hex")}`;
|
|
276
|
+
await writeFile(tmp, content, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
256
277
|
await chmod(tmp, 0o600);
|
|
257
278
|
await rename(tmp, path);
|
|
258
279
|
await chmod(path, 0o600);
|
|
@@ -292,6 +313,114 @@ async function ensureSessionInner(existing: string | undefined): Promise<string>
|
|
|
292
313
|
return token;
|
|
293
314
|
}
|
|
294
315
|
|
|
316
|
+
// Store a keychain entry by letting the OS tool prompt for the value on the
|
|
317
|
+
// user's terminal, so keyshroud never holds it in argv. `confirm` (macOS) trusts
|
|
318
|
+
// no app, so every later read asks the user first.
|
|
319
|
+
export async function storeKeychainInteractive(service: string, label: string, confirm: boolean): Promise<boolean> {
|
|
320
|
+
const user = process.env.USER || process.env.LOGNAME || "";
|
|
321
|
+
const attempts: Array<[string, string[]]> = process.platform === "darwin"
|
|
322
|
+
? [["security", ["add-generic-password", "-U", "-a", user, "-s", service, "-l", label, ...(confirm ? ["-T", ""] : []), "-w"]]]
|
|
323
|
+
: [["secret-tool", ["store", `--label=${label}`, "service", service, "account", user]]];
|
|
324
|
+
// -U keeps an existing entry's access list, so an ask-every-time entry is recreated.
|
|
325
|
+
if (process.platform === "darwin" && confirm) await runFile("security", ["delete-generic-password", "-a", user, "-s", service], { timeout: 10000, maxBuffer: 1024 * 1024 }).catch(() => {});
|
|
326
|
+
for (const [command, args] of attempts) {
|
|
327
|
+
const code = await new Promise<number | null>((resolve) => {
|
|
328
|
+
const child = spawn(command, args, { stdio: "inherit" });
|
|
329
|
+
child.on("error", () => resolve(null));
|
|
330
|
+
child.on("exit", (exit) => resolve(exit));
|
|
331
|
+
});
|
|
332
|
+
if (code === 0) return true;
|
|
333
|
+
}
|
|
334
|
+
return false;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// `setup` from an agent's shell (no terminal): a random master password for the
|
|
338
|
+
// new agent account, written to the keychain over stdin. Nobody types it and it
|
|
339
|
+
// never reaches argv or output; the user gets it via the clipboard for signup.
|
|
340
|
+
export async function generateKeychainSecret(service: string, label: string, confirm: boolean): Promise<boolean> {
|
|
341
|
+
const secret = randomBytes(24).toString("base64url");
|
|
342
|
+
const user = process.env.USER || process.env.LOGNAME || "";
|
|
343
|
+
const opts = { timeout: 30000, maxBuffer: 1024 * 1024 };
|
|
344
|
+
try {
|
|
345
|
+
if (process.platform === "darwin") {
|
|
346
|
+
// `security -i` reads the command from stdin, so the value is not in argv.
|
|
347
|
+
await runFile("security", ["-i"], opts, `add-generic-password -U -a "${user}" -s "${service}" -l "${label}"${confirm ? ' -T ""' : ""} -w "${secret}"\n`);
|
|
348
|
+
await runFile("security", ["find-generic-password", "-a", user, "-s", service], opts);
|
|
349
|
+
} else {
|
|
350
|
+
await runFile("secret-tool", ["store", `--label=${label}`, "service", service, "account", user], opts, secret);
|
|
351
|
+
}
|
|
352
|
+
return true;
|
|
353
|
+
} catch { return false; }
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const CLIPBOARD_TOOLS: Array<[string, string[], string, string[]]> = process.platform === "darwin"
|
|
357
|
+
? [["pbcopy", [], "pbpaste", []]]
|
|
358
|
+
: [["wl-copy", [], "wl-paste", ["--no-newline"]], ["xclip", ["-selection", "clipboard"], "xclip", ["-selection", "clipboard", "-o"]]];
|
|
359
|
+
|
|
360
|
+
export async function copyToClipboard(text: string): Promise<boolean> {
|
|
361
|
+
for (const [copy, args] of CLIPBOARD_TOOLS) {
|
|
362
|
+
try { await runFile(copy, args, { timeout: 10000, maxBuffer: 1024 * 1024 }, text); return true; } catch {}
|
|
363
|
+
}
|
|
364
|
+
return false;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Empty the clipboard only if it still holds `text`.
|
|
368
|
+
export async function clearClipboardIf(text: string): Promise<void> {
|
|
369
|
+
for (const [copy, copyArgs, paste, pasteArgs] of CLIPBOARD_TOOLS) {
|
|
370
|
+
try {
|
|
371
|
+
if ((await runFile(paste, pasteArgs, { timeout: 10000, maxBuffer: 1024 * 1024 })).stdout.trim() === text) await runFile(copy, copyArgs, { timeout: 10000, maxBuffer: 1024 * 1024 }, "");
|
|
372
|
+
return;
|
|
373
|
+
} catch {}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// `setup`: log keyshroud's own bw login in to the agent account. stdin and
|
|
378
|
+
// stderr stay on the terminal so bw can ask for a two-step login code.
|
|
379
|
+
export async function loginOwnAccount(email: string, master: string): Promise<string> {
|
|
380
|
+
const token = await new Promise<string>((resolve, reject) => {
|
|
381
|
+
const child = spawn("bw", ["login", email, "--passwordenv", "BW_PASSWORD", "--raw"], { env: { ...process.env, BW_PASSWORD: master }, stdio: ["inherit", "pipe", "inherit"] });
|
|
382
|
+
let out = "";
|
|
383
|
+
child.stdout.on("data", (chunk: Buffer) => { out += chunk; });
|
|
384
|
+
child.on("error", reject);
|
|
385
|
+
child.on("exit", (exit) => (exit === 0 && out.trim() ? resolve(out.trim()) : reject(new Error("bw login failed"))));
|
|
386
|
+
});
|
|
387
|
+
await writePrivateFile(SESSION_FILE, `${token}\n`);
|
|
388
|
+
process.env.BW_SESSION = token;
|
|
389
|
+
try { await bw(["sync", "--quiet"], { session: token }); } catch {}
|
|
390
|
+
invalidateSecretCache();
|
|
391
|
+
return token;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// `migrate`: read the items of the bw login the user had before setup (the
|
|
395
|
+
// default bw data dir), then lock it again. The values stay in memory.
|
|
396
|
+
export async function readPersonalItems(): Promise<Array<Record<string, unknown>>> {
|
|
397
|
+
const master = await readKeychain(PERSONAL_KEYCHAIN_MASTER_SERVICE);
|
|
398
|
+
if (!master) throw new Error(`Personal master password not in the keychain (${PERSONAL_KEYCHAIN_MASTER_SERVICE})`);
|
|
399
|
+
const env = (extra: Record<string, string>) => {
|
|
400
|
+
const out: Record<string, string> = {};
|
|
401
|
+
for (const [name, value] of Object.entries(process.env)) if (value !== undefined && !VAULT_ENV_KEYS.includes(name)) out[name] = value;
|
|
402
|
+
return { ...out, ...extra };
|
|
403
|
+
};
|
|
404
|
+
const token = (await runFile("bw", ["unlock", "--passwordenv", "BW_PASSWORD", "--raw"], { env: env({ BW_PASSWORD: master }), timeout: 30000, maxBuffer: 4 * 1024 * 1024 })).stdout.trim();
|
|
405
|
+
if (!token) throw new Error("Personal vault unlock returned no session");
|
|
406
|
+
try {
|
|
407
|
+
const parsed = JSON.parse((await runFile("bw", ["list", "items"], { env: env({ BW_SESSION: token }), timeout: 120000, maxBuffer: 256 * 1024 * 1024 })).stdout) as unknown;
|
|
408
|
+
return Array.isArray(parsed) ? parsed as Array<Record<string, unknown>> : [];
|
|
409
|
+
} finally {
|
|
410
|
+
try { await runFile("bw", ["lock"], { env: env({}), timeout: 20000, maxBuffer: 1024 * 1024 }); } catch {}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// A personal item as a new item for the agent account: no ids, folders,
|
|
415
|
+
// organizations, history, attachments or per-item key (encrypted to the personal account).
|
|
416
|
+
export async function copyItemToOwnVault(item: Record<string, unknown>): Promise<void> {
|
|
417
|
+
const copy: Record<string, unknown> = { ...item, favorite: false };
|
|
418
|
+
for (const key of ["id", "key", "object", "organizationId", "folderId", "collectionIds", "revisionDate", "creationDate", "deletedDate", "passwordHistory", "attachments"]) delete copy[key];
|
|
419
|
+
await bw(["create", "item"], { input: Buffer.from(JSON.stringify(copy), "utf8").toString("base64") });
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
export async function syncOwnVault(): Promise<void> { await bw(["sync", "--quiet"]); invalidateSecretCache(); }
|
|
423
|
+
|
|
295
424
|
// Forget the cached session and lock the bw CLI. The next resolve unlocks
|
|
296
425
|
// again from the keychain.
|
|
297
426
|
export async function lock(): Promise<void> {
|
|
@@ -455,6 +584,7 @@ export async function resolveEntries(entries: Array<{ name: string; raw: string
|
|
|
455
584
|
export async function envFileEntries(path: string): Promise<Array<{ name: string; raw: string }>> {
|
|
456
585
|
return parseEnvFile(await readFile(path, "utf8")).map(({ name, value }) => {
|
|
457
586
|
if (value.trim().startsWith("!")) throw new Error(`${path}: ${name} uses a shell command; env files accept only bw:// references and literals`);
|
|
587
|
+
if (isLoaderEnvName(name)) throw new Error(`${path}: ${name} changes what programs load or run; env files cannot set it`);
|
|
458
588
|
return { name, raw: value };
|
|
459
589
|
});
|
|
460
590
|
}
|
|
@@ -504,6 +634,12 @@ export async function writeRedactIndex(values: Record<string, string>): Promise<
|
|
|
504
634
|
return index;
|
|
505
635
|
}
|
|
506
636
|
|
|
637
|
+
// Re-index after the wired set changes so hooks mask a new value right away.
|
|
638
|
+
export async function refreshRedactIndex(): Promise<void> {
|
|
639
|
+
const env = await resolveEntries(await authEnvEntries());
|
|
640
|
+
await writeRedactIndex(indexValues(env.secrets));
|
|
641
|
+
}
|
|
642
|
+
|
|
507
643
|
export async function readRedactIndex(): Promise<RedactIndex | null> {
|
|
508
644
|
try {
|
|
509
645
|
const parsed = JSON.parse(await readFile(REDACT_INDEX_FILE, "utf8")) as RedactIndex;
|
|
@@ -531,7 +667,16 @@ export async function getSecret(itemName: string): Promise<string> {
|
|
|
531
667
|
try { return await bw(["get", "password", itemName]); } catch (error) { throw mapItemError(error, itemName); }
|
|
532
668
|
}
|
|
533
669
|
|
|
670
|
+
// An agent can start add/rotate, so a dialog could be used to phish the master
|
|
671
|
+
// password into an item it can then read; refuse it outright.
|
|
672
|
+
async function refuseMasterPassword(secret: string): Promise<void> {
|
|
673
|
+
if (secret && secret === await readMasterPassword()) throw new Error("Refusing to store the vault master password as a secret");
|
|
674
|
+
}
|
|
675
|
+
|
|
534
676
|
export async function createSecretItem(opts: { itemName: string; secret: string; envName?: string; notes?: string }): Promise<{ id: string }> {
|
|
677
|
+
const problem = itemNameProblem(opts.itemName);
|
|
678
|
+
if (problem) throw new Error(`Vault item name ${problem}`);
|
|
679
|
+
await refuseMasterPassword(opts.secret);
|
|
535
680
|
const existing = await findItems(opts.itemName);
|
|
536
681
|
if (existing.some((item) => item.name.toLowerCase() === opts.itemName.toLowerCase())) throw new Error(`Vault item already exists: ${opts.itemName}`);
|
|
537
682
|
const item: Record<string, unknown> = {
|
|
@@ -548,6 +693,7 @@ export async function createSecretItem(opts: { itemName: string; secret: string;
|
|
|
548
693
|
}
|
|
549
694
|
|
|
550
695
|
export async function rotateSecretItem(opts: { itemName: string; secret: string }): Promise<{ id: string }> {
|
|
696
|
+
await refuseMasterPassword(opts.secret);
|
|
551
697
|
let parsed: Record<string, unknown>;
|
|
552
698
|
try { parsed = JSON.parse(await bw(["get", "item", opts.itemName])) as Record<string, unknown>; } catch (error) { throw mapItemError(error, opts.itemName); }
|
|
553
699
|
const login = parsed.login && typeof parsed.login === "object" ? parsed.login as Record<string, unknown> : {};
|
|
@@ -565,45 +711,82 @@ export async function deleteItem(id: string, permanent = false): Promise<void> {
|
|
|
565
711
|
}
|
|
566
712
|
|
|
567
713
|
export async function readSecretFromInput(opts: { prompt: string; stdin: boolean }): Promise<string> {
|
|
568
|
-
if (opts.stdin
|
|
714
|
+
if (opts.stdin) {
|
|
569
715
|
let input = "";
|
|
570
716
|
for await (const chunk of process.stdin) input += String(chunk);
|
|
571
717
|
input = input.replace(/\r?\n$/, "");
|
|
572
718
|
if (!input) throw new Error("Empty secret");
|
|
573
719
|
return input;
|
|
574
720
|
}
|
|
721
|
+
if (!process.stdin.isTTY) throw new Error("No terminal to prompt on; pipe the value with --stdin");
|
|
575
722
|
const value = await readMasked(opts.prompt);
|
|
576
723
|
if (!value) throw new Error("Empty secret");
|
|
577
724
|
return value;
|
|
578
725
|
}
|
|
579
726
|
|
|
580
727
|
// Native hidden-input dialog for callers without a terminal (agent shells, MCP).
|
|
581
|
-
//
|
|
582
|
-
//
|
|
583
|
-
//
|
|
584
|
-
|
|
728
|
+
// An agent can open it, so the wording is fixed, says who asked, and warns
|
|
729
|
+
// against the master password; names are validated and passed as data (argv),
|
|
730
|
+
// never as script. One dialog at a time, with a pause after a cancel. Resolves
|
|
731
|
+
// null when no dialog can be shown (SSH, headless Linux, missing tools).
|
|
732
|
+
const DIALOG_LOCK = join(CONFIG_DIR, "dialog.lock");
|
|
733
|
+
const DIALOG_COOLDOWN_MS = 30_000;
|
|
734
|
+
const DIALOG_TIMEOUT_MS = 300_000;
|
|
735
|
+
|
|
736
|
+
export async function readSecretFromDialog(opts: { itemName: string; envName?: string }): Promise<string | null> {
|
|
585
737
|
if (process.env.SSH_CONNECTION || process.env.SSH_TTY) return null;
|
|
586
|
-
const
|
|
738
|
+
const problem = itemNameProblem(opts.itemName);
|
|
739
|
+
if (problem) throw new Error(`Vault item name ${problem}`);
|
|
740
|
+
if (opts.envName !== undefined && !ENV_NAME_RE.test(opts.envName)) throw new Error("Variable names must be UPPER_SNAKE_CASE");
|
|
741
|
+
const text = [
|
|
742
|
+
opts.envName ? "A program on this computer, usually an AI agent, asked to store a NEW secret." : "A program on this computer, usually an AI agent, asked to REPLACE the value of a secret.",
|
|
743
|
+
"",
|
|
744
|
+
...(opts.envName ? [`Variable: ${opts.envName}`] : []),
|
|
745
|
+
`Vault item: ${opts.itemName}`,
|
|
746
|
+
"",
|
|
747
|
+
"This is not a login prompt. Never enter your vault master password here.",
|
|
748
|
+
].join("\n");
|
|
587
749
|
const candidates: Array<[string, string[]]> = process.platform === "darwin"
|
|
588
|
-
? [["osascript", ["-e",
|
|
589
|
-
'
|
|
590
|
-
|
|
591
|
-
'text returned of (display dialog t default answer "" with hidden answer with title "keyshroud" buttons {"Cancel", "Save"} default button "Save" cancel button "Cancel")',
|
|
592
|
-
].join("\n")]]]
|
|
750
|
+
? [["osascript", ["-e", "on run argv", "-e", 'activate', "-e",
|
|
751
|
+
'text returned of (display dialog (item 1 of argv) default answer "" with hidden answer with title "keyshroud" buttons {"Cancel", "Save"} default button "Save" cancel button "Cancel" with icon caution)',
|
|
752
|
+
"-e", "end run", "--", text]]]
|
|
593
753
|
: process.env.DISPLAY || process.env.WAYLAND_DISPLAY
|
|
594
|
-
? [["zenity", ["--entry", "--hide-text", "--title=keyshroud", `--text=${text}`]], ["kdialog", ["--title", "keyshroud", "--password", text]]]
|
|
754
|
+
? [["zenity", ["--entry", "--hide-text", "--no-markup", "--title=keyshroud", `--text=${text}`]], ["kdialog", ["--title", "keyshroud", "--password", text.replace(/[<>]/g, "")]]]
|
|
595
755
|
: [];
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
756
|
+
if (!candidates.length) return null;
|
|
757
|
+
return withDialogLock(async () => {
|
|
758
|
+
for (const [command, args] of candidates) {
|
|
759
|
+
const result = await new Promise<{ code: number | string | null; stdout: string; stderr: string }>((resolve) => {
|
|
760
|
+
execFile(command, args, { timeout: DIALOG_TIMEOUT_MS }, (error, stdout, stderr) => resolve({ code: error ? ((error as NodeJS.ErrnoException).code ?? 1) : 0, stdout: String(stdout), stderr: String(stderr) }));
|
|
761
|
+
});
|
|
762
|
+
if (result.code === "ENOENT") continue;
|
|
763
|
+
if (result.code === 0) return result.stdout.replace(/\r?\n$/, "");
|
|
764
|
+
if (command !== "osascript" || /-128/.test(result.stderr)) throw new Error("Cancelled");
|
|
765
|
+
return null;
|
|
766
|
+
}
|
|
604
767
|
return null;
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
async function withDialogLock<T>(fn: () => Promise<T>): Promise<T> {
|
|
772
|
+
await mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
773
|
+
let held = "";
|
|
774
|
+
try { held = await readFile(DIALOG_LOCK, "utf8"); } catch {}
|
|
775
|
+
const [kind, at] = held.trim().split(":");
|
|
776
|
+
const age = Date.now() - Number(at);
|
|
777
|
+
const live = (kind === "open" || kind === "cancelled") && age >= 0 && age < (kind === "cancelled" ? DIALOG_COOLDOWN_MS : DIALOG_TIMEOUT_MS + 30_000);
|
|
778
|
+
if (!live) await rm(DIALOG_LOCK, { force: true });
|
|
779
|
+
try { await writeFile(DIALOG_LOCK, `open:${Date.now()}\n`, { flag: "wx", mode: 0o600 }); }
|
|
780
|
+
catch { throw new Error(kind === "cancelled" ? "The last dialog was cancelled; wait 30 seconds before asking again" : "Another keyshroud dialog is already open"); }
|
|
781
|
+
try {
|
|
782
|
+
const value = await fn();
|
|
783
|
+
await rm(DIALOG_LOCK, { force: true });
|
|
784
|
+
return value;
|
|
785
|
+
} catch (error) {
|
|
786
|
+
if (error instanceof Error && error.message === "Cancelled") await writeFile(DIALOG_LOCK, `cancelled:${Date.now()}\n`, { mode: 0o600 });
|
|
787
|
+
else await rm(DIALOG_LOCK, { force: true });
|
|
788
|
+
throw error;
|
|
605
789
|
}
|
|
606
|
-
return null;
|
|
607
790
|
}
|
|
608
791
|
|
|
609
792
|
// Raw-mode prompt that echoes one "*" per character (capped, then a count), so
|
|
@@ -616,14 +799,25 @@ function readMasked(prompt: string): Promise<string> {
|
|
|
616
799
|
process.stderr.write(`\r\x1b[2K${prompt}${shown}`);
|
|
617
800
|
};
|
|
618
801
|
return new Promise((resolve, reject) => {
|
|
802
|
+
// After Enter, keep reading briefly: the rest of a multi-line paste must be
|
|
803
|
+
// consumed here (or it would reach the parent shell) and is then refused.
|
|
804
|
+
let ending: ReturnType<typeof setTimeout> | undefined;
|
|
805
|
+
let multiline = false;
|
|
806
|
+
const settle = () => {
|
|
807
|
+
clearTimeout(ending);
|
|
808
|
+
ending = setTimeout(() => finish(multiline ? new Error("Multi-line values are not supported in the prompt; pipe them with --stdin") : undefined), 100);
|
|
809
|
+
};
|
|
619
810
|
const finish = (error?: Error) => {
|
|
811
|
+
clearTimeout(ending);
|
|
620
812
|
stdin.off("data", onData); stdin.setRawMode(false); stdin.pause();
|
|
621
813
|
process.stderr.write("\n");
|
|
622
814
|
if (error) reject(error); else resolve(chars.join(""));
|
|
623
815
|
};
|
|
624
816
|
const onData = (chunk: string) => {
|
|
625
|
-
|
|
626
|
-
|
|
817
|
+
if (ending) { if (/[^\r\n]/.test(chunk)) multiline = true; return settle(); }
|
|
818
|
+
const clean = [...chunk.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b./g, "")];
|
|
819
|
+
for (const [index, ch] of clean.entries()) {
|
|
820
|
+
if (ch === "\r" || ch === "\n") { if (/[^\r\n]/.test(clean.slice(index + 1).join(""))) multiline = true; return settle(); }
|
|
627
821
|
if (ch === "\x03") return finish(new Error("Cancelled"));
|
|
628
822
|
if (ch === "\x04") return chars.length ? finish() : finish(new Error("Cancelled"));
|
|
629
823
|
if (ch === "\x7f" || ch === "\b") chars.pop();
|