Merge pull request #4299 from code-yeongyu/fix/issue-4128-ctx-dollar-guard

fix(session-notification-sender): guard ctx.$ availability with execFile fallback (#4128, #4061)
This commit is contained in:
YeonGyu-Kim
2026-05-22 20:49:16 +09:00
committed by GitHub
2 changed files with 256 additions and 100 deletions
+53 -25
View File
@@ -1,4 +1,7 @@
/// <reference types="bun-types" />
import { afterEach, beforeEach, describe, expect, jest, spyOn, test } from "bun:test" import { afterEach, beforeEach, describe, expect, jest, spyOn, test } from "bun:test"
import * as childProcess from "node:child_process"
import * as sender from "./session-notification-sender" import * as sender from "./session-notification-sender"
import * as utils from "./session-notification-utils" import * as utils from "./session-notification-utils"
import type { PluginInput } from "@opencode-ai/plugin" import type { PluginInput } from "@opencode-ai/plugin"
@@ -6,6 +9,9 @@ import { unsafeTestValue } from "../../test-support/unsafe-test-value"
type TestShellResult = ReturnType<NonNullable<PluginInput["$"]>>
type TestShellFactory = (cmd: TemplateStringsArray, ...values: unknown[]) => TestShellResult
function createShellPromise(handler: (cmdStr: string) => void) { function createShellPromise(handler: (cmdStr: string) => void) {
return (cmd: TemplateStringsArray, ...values: unknown[]) => { return (cmd: TemplateStringsArray, ...values: unknown[]) => {
const cmdStr = cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "") const cmdStr = cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "")
@@ -64,6 +70,29 @@ function createThrowingShellPromise(shouldThrow: (cmdStr: string) => boolean) {
} }
} }
type ExecFileCall = {
readonly file: string
readonly args: readonly string[]
readonly options: { readonly windowsHide?: boolean }
}
function mockExecFile(calls: ExecFileCall[], error: Error | null = null): ReturnType<typeof spyOn> {
return spyOn(childProcess, "execFile").mockImplementation(
unsafeTestValue<typeof childProcess.execFile>(
(
file: string,
args: readonly string[],
options: { readonly windowsHide?: boolean },
callback: (execError: Error | null, stdout: string, stderr: string) => void
) => {
calls.push({ file, args: [...args], options })
callback(error, "", "")
return unsafeTestValue<ReturnType<typeof childProcess.execFile>>({})
}
)
)
}
describe("session-notification-sender", () => { describe("session-notification-sender", () => {
beforeEach(() => { beforeEach(() => {
jest.restoreAllMocks() jest.restoreAllMocks()
@@ -77,34 +106,33 @@ describe("session-notification-sender", () => {
spyOn(utils, "getAplayPath").mockResolvedValue("/usr/bin/aplay") spyOn(utils, "getAplayPath").mockResolvedValue("/usr/bin/aplay")
}) })
afterEach(() => {
jest.restoreAllMocks()
})
describe("#given sendSessionNotification", () => { describe("#given sendSessionNotification", () => {
describe("#when ctx.$ is unavailable", () => { describe("#when ctx.$ is unavailable", () => {
test("#then it returns early without throwing when ctx has no $", async () => { test("#then it falls back to execFile without throwing", async () => {
const cmuxSpy = spyOn(utils, "getCmuxPath") const execFileCalls: ExecFileCall[] = []
mockExecFile(execFileCalls)
const mockCtx = unsafeTestValue<PluginInput>({}) const mockCtx = unsafeTestValue<PluginInput>({})
await expect(sender.sendSessionNotification(mockCtx, "darwin", "Test", "Message")).resolves.toBeUndefined() await sender.sendSessionNotification(mockCtx, "win32", "Test", "Message")
expect(cmuxSpy).not.toHaveBeenCalled()
expect(execFileCalls.length).toBe(1)
expect(execFileCalls[0]?.file).toBe("powershell")
expect(execFileCalls[0]?.args[0]).toBe("-Command")
expect(execFileCalls[0]?.options.windowsHide).toBe(true)
}) })
test("#then it returns early without throwing when ctx.$ is not a function", async () => { test("#then it swallows execFile rejection without throwing", async () => {
const cmuxSpy = spyOn(utils, "getCmuxPath") const execFileCalls: ExecFileCall[] = []
const mockCtx = unsafeTestValue<PluginInput>({ mockExecFile(execFileCalls, new Error("execFile failed"))
$: "not-a-function",
})
await expect(sender.sendSessionNotification(mockCtx, "darwin", "Test", "Message")).resolves.toBeUndefined()
expect(cmuxSpy).not.toHaveBeenCalled()
})
test("#then it remains non-throwing across sender APIs", async () => {
const afplaySpy = spyOn(utils, "getAfplayPath")
const mockCtx = unsafeTestValue<PluginInput>({}) const mockCtx = unsafeTestValue<PluginInput>({})
await expect(sender.sendSessionNotification(mockCtx, "darwin", "Test", "Message")).resolves.toBeUndefined() await sender.sendSessionNotification(mockCtx, "win32", "Test", "Message")
await expect(sender.playSessionNotificationSound(mockCtx, "darwin", "/sound.aiff")).resolves.toBeUndefined()
expect(afplaySpy).not.toHaveBeenCalled() expect(execFileCalls.length).toBe(1)
}) })
}) })
@@ -192,13 +220,13 @@ describe("session-notification-sender", () => {
$: createThrowingShellPromise((cmdStr) => cmdStr.includes("cmux notify")), $: createThrowingShellPromise((cmdStr) => cmdStr.includes("cmux notify")),
}) })
const originalFactory = mockCtx.$ const originalFactory = unsafeTestValue<TestShellFactory>(mockCtx.$)
const trackingCalls: string[] = [] const trackingCalls: string[] = []
mockCtx.$ = ((cmd: TemplateStringsArray, ...values: unknown[]) => { mockCtx.$ = unsafeTestValue<typeof mockCtx.$>((cmd: TemplateStringsArray, ...values: unknown[]) => {
const cmdStr = cmd.reduce((acc: string, part: string, i: number) => acc + part + (values[i] ?? ""), "") const cmdStr = cmd.reduce((acc: string, part: string, i: number) => acc + part + (values[i] ?? ""), "")
trackingCalls.push(cmdStr) trackingCalls.push(cmdStr)
return originalFactory(cmd, ...values) return originalFactory(cmd, ...values)
}) as typeof mockCtx.$ })
await sender.sendSessionNotification(mockCtx, "darwin", "Test", "Message") await sender.sendSessionNotification(mockCtx, "darwin", "Test", "Message")
@@ -215,12 +243,12 @@ describe("session-notification-sender", () => {
$: createThrowingShellPromise((cmdStr) => cmdStr.includes("cmux notify") || cmdStr.includes("terminal-notifier")), $: createThrowingShellPromise((cmdStr) => cmdStr.includes("cmux notify") || cmdStr.includes("terminal-notifier")),
}) })
const originalFactory = mockCtx.$ const originalFactory = unsafeTestValue<TestShellFactory>(mockCtx.$)
mockCtx.$ = ((cmd: TemplateStringsArray, ...values: unknown[]) => { mockCtx.$ = unsafeTestValue<typeof mockCtx.$>((cmd: TemplateStringsArray, ...values: unknown[]) => {
const cmdStr = cmd.reduce((acc: string, part: string, i: number) => acc + part + (values[i] ?? ""), "") const cmdStr = cmd.reduce((acc: string, part: string, i: number) => acc + part + (values[i] ?? ""), "")
trackingCalls.push(cmdStr) trackingCalls.push(cmdStr)
return originalFactory(cmd, ...values) return originalFactory(cmd, ...values)
}) as typeof mockCtx.$ })
await sender.sendSessionNotification(mockCtx, "darwin", "Test", "Message") await sender.sendSessionNotification(mockCtx, "darwin", "Test", "Message")
+151 -23
View File
@@ -1,4 +1,6 @@
import type { PluginInput } from "@opencode-ai/plugin" import type { PluginInput } from "@opencode-ai/plugin"
import { execFile } from "node:child_process"
import { promisify } from "node:util"
import { platform } from "os" import { platform } from "os"
import { log } from "../shared" import { log } from "../shared"
import { import {
@@ -39,17 +41,45 @@ type ShellCommand = Promise<unknown> & {
nothrow?: () => ShellCommand nothrow?: () => ShellCommand
} }
type ShellRunner = NonNullable<PluginInput["$"]>
type ShellFailureMode = "throw" | "nothrow"
let hasLoggedUnavailableShellHelper = false let hasLoggedUnavailableShellHelper = false
function canRunNotificationCommand(ctx: PluginInput): boolean { function getShellRunner(ctx: PluginInput): ShellRunner | undefined {
if (typeof ctx?.$ === "function") return true // Guard for #4128 + #4061: OpenCode Desktop's Electron sidecar can omit Bun's ctx.$ helper.
if (typeof ctx.$ === "function") return ctx.$
if (!hasLoggedUnavailableShellHelper) { if (!hasLoggedUnavailableShellHelper) {
hasLoggedUnavailableShellHelper = true hasLoggedUnavailableShellHelper = true
log("[session-notification] ctx.$ unavailable; skipping notification command execution") log("[session-notification] ctx.$ unavailable; falling back to child_process.execFile")
} }
return false return undefined
}
function logCommandFailure(commandName: string, error: Error | string): void {
log("[session-notification] notification command failed", {
commandName,
error: typeof error === "string" ? error : error.message,
})
}
function logOperationFailure(operation: string, error: Error | string): void {
log("[session-notification] notification operation failed", {
operation,
error: typeof error === "string" ? error : error.message,
})
}
async function runQuiet(command: ShellCommand): Promise<void> {
if (typeof command.quiet === "function") {
await command.quiet()
return
}
await command
} }
async function runQuietNothrow(command: ShellCommand): Promise<void> { async function runQuietNothrow(command: ShellCommand): Promise<void> {
@@ -62,23 +92,59 @@ async function runQuietNothrow(command: ShellCommand): Promise<void> {
await safeCommand await safeCommand
} }
async function runExecFile(commandPath: string, args: readonly string[]): Promise<void> {
const execFileAsync = promisify(execFile)
await execFileAsync(commandPath, [...args], { windowsHide: true })
}
async function runNotificationCommand(
ctx: PluginInput,
commandPath: string,
args: readonly string[],
shellCommand: (shell: ShellRunner) => ShellCommand,
shellFailureMode: ShellFailureMode = "nothrow"
): Promise<void> {
const shell = getShellRunner(ctx)
if (shell) {
if (shellFailureMode === "throw") {
await runQuiet(shellCommand(shell))
return
}
await runQuietNothrow(shellCommand(shell))
return
}
await runExecFile(commandPath, args)
}
export async function sendSessionNotification( export async function sendSessionNotification(
ctx: PluginInput, ctx: PluginInput,
platform: Platform, platform: Platform,
title: string, title: string,
message: string message: string
): Promise<void> { ): Promise<void> {
if (!canRunNotificationCommand(ctx)) return try {
switch (platform) { switch (platform) {
case "darwin": { case "darwin": {
// Try cmux first - native UNUserNotificationCenter, properly attributed // Try cmux first - native UNUserNotificationCenter, properly attributed
const cmuxPath = await getCmuxPath() const cmuxPath = await getCmuxPath()
if (cmuxPath) { if (cmuxPath) {
try { try {
await ctx.$`${cmuxPath} notify --title ${title} --body ${message}`.quiet() await runNotificationCommand(
ctx,
cmuxPath,
["notify", "--title", title, "--body", message],
(shell) => shell`${cmuxPath} notify --title ${title} --body ${message}`,
"throw"
)
break break
} catch { } catch (error) {
if (error instanceof Error) {
logCommandFailure("cmux", error)
} else {
logCommandFailure("cmux", String(error))
}
} }
} }
@@ -86,14 +152,26 @@ export async function sendSessionNotification(
const terminalNotifierPath = await getTerminalNotifierPath() const terminalNotifierPath = await getTerminalNotifierPath()
if (terminalNotifierPath) { if (terminalNotifierPath) {
const bundleId = process.env.__CFBundleIdentifier const bundleId = process.env.__CFBundleIdentifier
const args = bundleId
? ["-title", title, "-message", message, "-activate", bundleId]
: ["-title", title, "-message", message]
try { try {
if (bundleId) { await runNotificationCommand(
await ctx.$`${terminalNotifierPath} -title ${title} -message ${message} -activate ${bundleId}`.quiet() ctx,
} else { terminalNotifierPath,
await ctx.$`${terminalNotifierPath} -title ${title} -message ${message}`.quiet() args,
} (shell) => bundleId
? shell`${terminalNotifierPath} -title ${title} -message ${message} -activate ${bundleId}`
: shell`${terminalNotifierPath} -title ${title} -message ${message}`,
"throw"
)
break break
} catch { } catch (error) {
if (error instanceof Error) {
logCommandFailure("terminal-notifier", error)
} else {
logCommandFailure("terminal-notifier", String(error))
}
} }
} }
@@ -103,14 +181,25 @@ export async function sendSessionNotification(
const escapedTitle = escapeAppleScriptText(title) const escapedTitle = escapeAppleScriptText(title)
const escapedMessage = escapeAppleScriptText(message) const escapedMessage = escapeAppleScriptText(message)
await runQuietNothrow(ctx.$`${osascriptPath} -e ${"display notification \"" + escapedMessage + "\" with title \"" + escapedTitle + "\""}`) const appleScript = "display notification \"" + escapedMessage + "\" with title \"" + escapedTitle + "\""
await runNotificationCommand(
ctx,
osascriptPath,
["-e", appleScript],
(shell) => shell`${osascriptPath} -e ${appleScript}`
)
break break
} }
case "linux": { case "linux": {
const notifySendPath = await getNotifySendPath() const notifySendPath = await getNotifySendPath()
if (!notifySendPath) return if (!notifySendPath) return
await runQuietNothrow(ctx.$`${notifySendPath} ${title} ${message} 2>/dev/null`) await runNotificationCommand(
ctx,
notifySendPath,
[title, message],
(shell) => shell`${notifySendPath} ${title} ${message} 2>/dev/null`
)
break break
} }
case "win32": { case "win32": {
@@ -118,10 +207,22 @@ export async function sendSessionNotification(
if (!powershellPath) return if (!powershellPath) return
const toastScript = buildWindowsToastScript(title, message) const toastScript = buildWindowsToastScript(title, message)
await runQuietNothrow(ctx.$`${powershellPath} -Command ${toastScript}`) await runNotificationCommand(
ctx,
powershellPath,
["-Command", toastScript],
(shell) => shell`${powershellPath} -Command ${toastScript}`
)
break break
} }
} }
} catch (error) {
if (error instanceof Error) {
logOperationFailure("send", error)
} else {
logOperationFailure("send", String(error))
}
}
} }
export async function playSessionNotificationSound( export async function playSessionNotificationSound(
@@ -129,23 +230,37 @@ export async function playSessionNotificationSound(
platform: Platform, platform: Platform,
soundPath: string soundPath: string
): Promise<void> { ): Promise<void> {
if (!canRunNotificationCommand(ctx)) return try {
switch (platform) { switch (platform) {
case "darwin": { case "darwin": {
const afplayPath = await getAfplayPath() const afplayPath = await getAfplayPath()
if (!afplayPath) return if (!afplayPath) return
await runQuietNothrow(ctx.$`${afplayPath} ${soundPath}`) await runNotificationCommand(
ctx,
afplayPath,
[soundPath],
(shell) => shell`${afplayPath} ${soundPath}`
)
break break
} }
case "linux": { case "linux": {
const paplayPath = await getPaplayPath() const paplayPath = await getPaplayPath()
if (paplayPath) { if (paplayPath) {
await runQuietNothrow(ctx.$`${paplayPath} ${soundPath} 2>/dev/null`) await runNotificationCommand(
ctx,
paplayPath,
[soundPath],
(shell) => shell`${paplayPath} ${soundPath} 2>/dev/null`
)
} else { } else {
const aplayPath = await getAplayPath() const aplayPath = await getAplayPath()
if (aplayPath) { if (aplayPath) {
await runQuietNothrow(ctx.$`${aplayPath} ${soundPath} 2>/dev/null`) await runNotificationCommand(
ctx,
aplayPath,
[soundPath],
(shell) => shell`${aplayPath} ${soundPath} 2>/dev/null`
)
} }
} }
break break
@@ -154,8 +269,21 @@ export async function playSessionNotificationSound(
const powershellPath = await getPowershellPath() const powershellPath = await getPowershellPath()
if (!powershellPath) return if (!powershellPath) return
const escaped = escapePowerShellSingleQuotedText(soundPath) const escaped = escapePowerShellSingleQuotedText(soundPath)
await runQuietNothrow(ctx.$`${powershellPath} -Command ${"(New-Object Media.SoundPlayer '" + escaped + "').PlaySync()"}`) const soundScript = "(New-Object Media.SoundPlayer '" + escaped + "').PlaySync()"
await runNotificationCommand(
ctx,
powershellPath,
["-Command", soundScript],
(shell) => shell`${powershellPath} -Command ${soundScript}`
)
break break
} }
} }
} catch (error) {
if (error instanceof Error) {
logOperationFailure("sound", error)
} else {
logOperationFailure("sound", String(error))
}
}
} }