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}`);