fix(repl): use external clipboard tools for .copy command#27672
fix(repl): use external clipboard tools for .copy command#27672
Conversation
The .copy command previously relied exclusively on OSC 52 escape sequences, which are not supported by VTE-based terminals (XFCE4 Terminal, GNOME Terminal). Now tries external clipboard tools first (xclip, xsel, wl-copy on Linux; pbcopy on macOS), then falls back to OSC 52 for SSH and compatible terminals. Closes #27671 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
Updated 8:53 AM PT - Mar 1st, 2026
❌ Your commit
🧪 To try this PR locally: bunx bun-pr 27672That installs a local version of the PR into your bun-27672 --bun |
WalkthroughReworks REPL Changes
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@test/regression/issue/27671.test.ts`:
- Around line 96-98: The test currently only checks generic success and can pass
when an external clipboard tool exists; modify the test around the runRepl
invocation (the call using commands ".copy 42" and ".exit" that returns stdout
and exitCode) to simulate absence of any external clipboard utility (e.g., by
sanitizing PATH or ensuring no helper binaries are available) and add an
assertion that the raw OSC 52 sequence (the OSC 52 CSI prefix like "\x1b]52;c;")
appears in stdout to confirm the fallback path is used; apply the same
strengthening to the similar assertions in the later block (lines covering the
second runRepl case mentioned around 100-107).
- Around line 37-55: The test always creates a fake "xclip" helper but on macOS
the REPL uses "pbcopy", causing the fake helper to be ignored; update the test
setup in the block that creates fakeClipboard/clipOutputFile and the subsequent
runRepl invocation so it is platform-aware: detect process.platform (or
isDarwin) and create the appropriate fake binary name ("pbcopy" on Darwin,
"xclip" elsewhere) or create both helpers, write the same shell wrapper that
writes stdin to clipOutputFile, make them executable (chmod), and ensure the
PATH passed to runRepl includes the temp dir so the REPL will invoke the fake
helper referenced by the .copy command used in the test.
- Line 74: The second call to Bun.spawnSync({ cmd: ["chmod", "+x",
fakeClipboard] }) lacks an exit-code assertion, so capture its result (e.g.,
const { exitCode: chmodExit } = Bun.spawnSync(...)) and add an assertion
(expect(chmodExit).toBe(0)); modify the invocation near the second occurrence of
Bun.spawnSync and fakeClipboard in the test so the test fails at the chmod step
rather than later in runRepl.
ℹ️ Review info
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Disabled knowledge base sources:
- Linear integration is disabled
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (2)
src/repl.zigtest/regression/issue/27671.test.ts
- Use platform-appropriate fake clipboard tool name (pbcopy on macOS, xclip on Linux) - Assert chmod exit code in all test cases - Verify OSC 52 escape sequence appears in stdout when no external clipboard tools are available Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@test/regression/issue/27671.test.ts`:
- Around line 45-53: The test duplicates fake clipboard setup (creating
clipOutputFile, writing the shell script to fakeClipboard, and chmod) in two
places; extract that logic into a single helper (e.g., createFakeClipboard or
setupFakeClipboard) and replace both blocks with calls to it. The helper should
accept dir and fakeClipboardName (and optionally clipOutputFile path) and
perform the Bun.write and Bun.spawnSync chmod steps, returning any paths or
results needed by tests so existing references to clipOutputFile, fakeClipboard,
and fakeClipboardName work unchanged.
ℹ️ Review info
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Disabled knowledge base sources:
- Linear integration is disabled
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (1)
test/regression/issue/27671.test.ts
test/regression/issue/27671.test.ts
Outdated
| const clipOutputFile = `${dir}/clip-output.txt`; | ||
|
|
||
| // Create a fake clipboard tool that saves stdin to a file | ||
| const fakeClipboard = `${dir}/${fakeClipboardName}`; | ||
| await Bun.write(fakeClipboard, `#!/bin/sh\ncat > "${clipOutputFile}"\n`); | ||
| const { exitCode: chmodExit } = Bun.spawnSync({ | ||
| cmd: ["chmod", "+x", fakeClipboard], | ||
| }); | ||
| expect(chmodExit).toBe(0); |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
Extract duplicated fake-clipboard setup into one helper.
The two setup blocks are identical and should be centralized to avoid drift.
♻️ Suggested refactor
+async function setupFakeClipboard(dir: string, clipOutputFile: string): Promise<void> {
+ const fakeClipboard = `${dir}/${fakeClipboardName}`;
+ await Bun.write(fakeClipboard, `#!/bin/sh\ncat > "${clipOutputFile}"\n`);
+ const { exitCode: chmodExit } = Bun.spawnSync({
+ cmd: ["chmod", "+x", fakeClipboard],
+ });
+ expect(chmodExit).toBe(0);
+}
+
describe("issue `#27671` - .copy with external clipboard tools", () => {
test.skipIf(isWindows)(".copy pipes text to external clipboard tool", async () => {
using dir = tempDir("repl-copy-test", {});
const clipOutputFile = `${dir}/clip-output.txt`;
-
- // Create a fake clipboard tool that saves stdin to a file
- const fakeClipboard = `${dir}/${fakeClipboardName}`;
- await Bun.write(fakeClipboard, `#!/bin/sh\ncat > "${clipOutputFile}"\n`);
- const { exitCode: chmodExit } = Bun.spawnSync({
- cmd: ["chmod", "+x", fakeClipboard],
- });
- expect(chmodExit).toBe(0);
+ await setupFakeClipboard(dir, clipOutputFile);
...
test.skipIf(isWindows)(".copy pipes string value without quotes", async () => {
using dir = tempDir("repl-copy-test2", {});
const clipOutputFile = `${dir}/clip-output.txt`;
- const fakeClipboard = `${dir}/${fakeClipboardName}`;
- await Bun.write(fakeClipboard, `#!/bin/sh\ncat > "${clipOutputFile}"\n`);
- const { exitCode: chmodExit } = Bun.spawnSync({
- cmd: ["chmod", "+x", fakeClipboard],
- });
- expect(chmodExit).toBe(0);
+ await setupFakeClipboard(dir, clipOutputFile);Also applies to: 76-83
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@test/regression/issue/27671.test.ts` around lines 45 - 53, The test
duplicates fake clipboard setup (creating clipOutputFile, writing the shell
script to fakeClipboard, and chmod) in two places; extract that logic into a
single helper (e.g., createFakeClipboard or setupFakeClipboard) and replace both
blocks with calls to it. The helper should accept dir and fakeClipboardName (and
optionally clipOutputFile path) and perform the Bun.write and Bun.spawnSync
chmod steps, returning any paths or results needed by tests so existing
references to clipOutputFile, fakeClipboard, and fakeClipboardName work
unchanged.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@test/regression/issue/27671.test.ts`:
- Around line 8-9: Tests use the host PATH and only mock one clipboard binary
(fakeClipboardName), making them flaky; update the test setup (in this file's
test cases that call .copy) to set a controlled PATH that points to a temp
directory with mocked executables for all candidate clipboard tools (e.g.,
pbcopy, xclip, wl-copy) and ensure each candidate binary is present (even if a
no-op) so probe order cannot hit host tools; reference the
fakeClipboardName/isMacOS logic and the .copy test cases to locate where to
override process.env.PATH and install the mocked binaries, then restore PATH
after the test.
ℹ️ Review info
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Disabled knowledge base sources:
- Linear integration is disabled
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (1)
test/regression/issue/27671.test.ts
| const fakeClipboardName = isMacOS ? "pbcopy" : "xclip"; | ||
|
|
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
Harden external-tool tests against host PATH leakage.
Line 59 and Line 80 keep the full host PATH, while only one Linux tool is mocked. That makes these tests environment-dependent if another supported clipboard binary is preferred in probe order. Prefer isolating PATH and mocking all candidate tools used by .copy.
Suggested hardening
-const fakeClipboardName = isMacOS ? "pbcopy" : "xclip";
+const fakeClipboardNames = isMacOS ? ["pbcopy"] : ["xclip", "xsel", "wl-copy"];
async function setupFakeClipboard(dir: string): Promise<string> {
const clipOutputFile = `${dir}/clip-output.txt`;
- const fakeClipboard = `${dir}/${fakeClipboardName}`;
- await Bun.write(fakeClipboard, `#!/bin/sh\ncat > "${clipOutputFile}"\n`);
- const { exitCode } = Bun.spawnSync({ cmd: ["chmod", "+x", fakeClipboard] });
- expect(exitCode).toBe(0);
+ for (const name of fakeClipboardNames) {
+ const fakeClipboard = `${dir}/${name}`;
+ await Bun.write(fakeClipboard, `#!/bin/sh\n/bin/cat > "${clipOutputFile}"\n`);
+ const { exitCode } = Bun.spawnSync({ cmd: ["chmod", "+x", fakeClipboard] });
+ expect(exitCode).toBe(0);
+ }
return clipOutputFile;
}
...
- PATH: `${dir}:${process.env.PATH}`,
+ PATH: `${dir}:${path.dirname(bunExe())}`,
...
- PATH: `${dir}:${process.env.PATH}`,
+ PATH: `${dir}:${path.dirname(bunExe())}`,Also applies to: 43-50, 57-61, 78-81
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@test/regression/issue/27671.test.ts` around lines 8 - 9, Tests use the host
PATH and only mock one clipboard binary (fakeClipboardName), making them flaky;
update the test setup (in this file's test cases that call .copy) to set a
controlled PATH that points to a temp directory with mocked executables for all
candidate clipboard tools (e.g., pbcopy, xclip, wl-copy) and ensure each
candidate binary is present (even if a no-op) so probe order cannot hit host
tools; reference the fakeClipboardName/isMacOS logic and the .copy test cases to
locate where to override process.env.PATH and install the mocked binaries, then
restore PATH after the test.
| TERM: "dumb", | ||
| NO_COLOR: "1", | ||
| ...env, |
There was a problem hiding this comment.
🟡 Nit: The runRepl helper reads stdout/stderr sequentially after await proc.exited (lines 28-30) instead of using Promise.all as prescribed by test/CLAUDE.md, and wraps streams in new Response() instead of calling .text() directly. Additionally, line 99 uses inline require("path") instead of a module-scope import path from "node:path", violating test/CLAUDE.md guidelines on dynamic require.
Extended reasoning...
Sequential stdout/stderr reading
The runRepl helper at lines 28-30 uses this pattern:
const exitCode = await proc.exited;
const stdout = await new Response(proc.stdout).text();
const stderr = await new Response(proc.stderr).text();The test/CLAUDE.md (lines 42-46) documents the correct pattern as:
const [stdout, stderr, exitCode] = await Promise.all([
proc.stdout.text(),
proc.stderr.text(),
proc.exited,
]);The sequential pattern has a theoretical risk: if a process writes enough data to stdout/stderr to fill the OS pipe buffer (typically 64KB on Linux), the process blocks on the write syscall and never exits. Since the test awaits proc.exited first, it would deadlock. In practice, the REPL output for .copy 42 followed by .exit is only ~200 bytes, so this will never trigger. Additionally, the code wraps stdout in new Response(proc.stdout).text() rather than using the idiomatic Bun API proc.stdout.text() directly.
Inline require instead of module-scope import
Line 99 uses require("path").dirname(bunExe()) inline. The test/CLAUDE.md (lines 218-220) explicitly states: "Only use dynamic import or require when the test is specifically testing something related to dynamic import or require. Otherwise, always use module-scope import statements." This test is not testing require behavior — it is simply computing a directory path.
Step-by-step proof
- Looking at line 99:
PATH: \${dir}:${require("path").dirname(bunExe())}`` — this is a plain utility call to get the directory of the bun executable, not a test of dynamic require semantics. - Looking at lines 28-30:
await proc.exitedcompletes first, thennew Response(proc.stdout).text()is called. If the REPL ever produced >64KB output before these reads started, the process would block writing to the pipe, never exit, and theawait proc.exitedwould hang forever. - The fix for the sequential issue is to use
Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited])which reads streams concurrently with waiting for exit. - The fix for the require issue is to add
import path from "node:path";at the top of the file and replacerequire("path").dirname(bunExe())withpath.dirname(bunExe()).
Impact
Both issues are purely convention violations in test code with no practical impact on correctness. The sequential read pattern could theoretically cause test flakiness if REPL output grew significantly, but that scenario is extremely unlikely for these specific test cases.
| return { stdout, stderr, exitCode }; | ||
| } | ||
|
|
||
| // Create a fake clipboard tool that writes stdin to a capture file. | ||
| // Returns the path to the capture file. | ||
| async function setupFakeClipboard(dir: string): Promise<string> { | ||
| const clipOutputFile = `${dir}/clip-output.txt`; | ||
| const fakeClipboard = `${dir}/${fakeClipboardName}`; | ||
| await Bun.write(fakeClipboard, `#!/bin/sh\ncat > "${clipOutputFile}"\n`); | ||
| const { exitCode } = Bun.spawnSync({ cmd: ["chmod", "+x", fakeClipboard] }); | ||
| expect(exitCode).toBe(0); | ||
| return clipOutputFile; | ||
| } |
There was a problem hiding this comment.
🟡 The first two tests create a fake xclip and prepend it to PATH, but copyToClipboardExternal uses comptime platform checks — on macOS builds only pbcopy is tried, never xclip. So on macOS the fake xclip is never invoked, clipOutputFile is never written, and Bun.file(clipOutputFile).text() will throw. These tests should also skip on macOS (e.g. test.skipIf(!isLinux)) or create a fake pbcopy on macOS instead.
Extended reasoning...
What the bug is
The first two tests in 27671.test.ts (lines 38-62 and 64-86) create a fake xclip shell script that writes stdin to a file, then verify clipboard content by reading that file. However, the tests only skip on Windows (test.skipIf(isWindows)) and will fail on macOS.
Why it fails on macOS
The Zig function copyToClipboardExternal in src/repl.zig uses comptime platform checks to decide which clipboard tools to try:
const commands: []const ClipboardCmd = if (Environment.isMac)
&.{ .{ .argv = &.{"pbcopy"} } }
else if (Environment.isLinux)
&.{ ... xclip, xsel, wl-copy ... }
else
return false;Because these are comptime checks, on a macOS-compiled binary the code literally only contains pbcopy — the xclip branch is compiled out entirely.
Step-by-step proof on macOS
- Test creates
${dir}/xclipscript that writes stdin toclipOutputFile - Test sets
PATH=${dir}:${process.env.PATH}so fake xclip is first in PATH - REPL runs
.copy 42, which callscopyToClipboardExternal - On macOS, only
pbcopyis in the compiled command list —xclipis never tried pbcopyis found on the real system PATH and copies42to the actual system clipboard- The fake
xclipscript is never invoked, soclipOutputFileis never created await Bun.file(clipOutputFile).text()throws because the file does not exist- Test fails
Impact
This is a test-only issue (nit severity) but will cause CI failures on any macOS runner. It does not affect production code.
How to fix
The simplest fix is to restrict these two tests to Linux only, since the fake xclip approach only works there. The harness already exports isLinux:
import { bunEnv, bunExe, isWindows, isLinux } from "harness";
test.skipIf(!isLinux)(".copy pipes text to external clipboard tool", ...);
test.skipIf(!isLinux)(".copy pipes string value without quotes", ...);Alternatively, the tests could detect the platform and create a fake pbcopy on macOS instead of (or in addition to) xclip.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
♻️ Duplicate comments (1)
test/regression/issue/27671.test.ts (1)
7-9:⚠️ Potential issue | 🟠 MajorExternal-tool tests are still non-hermetic due to host
PATHinheritance.These tests can still pick host clipboard binaries if probe order changes or if the mocked tool path is bypassed, making outcomes environment-dependent. Mock all candidate clipboard tools and isolate
PATHto the temp dir.Suggested hardening diff
-const fakeClipboardName = isMacOS ? "pbcopy" : "xclip"; +const fakeClipboardNames = isMacOS ? ["pbcopy"] : ["xclip", "xsel", "wl-copy"]; async function setupFakeClipboard(dir: string): Promise<string> { const clipOutputFile = `${dir}/clip-output.txt`; - const fakeClipboard = `${dir}/${fakeClipboardName}`; - await Bun.write(fakeClipboard, `#!/bin/sh\ncat > "${clipOutputFile}"\n`); - const { exitCode } = Bun.spawnSync({ cmd: ["chmod", "+x", fakeClipboard] }); - expect(exitCode).toBe(0); + for (const tool of fakeClipboardNames) { + const fakeClipboard = `${dir}/${tool}`; + await Bun.write(fakeClipboard, `#!/bin/sh\n/bin/cat > "${clipOutputFile}"\n`); + const { exitCode } = Bun.spawnSync({ cmd: ["chmod", "+x", fakeClipboard] }); + expect(exitCode).toBe(0); + } return clipOutputFile; } ... - PATH: `${dir}:${process.env.PATH}`, + PATH: `${dir}`, ... - PATH: `${dir}:${process.env.PATH}`, + PATH: `${dir}`,Also applies to: 43-50, 57-61, 79-82
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@test/regression/issue/27671.test.ts` around lines 7 - 9, The tests still inherit the host PATH and only mock a single clipboard binary via fakeClipboardName, making them non-hermetic; update the test setup (where fakeClipboardName and isMacOS are used) to create a temporary bin directory, create stub executables for every candidate clipboard tool (e.g., "pbcopy", "pbpaste", "xclip", "xsel", etc.), prepend that temp bin to PATH (or set PATH to only that dir) for the test process, and ensure cleanup after tests; use the same helper setup for the other occurrences noted (around the blocks referenced at 43-50, 57-61, 79-82) so all clipboard probes are mocked and the tests are isolated from host binaries.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Duplicate comments:
In `@test/regression/issue/27671.test.ts`:
- Around line 7-9: The tests still inherit the host PATH and only mock a single
clipboard binary via fakeClipboardName, making them non-hermetic; update the
test setup (where fakeClipboardName and isMacOS are used) to create a temporary
bin directory, create stub executables for every candidate clipboard tool (e.g.,
"pbcopy", "pbpaste", "xclip", "xsel", etc.), prepend that temp bin to PATH (or
set PATH to only that dir) for the test process, and ensure cleanup after tests;
use the same helper setup for the other occurrences noted (around the blocks
referenced at 43-50, 57-61, 79-82) so all clipboard probes are mocked and the
tests are isolated from host binaries.
ℹ️ Review info
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Disabled knowledge base sources:
- Linear integration is disabled
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (1)
test/regression/issue/27671.test.ts
| return false; | ||
|
|
||
| for (commands) |cmd| { | ||
| var child = std.process.Child.init(cmd.argv, bun.default_allocator); | ||
| child.stdin_behavior = .Pipe; | ||
| child.stdout_behavior = .Ignore; | ||
| child.stderr_behavior = .Ignore; | ||
|
|
||
| child.spawn() catch continue; |
There was a problem hiding this comment.
🟡 Potential deadlock in copyToClipboardExternal: if stdin.writeAll(text) fails with a non-EPIPE error while the child process is still alive, the error path (line 1519) calls child.wait() without first closing stdin. Since clipboard tools read until EOF, the child blocks forever and wait() hangs. Add stdin.close(); child.stdin = null; before the child.wait() call in the catch block.
Extended reasoning...
What the bug is
In copyToClipboardExternal (lines 1517-1524), when stdin.writeAll(text) succeeds, stdin is properly closed before calling child.wait(). However, the error path at lines 1518-1520 skips straight to child.wait() without closing stdin first:
stdin.writeAll(text) catch {
_ = child.wait() catch {};
continue;
};
stdin.close();
child.stdin = null;How the deadlock occurs
Clipboard tools like xclip, pbcopy, xsel, and wl-copy all read from stdin until they receive EOF (i.e., the write end of the pipe is closed). If writeAll fails for a reason other than EPIPE (e.g., EIO, or a partial write failure on large data), the child process may still be alive waiting to read more input. The parent then calls child.wait(), which internally calls waitpid() -- but the child never exits because it never sees EOF on its stdin. This creates a classic deadlock: the parent waits for the child, and the child waits for EOF from the parent.
I verified this by examining the Zig standard library: std.process.Child.wait() calls waitpid() first, and only calls cleanupStreams() (which would close stdin) after waitpid returns. So stdin is definitively not closed before the wait.
Step-by-step proof
child.spawn()succeeds -- clipboard tool (e.g., xclip) starts and begins reading from its stdin pipestdin.writeAll(text)fails with an error other than EPIPE (e.g., EIO on a degraded system, or partial failure on very large clipboard content)- The child process is still alive, blocked on
read(0, ...)waiting for more data or EOF - Error path executes:
_ = child.wait() catch {}; child.wait()callswaitpid()which blocks because the child hasn't exited- The child never exits because stdin is still open (no EOF signal)
- Deadlock: parent blocked on waitpid, child blocked on read
Practical impact
The most common pipe write failure is EPIPE (broken pipe), which means the child has already exited -- in that case wait() returns immediately and there is no deadlock. Other failure modes like EIO are rare for local pipes. So while this is a real code defect, the practical likelihood of triggering it is very low. The clipboard content is typically small (well under the 64KB pipe buffer), further reducing the chance of partial write failures.
How to fix
Add stdin.close() and child.stdin = null before the child.wait() call in the error path:
stdin.writeAll(text) catch {
stdin.close();
child.stdin = null;
_ = child.wait() catch {};
continue;
};This ensures the child receives EOF and can exit cleanly, allowing wait() to return.
| const { stdout, exitCode } = await runRepl([".copy 42", ".exit"], { | ||
| env: { | ||
| // Only bun's directory and empty dir - no clipboard tools available | ||
| PATH: `${dir}:${path.dirname(bunExe())}`, | ||
| }, |
There was a problem hiding this comment.
🟡 The OSC 52 fallback test (line 102) sets PATH to ${dir}:${path.dirname(bunExe())}, but if bun is installed in a system directory like /usr/bin, that directory likely contains xclip/xsel, so the external clipboard tool succeeds instead of falling back to OSC 52, and the expect(stdout).toContain("\x1b]52;c;") assertion on line 107 fails. Fix by symlinking bun into the temp dir so PATH never includes a system bin directory.
Extended reasoning...
What the bug is
The third test (".copy falls back to OSC 52 when no external tools available") constructs a PATH intended to contain no clipboard tools. It does this by combining the temp dir with path.dirname(bunExe()). The assumption (stated in the comment on line 101) is that bun's directory contains only bun and no clipboard tools.
The specific code path that triggers failure
On line 102: PATH: \${dir}:${path.dirname(bunExe())}``
If bun is installed via a system package manager (e.g., apt install bun or brew install bun), bunExe() returns something like /usr/bin/bun or /usr/local/bin/bun. Then path.dirname(bunExe()) returns /usr/bin or /usr/local/bin. These directories commonly contain xclip, xsel, or pbcopy — the exact tools that copyToClipboardExternal probes for.
Step-by-step proof
- Assume bun is at
/usr/bin/bun(installed via system package manager) -
path.dirname(bunExe())returns/usr/bin - PATH is set to
${dir}:/usr/bin - The REPL runs
.copy 42, which callscopyToClipboardExternal - On Linux,
copyToClipboardExternaltriesxclip,xsel,wl-copyin order -
/usr/bin/xclipexists on many Linux systems — it spawns successfully and exits 0 -
copyToClipboardExternalreturnstrue, so the OSC 52 fallback is never reached -
expect(stdout).toContain("\x1b]52;c;")on line 107 fails because the OSC 52 sequence was never emitted
Why existing code doesn't prevent it
The test comment on line 101 says "Only bun's directory and empty dir - no clipboard tools available" but this assumption is only valid when bun lives in a dedicated build directory (like ./build/bun or ~/.bun/bin/bun), not when it shares a directory with system utilities.
Impact
This is a test-only fragility issue. It won't affect production code. In oven-sh/bun CI, the bun binary typically lives in a dedicated build output directory, so the test passes there. However, any developer running tests with a system-installed bun (e.g., from a package manager) would see this test unexpectedly fail.
How to fix
Symlink bun into the temp directory so PATH never includes a system directory:
const binDir = `${dir}/bin`;
fs.mkdirSync(binDir);
fs.symlinkSync(bunExe(), `${binDir}/bun`);
// ...
PATH: `${dir}:${binDir}`,This ensures the PATH contains only the empty temp dir and a directory with just the bun symlink — no clipboard tools can leak in.
Summary
.copycommand in the REPL previously relied exclusively on OSC 52 escape sequences to copy text to the clipboard.copyto silently failxclip,xsel,wl-copyon Linux;pbcopyon macOS), then falls back to OSC 52 for SSH sessions and terminals that support itTest plan
test/regression/issue/27671.test.ts) with fakexclipthat verifies text is piped correctlyCloses #27671
🤖 Generated with Claude Code