diff --git a/js/packages/cli/src/sinks.ts b/js/packages/cli/src/sinks.ts index 61d52838..a4f4ac50 100644 --- a/js/packages/cli/src/sinks.ts +++ b/js/packages/cli/src/sinks.ts @@ -159,12 +159,28 @@ export async function stdinSink( }); } -const CLIPBOARD_COMMANDS: Array<{ cmd: string; args: string[] }> = [ - { cmd: "wl-copy", args: [] }, - { cmd: "xclip", args: ["-selection", "clipboard"] }, - { cmd: "xsel", args: ["--clipboard", "--input"] }, - { cmd: "pbcopy", args: [] }, - { cmd: "clip.exe", args: [] }, +/** + * Clipboard tools, each paired with the command that reads the same + * selection back. The read command lets a scheduled clear check that the + * clipboard still holds *our* secret before wiping it, so it never clobbers + * something the user copied in the meantime. `paste: null` means the platform + * has a writer but no reliable reader here (Windows `clip.exe`). + */ +const CLIPBOARD_TOOLS: Array<{ + copy: { cmd: string; args: string[] }; + paste: { cmd: string; args: string[] } | null; +}> = [ + { copy: { cmd: "wl-copy", args: [] }, paste: { cmd: "wl-paste", args: ["-n"] } }, + { + copy: { cmd: "xclip", args: ["-selection", "clipboard"] }, + paste: { cmd: "xclip", args: ["-selection", "clipboard", "-o"] }, + }, + { + copy: { cmd: "xsel", args: ["--clipboard", "--input"] }, + paste: { cmd: "xsel", args: ["--clipboard", "--output"] }, + }, + { copy: { cmd: "pbcopy", args: [] }, paste: { cmd: "pbpaste", args: [] } }, + { copy: { cmd: "clip.exe", args: [] }, paste: null }, ]; /** @@ -177,6 +193,52 @@ const CLIPBOARD_COMMANDS: Array<{ cmd: string; args: string[] }> = [ * detach it, ignore its stdio, and treat "stdin accepted and flushed" * as success. */ +/** Read the current clipboard via a paste tool; null if it cannot be read. */ +function readClipboard(cmd: string, args: string[]): Promise { + return new Promise((resolve) => { + let out = ""; + const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "ignore"], env: sinkEnv() }); + child.on("error", () => resolve(null)); + child.stdout.on("data", (c: Buffer) => { + out += c.toString("utf8"); + }); + child.on("close", (code) => resolve(code === 0 ? out : null)); + }); +} + +/** + * After `delaySeconds`, clear the clipboard — but only if it still holds the + * secret we put there, so a value the user copied since is left alone. When + * the clipboard cannot be read back (no paste tool, or it failed), clear + * unconditionally: leaving a secret parked on a session-wide clipboard is the + * worse failure. Mirrors Python's copy_to_clipboard(text, timeout). + * + * The timer is unref'd: it fires while the (long-running) TUI is still up, but + * never keeps a process alive on its own. A one-shot CLI that exits before the + * delay will not clear — the same limitation Python's daemon thread has. + */ +function scheduleClipboardClear( + secret: string, + delaySeconds: number, + tool: { copy: { cmd: string; args: string[] }; paste: { cmd: string; args: string[] } | null }, +): void { + const timer = setTimeout(() => { + void (async () => { + if (tool.paste) { + const current = await readClipboard(tool.paste.cmd, tool.paste.args); + // Trailing newline is common from paste tools; compare trimmed. + if (current !== null && current.replace(/\n$/, "") !== secret) return; + } + try { + await feedClipboardTool("", tool.copy.cmd, tool.copy.args); + } catch { + // Best effort: nothing useful to do if the wipe itself fails. + } + })(); + }, delaySeconds * 1000); + timer.unref?.(); +} + function feedClipboardTool(secret: string, cmd: string, args: string[]): Promise { return new Promise((resolve, reject) => { const child = spawn(cmd, args, { @@ -211,13 +273,31 @@ function feedClipboardTool(secret: string, cmd: string, args: string[]): Promise }); } -/** Copy the secret to the system clipboard via the first available tool. */ -export async function clipboardSink(secret: string): Promise { +/** + * Copy the secret to the system clipboard via the first available tool. + * + * With `clearAfterSeconds > 0`, schedule a clear that wipes the value after + * the delay (see scheduleClipboardClear). The default — no options — leaves + * the clipboard untouched afterwards, preserving the behaviour the one-shot + * CLI and the agent rely on; only the long-running TUI passes a delay. + */ +export async function clipboardSink( + secret: string, + opts: { clearAfterSeconds?: number } = {}, +): Promise { let lastError: unknown = new Error("no clipboard tool found"); - for (const { cmd, args } of CLIPBOARD_COMMANDS) { + for (const tool of CLIPBOARD_TOOLS) { try { - await feedClipboardTool(secret, cmd, args); - return { sink: "clipboard", detail: `copied via ${cmd}` }; + await feedClipboardTool(secret, tool.copy.cmd, tool.copy.args); + const clear = opts.clearAfterSeconds ?? 0; + if (clear > 0) scheduleClipboardClear(secret, clear, tool); + return { + sink: "clipboard", + detail: + clear > 0 + ? `copied via ${tool.copy.cmd}; clears in ${clear}s` + : `copied via ${tool.copy.cmd}`, + }; } catch (e) { lastError = e; } diff --git a/js/packages/cli/src/tui/menus.ts b/js/packages/cli/src/tui/menus.ts index b5f0d537..8a9b06bd 100644 --- a/js/packages/cli/src/tui/menus.ts +++ b/js/packages/cli/src/tui/menus.ts @@ -146,6 +146,21 @@ function secretModeOn(s: Session): boolean { return Boolean(s.config["secret_mode_enabled"]); } +/** + * How long a copied secret should sit on the clipboard before being wiped. + * 0 (or a non-positive value) disables the auto-clear. Parity with Python's + * clipboard_clear_delay. + */ +function clipboardClearSeconds(s: Session): number { + const raw = Number(s.config["clipboard_clear_delay"] ?? 0); + return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 0; +} + +/** Copy a secret to the clipboard, honouring the configured auto-clear delay. */ +async function copyToClipboard(s: Session, value: string): Promise<{ detail: string }> { + return clipboardSink(value, { clearAfterSeconds: clipboardClearSeconds(s) }); +} + /** * Show a secret, honouring Secret Mode. * @@ -155,7 +170,7 @@ function secretModeOn(s: Session): boolean { async function reveal(s: Session, value: string, descriptor: string): Promise { if (secretModeOn(s)) { try { - const result = await clipboardSink(value); + const result = await copyToClipboard(s, value); ok(s.ui, `${descriptor} copied to clipboard (${result.detail}).`); } catch (e) { fail(s.ui, `Clipboard unavailable: ${(e as Error).message}`); @@ -630,7 +645,7 @@ async function entryDetails(s: Session, id: string): Promise { const secret = materializeSecret(s.vault.index, id, entry, s.vault.mnemonic); if (choice === "c") { try { - const r = await clipboardSink(secret.value); + const r = await copyToClipboard(s, secret.value); ok(s.ui, `Copied ${secret.descriptor} (${r.detail}).`); } catch (e) { fail(s.ui, `Clipboard unavailable: ${(e as Error).message}`); diff --git a/js/packages/cli/test/sinks.test.ts b/js/packages/cli/test/sinks.test.ts index acd9f730..49b54d40 100644 --- a/js/packages/cli/test/sinks.test.ts +++ b/js/packages/cli/test/sinks.test.ts @@ -68,3 +68,76 @@ describe("parseCommandSpec", () => { expect(() => parseCommandSpec([""])).toThrow(/empty command/); }); }); + +import { clipboardSink } from "../src/sinks.js"; +import { mkdtemp, writeFile, readFile, chmod } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import process from "node:process"; + +/** + * A fake wl-copy/wl-paste pair backed by a state file, placed first on PATH. + * clipboardSink tries wl-copy first, so these win over any real tools and the + * test does not depend on a display or a system clipboard. + */ +async function fakeClipboard(): Promise<{ dir: string; stateFile: string; restore: () => void }> { + const dir = await mkdtemp(join(tmpdir(), "seedpass-clip-")); + const stateFile = join(dir, "clip.txt"); + await writeFile(stateFile, ""); + await writeFile(join(dir, "wl-copy"), `#!/bin/sh\ncat > "${stateFile}"\n`); + // -n: no trailing newline, matching the real wl-paste flag clipboardSink uses. + await writeFile(join(dir, "wl-paste"), `#!/bin/sh\ncat "${stateFile}"\n`); + await chmod(join(dir, "wl-copy"), 0o755); + await chmod(join(dir, "wl-paste"), 0o755); + const savedPath = process.env["PATH"]; + process.env["PATH"] = `${dir}:${savedPath ?? ""}`; + return { + dir, + stateFile, + restore: () => { + if (savedPath === undefined) delete process.env["PATH"]; + else process.env["PATH"] = savedPath; + }, + }; +} + +describe("clipboardSink auto-clear", () => { + it("copies the secret and, by default, never wipes it", async () => { + const clip = await fakeClipboard(); + try { + await clipboardSink("s3cr3t"); + expect(await readFile(clip.stateFile, "utf8")).toBe("s3cr3t"); + // No clear scheduled: it stays put. + await new Promise((r) => setTimeout(r, 250)); + expect(await readFile(clip.stateFile, "utf8")).toBe("s3cr3t"); + } finally { + clip.restore(); + } + }); + + it("wipes the secret after the delay when it is still ours", async () => { + const clip = await fakeClipboard(); + try { + const r = await clipboardSink("wipe-me", { clearAfterSeconds: 0.15 }); + expect(r.detail).toContain("clears in"); + expect(await readFile(clip.stateFile, "utf8")).toBe("wipe-me"); + await new Promise((res) => setTimeout(res, 400)); + expect(await readFile(clip.stateFile, "utf8")).toBe(""); + } finally { + clip.restore(); + } + }); + + it("leaves a value the user copied since the secret untouched", async () => { + const clip = await fakeClipboard(); + try { + await clipboardSink("old-secret", { clearAfterSeconds: 0.15 }); + // The user copies something else before the timer fires. + await writeFile(clip.stateFile, "user's own copy"); + await new Promise((res) => setTimeout(res, 400)); + expect(await readFile(clip.stateFile, "utf8")).toBe("user's own copy"); + } finally { + clip.restore(); + } + }); +});