feat(tui): actually clear the clipboard after the configured delay

clipboard_clear_delay has been in the default config (45s), settable, and
read by nothing: clipboardSink copied and returned. That made TUI Secret
Mode -- whose entire purpose is to route secrets off the screen -- strictly
less safe than Python's, because it parked them on a session-wide clipboard
indefinitely.

clipboardSink now takes an optional clearAfterSeconds. When set, it schedules
a wipe after the delay, but only if the clipboard still holds the value it
put there, so a value the user copied in the meantime is left alone -- the
same guard Python's copy_to_clipboard(text, timeout) applies via a read-back.
Where the clipboard cannot be read (no paste tool), it clears unconditionally,
because leaving a secret parked is the worse outcome. The timer is unref'd:
it fires while the long-running TUI is up but never holds a process open, so
the one-shot CLI and the agent are unchanged (they pass no delay).

Both TUI clipboard paths -- Secret Mode reveals and the entry 'C' action --
now route through the profile's clipboard_clear_delay.

Tests use a fake wl-copy/wl-paste pair on PATH: the secret is wiped after the
delay, and a value copied over it in the meantime survives.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Tkj4rzKBA3uhfgk7kqjsb8
This commit is contained in:
thePR0M3TH3ANandClaude Opus 5 committed 2026-08-17 22:22:12 -04:00
1 parent 7f6edf10cc
commit 135a6268ce
3 files changed
+181 -13

No files matched your search

+91 -11
View File
@@ -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<string | null> {
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<void> {
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<SinkResult> {
/**
* 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<SinkResult> {
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;
}
+17 -2
View File
@@ -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<void> {
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<void> {
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}`);
+73
View File
@@ -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();
}
});
});