fix(coding-agent): harden clipboard copy

closes #3639
This commit is contained in:
Mario Zechner
2026-04-24 12:52:32 +02:00
parent 9fdb12f985
commit 1e33492525
24 changed files with 561 additions and 108 deletions

View File

@@ -4,6 +4,7 @@
### Fixed
- Fixed `/copy` to avoid unbounded OSC 52 writes and clipboard races that could break terminal rendering or panic the native clipboard addon ([#3639](https://github.com/badlogic/pi-mono/issues/3639))
- Fixed extension flag docs to show `pi.getFlag()` using registered flag names without the CLI `--` prefix ([#3614](https://github.com/badlogic/pi-mono/issues/3614))
## [0.70.0] - 2026-04-23

View File

@@ -107,6 +107,7 @@ For each built-in provider, pi maintains a list of tool-capable models, updated
- Anthropic
- OpenAI
- Azure OpenAI
- DeepSeek
- Google Gemini
- Google Vertex
- Amazon Bedrock

View File

@@ -627,11 +627,12 @@ interface ProviderModelConfig {
requiresToolResultName?: boolean;
requiresAssistantAfterToolResult?: boolean;
requiresThinkingAsText?: boolean;
thinkingFormat?: "openai" | "zai" | "qwen" | "qwen-chat-template";
requiresReasoningContentOnAssistantMessages?: boolean;
thinkingFormat?: "openai" | "deepseek" | "zai" | "qwen" | "qwen-chat-template";
cacheControlFormat?: "anthropic";
};
}
```
`qwen` is for DashScope-style top-level `enable_thinking`. Use `qwen-chat-template` for local Qwen-compatible servers that read `chat_template_kwargs.enable_thinking`.
`deepseek` sends `thinking: { type: "enabled" | "disabled" }` and `reasoning_effort` when enabled. `qwen` is for DashScope-style top-level `enable_thinking`. Use `qwen-chat-template` for local Qwen-compatible servers that read `chat_template_kwargs.enable_thinking`.
`cacheControlFormat: "anthropic"` applies Anthropic-style `cache_control` markers to the system prompt, last tool definition, and last user/assistant text content.

View File

@@ -338,7 +338,8 @@ For providers with partial OpenAI compatibility, use the `compat` field.
| `requiresToolResultName` | Include `name` on tool result messages |
| `requiresAssistantAfterToolResult` | Insert an assistant message before a user message after tool results |
| `requiresThinkingAsText` | Convert thinking blocks to plain text |
| `thinkingFormat` | Use `reasoning_effort`, `zai`, `qwen`, or `qwen-chat-template` thinking parameters |
| `requiresReasoningContentOnAssistantMessages` | Include empty `reasoning_content` on all replayed assistant messages when reasoning is enabled |
| `thinkingFormat` | Use `reasoning_effort`, `deepseek`, `zai`, `qwen`, or `qwen-chat-template` thinking parameters |
| `cacheControlFormat` | Use Anthropic-style `cache_control` markers on the system prompt, last tool definition, and last user/assistant text content. Currently only `anthropic` is supported. |
| `supportsStrictMode` | Include the `strict` field in tool definitions |
| `supportsLongCacheRetention` | Whether the provider accepts long cache retention when cache retention is `long`: `prompt_cache_retention: "24h"` for OpenAI prompt caching, or `cache_control.ttl: "1h"` when `cacheControlFormat` is `anthropic`. Default: `true`. |

View File

@@ -56,6 +56,7 @@ pi
| Anthropic | `ANTHROPIC_API_KEY` | `anthropic` |
| Azure OpenAI Responses | `AZURE_OPENAI_API_KEY` | `azure-openai-responses` |
| OpenAI | `OPENAI_API_KEY` | `openai` |
| DeepSeek | `DEEPSEEK_API_KEY` | `deepseek` |
| Google Gemini | `GEMINI_API_KEY` | `google` |
| Mistral | `MISTRAL_API_KEY` | `mistral` |
| Groq | `GROQ_API_KEY` | `groq` |
@@ -82,6 +83,7 @@ Store credentials in `~/.pi/agent/auth.json`:
{
"anthropic": { "type": "api_key", "key": "sk-ant-..." },
"openai": { "type": "api_key", "key": "sk-..." },
"deepseek": { "type": "api_key", "key": "sk-..." },
"google": { "type": "api_key", "key": "..." },
"opencode": { "type": "api_key", "key": "..." },
"opencode-go": { "type": "api_key", "key": "..." }

View File

@@ -67,7 +67,7 @@
}
},
"optionalDependencies": {
"@mariozechner/clipboard": "^0.3.2"
"@mariozechner/clipboard": "^0.3.3"
},
"devDependencies": {
"@types/diff": "^7.0.2",

View File

@@ -303,6 +303,7 @@ ${chalk.bold("Environment Variables:")}
AZURE_OPENAI_RESOURCE_NAME - Azure OpenAI resource name (alternative to base URL)
AZURE_OPENAI_API_VERSION - Azure OpenAI API version (default: v1)
AZURE_OPENAI_DEPLOYMENT_NAME_MAP - Azure OpenAI model=deployment map (comma-separated)
DEEPSEEK_API_KEY - DeepSeek API key
GEMINI_API_KEY - Google Gemini API key
GROQ_API_KEY - Groq API key
CEREBRAS_API_KEY - Cerebras API key

View File

@@ -98,10 +98,12 @@ const OpenAICompletionsCompatSchema = Type.Object({
requiresToolResultName: Type.Optional(Type.Boolean()),
requiresAssistantAfterToolResult: Type.Optional(Type.Boolean()),
requiresThinkingAsText: Type.Optional(Type.Boolean()),
requiresReasoningContentOnAssistantMessages: Type.Optional(Type.Boolean()),
thinkingFormat: Type.Optional(
Type.Union([
Type.Literal("openai"),
Type.Literal("openrouter"),
Type.Literal("deepseek"),
Type.Literal("zai"),
Type.Literal("qwen"),
Type.Literal("qwen-chat-template"),

View File

@@ -17,6 +17,7 @@ export const defaultModelPerProvider: Record<KnownProvider, string> = {
openai: "gpt-5.4",
"azure-openai-responses": "gpt-5.4",
"openai-codex": "gpt-5.5",
deepseek: "deepseek-v4-pro",
google: "gemini-3.1-pro-preview",
"google-gemini-cli": "gemini-3.1-pro-preview",
"google-antigravity": "gemini-3.1-pro-high",

View File

@@ -183,6 +183,7 @@ const API_KEY_LOGIN_PROVIDERS: Record<string, string> = {
[BEDROCK_PROVIDER_ID]: "Amazon Bedrock",
"azure-openai-responses": "Azure OpenAI Responses",
cerebras: "Cerebras",
deepseek: "DeepSeek",
fireworks: "Fireworks",
google: "Google Gemini",
"google-vertex": "Google Vertex AI",

View File

@@ -17,65 +17,103 @@ function copyToX11Clipboard(options: NativeClipboardExecOptions): void {
}
}
export async function copyToClipboard(text: string): Promise<void> {
// Always emit OSC 52 - works over SSH/mosh, harmless locally
const encoded = Buffer.from(text).toString("base64");
process.stdout.write(`\x1b]52;c;${encoded}\x07`);
const MAX_OSC52_ENCODED_LENGTH = 100_000;
function isRemoteSession(env: NodeJS.ProcessEnv = process.env): boolean {
return Boolean(env.SSH_CONNECTION || env.SSH_CLIENT || env.MOSH_CONNECTION);
}
function emitOsc52(text: string): boolean {
const encoded = Buffer.from(text).toString("base64");
if (encoded.length > MAX_OSC52_ENCODED_LENGTH) {
return false;
}
process.stdout.write(`\x1b]52;c;${encoded}\x07`);
return true;
}
export async function copyToClipboard(text: string): Promise<void> {
let copied = false;
// Prefer direct clipboard writes. Emitting OSC 52 first can make terminals
// write the same native clipboard concurrently with the addon, and very large
// OSC 52 payloads can desynchronize terminal rendering.
try {
if (clipboard) {
await clipboard.setText(text);
return;
copied = true;
}
} catch {
// Fall through to platform-specific clipboard tools.
}
// Also try native tools (best effort for local sessions)
const remote = isRemoteSession();
if (copied && !remote) {
return;
}
const p = platform();
const options: NativeClipboardExecOptions = { input: text, timeout: 5000, stdio: ["pipe", "ignore", "ignore"] };
try {
if (p === "darwin") {
execSync("pbcopy", options);
} else if (p === "win32") {
execSync("clip", options);
} else {
// Linux. Try Termux, Wayland, or X11 clipboard tools.
if (process.env.TERMUX_VERSION) {
try {
execSync("termux-clipboard-set", options);
return;
} catch {
// Fall back to Wayland or X11 tools.
}
}
const hasWaylandDisplay = Boolean(process.env.WAYLAND_DISPLAY);
const hasX11Display = Boolean(process.env.DISPLAY);
const isWayland = isWaylandSession();
if (isWayland && hasWaylandDisplay) {
try {
// Verify wl-copy exists (spawn errors are async and won't be caught)
execSync("which wl-copy", { stdio: "ignore" });
// wl-copy with execSync hangs due to fork behavior; use spawn instead
const proc = spawn("wl-copy", [], { stdio: ["pipe", "ignore", "ignore"] });
proc.stdin.on("error", () => {
// Ignore EPIPE errors if wl-copy exits early
});
proc.stdin.write(text);
proc.stdin.end();
proc.unref();
} catch {
if (hasX11Display) {
copyToX11Clipboard(options);
if (!copied) {
try {
if (p === "darwin") {
execSync("pbcopy", options);
copied = true;
} else if (p === "win32") {
execSync("clip", options);
copied = true;
} else {
// Linux. Try Termux, Wayland, or X11 clipboard tools.
if (process.env.TERMUX_VERSION) {
try {
execSync("termux-clipboard-set", options);
copied = true;
} catch {
// Fall back to Wayland or X11 tools.
}
}
if (!copied) {
const hasWaylandDisplay = Boolean(process.env.WAYLAND_DISPLAY);
const hasX11Display = Boolean(process.env.DISPLAY);
const isWayland = isWaylandSession();
if (isWayland && hasWaylandDisplay) {
try {
// Verify wl-copy exists (spawn errors are async and won't be caught)
execSync("which wl-copy", { stdio: "ignore" });
// wl-copy with execSync hangs due to fork behavior; use spawn instead
const proc = spawn("wl-copy", [], { stdio: ["pipe", "ignore", "ignore"] });
proc.stdin.on("error", () => {
// Ignore EPIPE errors if wl-copy exits early
});
proc.stdin.write(text);
proc.stdin.end();
proc.unref();
copied = true;
} catch {
if (hasX11Display) {
copyToX11Clipboard(options);
copied = true;
}
}
} else if (hasX11Display) {
copyToX11Clipboard(options);
copied = true;
}
}
} else if (hasX11Display) {
copyToX11Clipboard(options);
}
} catch {
// Fall through to OSC 52 fallback.
}
} catch {
// Ignore - OSC 52 already emitted as fallback
}
if (remote || !copied) {
const osc52Copied = emitOsc52(text);
copied = copied || osc52Copied;
}
if (!copied) {
throw new Error("Failed to copy to clipboard");
}
}

View File

@@ -0,0 +1,145 @@
import { execSync, spawn } from "child_process";
import { platform } from "os";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { copyToClipboard } from "../src/utils/clipboard.js";
const mocks = vi.hoisted(() => {
return {
clipboard: {
setText: vi.fn<(text: string) => Promise<void>>(),
},
execSync: vi.fn(),
spawn: vi.fn(),
platform: vi.fn<() => NodeJS.Platform>(),
isWaylandSession: vi.fn<() => boolean>(),
};
});
vi.mock("../src/utils/clipboard-native.js", () => {
return {
clipboard: mocks.clipboard,
};
});
vi.mock("child_process", () => {
return {
execSync: mocks.execSync,
spawn: mocks.spawn,
};
});
vi.mock("os", () => {
return {
platform: mocks.platform,
};
});
vi.mock("../src/utils/clipboard-image.js", () => {
return {
isWaylandSession: mocks.isWaylandSession,
};
});
const mockedExecSync = vi.mocked(execSync);
const mockedSpawn = vi.mocked(spawn);
const mockedPlatform = vi.mocked(platform);
let originalWrite: typeof process.stdout.write;
let stdoutWrites: string[];
let nativeResolved = false;
function osc52Writes(): string[] {
return stdoutWrites.filter((write) => write.startsWith("\x1b]52;c;"));
}
beforeEach(() => {
vi.unstubAllEnvs();
stdoutWrites = [];
nativeResolved = false;
mocks.clipboard.setText.mockReset();
mocks.execSync.mockReset();
mocks.spawn.mockReset();
mocks.platform.mockReset();
mocks.isWaylandSession.mockReset();
mockedPlatform.mockReturnValue("darwin");
mocks.isWaylandSession.mockReturnValue(false);
mocks.clipboard.setText.mockImplementation(async () => {
await new Promise((resolve) => setTimeout(resolve, 1));
nativeResolved = true;
});
originalWrite = process.stdout.write.bind(process.stdout);
process.stdout.write = ((...args: Parameters<typeof process.stdout.write>) => {
const [chunk] = args;
if (typeof chunk === "string" && chunk.startsWith("\x1b]52;c;")) {
stdoutWrites.push(chunk);
return true;
}
return originalWrite(...args);
}) as typeof process.stdout.write;
});
afterEach(() => {
process.stdout.write = originalWrite;
vi.unstubAllEnvs();
});
describe("copyToClipboard", () => {
test("local native success skips OSC 52 and shell fallbacks", async () => {
await copyToClipboard("hello");
expect(mocks.clipboard.setText).toHaveBeenCalledWith("hello");
expect(osc52Writes()).toHaveLength(0);
expect(mockedExecSync).not.toHaveBeenCalled();
expect(mockedSpawn).not.toHaveBeenCalled();
});
test("remote native success emits OSC 52 after native write", async () => {
vi.stubEnv("SSH_CONNECTION", "client server");
mocks.clipboard.setText.mockImplementation(async () => {
await new Promise((resolve) => setTimeout(resolve, 1));
expect(osc52Writes()).toHaveLength(0);
nativeResolved = true;
});
await copyToClipboard("hello");
expect(nativeResolved).toBe(true);
expect(osc52Writes()).toHaveLength(1);
expect(mockedExecSync).not.toHaveBeenCalled();
});
test("local shell fallback success skips OSC 52", async () => {
mocks.clipboard.setText.mockRejectedValue(new Error("native failed"));
mockedExecSync.mockReturnValue(Buffer.alloc(0));
await copyToClipboard("hello");
expect(mockedExecSync).toHaveBeenCalledWith("pbcopy", {
input: "hello",
stdio: ["pipe", "ignore", "ignore"],
timeout: 5000,
});
expect(osc52Writes()).toHaveLength(0);
});
test("uses OSC 52 fallback when native and shell tools fail", async () => {
mocks.clipboard.setText.mockRejectedValue(new Error("native failed"));
mockedExecSync.mockImplementation(() => {
throw new Error("pbcopy failed");
});
await copyToClipboard("hello");
expect(osc52Writes()).toHaveLength(1);
});
test("does not emit oversized OSC 52 payloads", async () => {
mocks.clipboard.setText.mockRejectedValue(new Error("native failed"));
mockedExecSync.mockImplementation(() => {
throw new Error("pbcopy failed");
});
await expect(copyToClipboard("x".repeat(80_000))).rejects.toThrow("Failed to copy to clipboard");
expect(osc52Writes()).toHaveLength(0);
});
});