From ce39070b3f8ffdeb7e1b5feb70dc8e343fc651e2 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 4 Jun 2026 23:22:57 -0400 Subject: [PATCH 1/2] fix: prevent notifications leaking into other terminals --- src/index.ts | 33 ++++----------------------------- src/notify.ts | 17 +++++++++++++---- src/payload.ts | 33 ++++++++++++++++++++++++++++++++- tests/index.test.ts | 31 ++++++++++++++++++++++++++++++- tests/notify.test.ts | 21 +++++++++++++++++++++ 5 files changed, 100 insertions(+), 35 deletions(-) diff --git a/src/index.ts b/src/index.ts index 1ad5f90..6cea1b6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,8 @@ import type { Plugin } from "@opencode-ai/plugin" import type { Event, Permission } from "@opencode-ai/sdk" -import { buildPayload } from "./payload" -import { warpNotify } from "./notify" +import { buildPayload, buildPermissionPayload } from "./payload" +import { warpNotify, supportsWarpCliAgentProtocol } from "./notify" import { truncate, extractTextFromParts } from "./utils" // Must be kept in sync with the "version" field in package.json. @@ -13,39 +13,14 @@ const PLUGIN_VERSION = "0.1.6" const NOTIFICATION_TITLE = "warp://cli-agent" function sendPermissionNotification(perm: Permission, cwd: string): void { - const sessionId = perm.sessionID - const toolName = perm.type || "unknown" - const metadata = perm.metadata || {} - - let toolPreview = "" - if (typeof metadata.command === "string") { - toolPreview = metadata.command - } else if (typeof metadata.file_path === "string") { - toolPreview = metadata.file_path as string - } else if (typeof metadata.filePath === "string") { - toolPreview = metadata.filePath as string - } else { - const raw = JSON.stringify(metadata) - toolPreview = raw.slice(0, 80) - } - - let summary = `Wants to run ${toolName}` - if (toolPreview) { - summary += `: ${truncate(toolPreview, 120)}` - } - - const body = buildPayload("permission_request", sessionId, cwd, { - summary, - tool_name: toolName, - tool_input: metadata, - }) + const body = buildPermissionPayload(perm.sessionID, cwd, perm.type || "unknown", perm.metadata || {}) warpNotify(NOTIFICATION_TITLE, body) } export const WarpPlugin: Plugin = async ({ client, directory }) => { // Fire-and-forget the init log: awaiting client.app.log here can deadlock // opencode's plugin loader on some versions (e.g. 1.4.8). - if (!process.env.WARP_CLI_AGENT_PROTOCOL_VERSION) { + if (!supportsWarpCliAgentProtocol()) { client.app .log({ body: { diff --git a/src/notify.ts b/src/notify.ts index fb8b9f1..aaf3b12 100644 --- a/src/notify.ts +++ b/src/notify.ts @@ -2,8 +2,8 @@ import { writeFileSync } from "fs" /** * Send a Warp notification via OSC 777 escape sequence. - * Only emits when Warp declares cli-agent protocol support, - * avoiding garbled output in other terminals (and working over SSH). + * Only emits when Warp declares cli-agent protocol support and the current + * terminal does not explicitly identify itself as another terminal. * * On Unix we write to /dev/tty so the sequence bypasses any * stdout redirection or terminal-multiplexer capture. @@ -12,7 +12,7 @@ import { writeFileSync } from "fs" * in-process (sharing the same ConPTY-connected stdout). */ function warpNotify(title: string, body: string): void { - if (!process.env.WARP_CLI_AGENT_PROTOCOL_VERSION) return + if (!supportsWarpCliAgentProtocol()) return const sequence = `\x1b]777;notify;${title};${body}\x07` @@ -28,4 +28,13 @@ function warpNotify(title: string, body: string): void { } } -export { warpNotify } +function supportsWarpCliAgentProtocol(): boolean { + if (!process.env.WARP_CLI_AGENT_PROTOCOL_VERSION) return false + + // Warp's variables can leak into GUI apps launched from Warp. Keep allowing + // SSH sessions where TERM_PROGRAM is unset, but never write Warp OSC into a + // terminal that explicitly identifies itself as something else. + return !process.env.TERM_PROGRAM || process.env.TERM_PROGRAM === "WarpTerminal" +} + +export { warpNotify, supportsWarpCliAgentProtocol } diff --git a/src/payload.ts b/src/payload.ts index ff6cb44..956ffe9 100644 --- a/src/payload.ts +++ b/src/payload.ts @@ -1,4 +1,5 @@ import path from "path" +import { truncate } from "./utils" const PLUGIN_MAX_PROTOCOL_VERSION = 1 @@ -37,4 +38,34 @@ function buildPayload( return JSON.stringify({ ...base, ...extraFields }) } -export { buildPayload, negotiateProtocolVersion, PLUGIN_MAX_PROTOCOL_VERSION } +function buildPermissionPayload( + sessionId: string, + cwd: string, + toolName: string, + metadata: Record, +): string { + let toolInput: { command?: string; file_path?: string } | undefined + let toolPreview = "" + + if (typeof metadata.command === "string") { + toolPreview = metadata.command + toolInput = { command: truncate(metadata.command, 200) } + } else if (typeof metadata.file_path === "string") { + toolPreview = metadata.file_path + toolInput = { file_path: truncate(metadata.file_path, 200) } + } else if (typeof metadata.filePath === "string") { + toolPreview = metadata.filePath + toolInput = { file_path: truncate(metadata.filePath, 200) } + } else { + toolPreview = truncate(JSON.stringify(metadata), 80) + } + + const summary = `Wants to run ${toolName}${toolPreview ? `: ${truncate(toolPreview, 120)}` : ""}` + return buildPayload("permission_request", sessionId, cwd, { + summary, + tool_name: toolName, + ...(toolInput ? { tool_input: toolInput } : {}), + }) +} + +export { buildPayload, buildPermissionPayload, negotiateProtocolVersion, PLUGIN_MAX_PROTOCOL_VERSION } diff --git a/tests/index.test.ts b/tests/index.test.ts index e3628b5..fb67576 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -1,7 +1,7 @@ import { describe, it } from "node:test" import assert from "node:assert/strict" import { truncate, extractTextFromParts } from "../src/utils" -import { buildPayload } from "../src/payload" +import { buildPayload, buildPermissionPayload } from "../src/payload" describe("truncate", () => { it("returns string unchanged when under maxLen", () => { @@ -75,3 +75,32 @@ describe("question_asked event", () => { assert.strictEqual(payload.session_id, "s1") }) }) + +describe("permission_request event", () => { + it("does not include full patch metadata", () => { + const payload = JSON.parse( + buildPermissionPayload("s1", "/tmp/proj", "patch", { + diff: "a".repeat(10_000), + additions: 1, + deletions: 100, + }), + ) + + assert.strictEqual(payload.event, "permission_request") + assert.strictEqual(payload.tool_input, undefined) + assert.ok(payload.summary.length < 256) + assert.ok(JSON.stringify(payload).length < 512) + }) + + it("includes a bounded command preview", () => { + const payload = JSON.parse( + buildPermissionPayload("s1", "/tmp/proj", "bash", { + command: "a".repeat(10_000), + }), + ) + + assert.ok(payload.tool_input.command.length <= 200) + assert.ok(payload.summary.length < 256) + assert.ok(JSON.stringify(payload).length < 512) + }) +}) diff --git a/tests/notify.test.ts b/tests/notify.test.ts index 5f54001..5cb0514 100644 --- a/tests/notify.test.ts +++ b/tests/notify.test.ts @@ -12,6 +12,7 @@ const { warpNotify } = await import("../src/notify") describe("warpNotify", () => { const originalVersion = process.env.WARP_CLI_AGENT_PROTOCOL_VERSION + const originalTermProgram = process.env.TERM_PROGRAM afterEach(() => { writeSpy.mockClear() @@ -20,6 +21,11 @@ describe("warpNotify", () => { } else { process.env.WARP_CLI_AGENT_PROTOCOL_VERSION = originalVersion } + if (originalTermProgram === undefined) { + delete process.env.TERM_PROGRAM + } else { + process.env.TERM_PROGRAM = originalTermProgram + } }) it("skips when WARP_CLI_AGENT_PROTOCOL_VERSION is not set", () => { @@ -30,6 +36,7 @@ describe("warpNotify", () => { it("writes OSC 777 sequence when Warp declares protocol support", () => { process.env.WARP_CLI_AGENT_PROTOCOL_VERSION = "1" + delete process.env.TERM_PROGRAM warpNotify("warp://cli-agent", '{"event":"stop"}') expect(writeSpy).toHaveBeenCalledTimes(1) @@ -40,4 +47,18 @@ describe("warpNotify", () => { expect(data).toMatch(/^\x1b\]777;notify;/) expect(data).toMatch(/\x07$/) }) + + it("writes OSC 777 sequence inside Warp", () => { + process.env.WARP_CLI_AGENT_PROTOCOL_VERSION = "1" + process.env.TERM_PROGRAM = "WarpTerminal" + warpNotify("warp://cli-agent", '{"event":"stop"}') + expect(writeSpy).toHaveBeenCalledTimes(1) + }) + + it("skips when Warp variables were inherited by another terminal", () => { + process.env.WARP_CLI_AGENT_PROTOCOL_VERSION = "1" + process.env.TERM_PROGRAM = "zed" + warpNotify("warp://cli-agent", '{"event":"stop"}') + expect(writeSpy).not.toHaveBeenCalled() + }) }) From 84e4a31f0b5a393a521582cb91eda7f6954c87de Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 4 Jun 2026 23:26:58 -0400 Subject: [PATCH 2/2] refactor: narrow notification leak fix --- src/index.ts | 33 +++++++++++++++++++++++++++++---- src/notify.ts | 14 +++----------- src/payload.ts | 33 +-------------------------------- tests/index.test.ts | 31 +------------------------------ 4 files changed, 34 insertions(+), 77 deletions(-) diff --git a/src/index.ts b/src/index.ts index 6cea1b6..1ad5f90 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,8 @@ import type { Plugin } from "@opencode-ai/plugin" import type { Event, Permission } from "@opencode-ai/sdk" -import { buildPayload, buildPermissionPayload } from "./payload" -import { warpNotify, supportsWarpCliAgentProtocol } from "./notify" +import { buildPayload } from "./payload" +import { warpNotify } from "./notify" import { truncate, extractTextFromParts } from "./utils" // Must be kept in sync with the "version" field in package.json. @@ -13,14 +13,39 @@ const PLUGIN_VERSION = "0.1.6" const NOTIFICATION_TITLE = "warp://cli-agent" function sendPermissionNotification(perm: Permission, cwd: string): void { - const body = buildPermissionPayload(perm.sessionID, cwd, perm.type || "unknown", perm.metadata || {}) + const sessionId = perm.sessionID + const toolName = perm.type || "unknown" + const metadata = perm.metadata || {} + + let toolPreview = "" + if (typeof metadata.command === "string") { + toolPreview = metadata.command + } else if (typeof metadata.file_path === "string") { + toolPreview = metadata.file_path as string + } else if (typeof metadata.filePath === "string") { + toolPreview = metadata.filePath as string + } else { + const raw = JSON.stringify(metadata) + toolPreview = raw.slice(0, 80) + } + + let summary = `Wants to run ${toolName}` + if (toolPreview) { + summary += `: ${truncate(toolPreview, 120)}` + } + + const body = buildPayload("permission_request", sessionId, cwd, { + summary, + tool_name: toolName, + tool_input: metadata, + }) warpNotify(NOTIFICATION_TITLE, body) } export const WarpPlugin: Plugin = async ({ client, directory }) => { // Fire-and-forget the init log: awaiting client.app.log here can deadlock // opencode's plugin loader on some versions (e.g. 1.4.8). - if (!supportsWarpCliAgentProtocol()) { + if (!process.env.WARP_CLI_AGENT_PROTOCOL_VERSION) { client.app .log({ body: { diff --git a/src/notify.ts b/src/notify.ts index aaf3b12..6381679 100644 --- a/src/notify.ts +++ b/src/notify.ts @@ -12,7 +12,8 @@ import { writeFileSync } from "fs" * in-process (sharing the same ConPTY-connected stdout). */ function warpNotify(title: string, body: string): void { - if (!supportsWarpCliAgentProtocol()) return + if (!process.env.WARP_CLI_AGENT_PROTOCOL_VERSION) return + if (process.env.TERM_PROGRAM && process.env.TERM_PROGRAM !== "WarpTerminal") return const sequence = `\x1b]777;notify;${title};${body}\x07` @@ -28,13 +29,4 @@ function warpNotify(title: string, body: string): void { } } -function supportsWarpCliAgentProtocol(): boolean { - if (!process.env.WARP_CLI_AGENT_PROTOCOL_VERSION) return false - - // Warp's variables can leak into GUI apps launched from Warp. Keep allowing - // SSH sessions where TERM_PROGRAM is unset, but never write Warp OSC into a - // terminal that explicitly identifies itself as something else. - return !process.env.TERM_PROGRAM || process.env.TERM_PROGRAM === "WarpTerminal" -} - -export { warpNotify, supportsWarpCliAgentProtocol } +export { warpNotify } diff --git a/src/payload.ts b/src/payload.ts index 956ffe9..ff6cb44 100644 --- a/src/payload.ts +++ b/src/payload.ts @@ -1,5 +1,4 @@ import path from "path" -import { truncate } from "./utils" const PLUGIN_MAX_PROTOCOL_VERSION = 1 @@ -38,34 +37,4 @@ function buildPayload( return JSON.stringify({ ...base, ...extraFields }) } -function buildPermissionPayload( - sessionId: string, - cwd: string, - toolName: string, - metadata: Record, -): string { - let toolInput: { command?: string; file_path?: string } | undefined - let toolPreview = "" - - if (typeof metadata.command === "string") { - toolPreview = metadata.command - toolInput = { command: truncate(metadata.command, 200) } - } else if (typeof metadata.file_path === "string") { - toolPreview = metadata.file_path - toolInput = { file_path: truncate(metadata.file_path, 200) } - } else if (typeof metadata.filePath === "string") { - toolPreview = metadata.filePath - toolInput = { file_path: truncate(metadata.filePath, 200) } - } else { - toolPreview = truncate(JSON.stringify(metadata), 80) - } - - const summary = `Wants to run ${toolName}${toolPreview ? `: ${truncate(toolPreview, 120)}` : ""}` - return buildPayload("permission_request", sessionId, cwd, { - summary, - tool_name: toolName, - ...(toolInput ? { tool_input: toolInput } : {}), - }) -} - -export { buildPayload, buildPermissionPayload, negotiateProtocolVersion, PLUGIN_MAX_PROTOCOL_VERSION } +export { buildPayload, negotiateProtocolVersion, PLUGIN_MAX_PROTOCOL_VERSION } diff --git a/tests/index.test.ts b/tests/index.test.ts index fb67576..e3628b5 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -1,7 +1,7 @@ import { describe, it } from "node:test" import assert from "node:assert/strict" import { truncate, extractTextFromParts } from "../src/utils" -import { buildPayload, buildPermissionPayload } from "../src/payload" +import { buildPayload } from "../src/payload" describe("truncate", () => { it("returns string unchanged when under maxLen", () => { @@ -75,32 +75,3 @@ describe("question_asked event", () => { assert.strictEqual(payload.session_id, "s1") }) }) - -describe("permission_request event", () => { - it("does not include full patch metadata", () => { - const payload = JSON.parse( - buildPermissionPayload("s1", "/tmp/proj", "patch", { - diff: "a".repeat(10_000), - additions: 1, - deletions: 100, - }), - ) - - assert.strictEqual(payload.event, "permission_request") - assert.strictEqual(payload.tool_input, undefined) - assert.ok(payload.summary.length < 256) - assert.ok(JSON.stringify(payload).length < 512) - }) - - it("includes a bounded command preview", () => { - const payload = JSON.parse( - buildPermissionPayload("s1", "/tmp/proj", "bash", { - command: "a".repeat(10_000), - }), - ) - - assert.ok(payload.tool_input.command.length <= 200) - assert.ok(payload.summary.length < 256) - assert.ok(JSON.stringify(payload).length < 512) - }) -})