From b882d89f99b096f81f4f59e8fde66ae15f698633 Mon Sep 17 00:00:00 2001 From: Matt Silverlock Date: Sun, 4 Jan 2026 09:16:06 -0500 Subject: [PATCH 1/4] fix: auto-deny permissions and emit events in non-interactive mode - Add isInteractive() utility to detect CI/non-TTY environments - Auto-deny permission requests when running non-interactively - Emit permission.asked events before denying for visibility - Subscribe to permission events in github.ts and run.ts with actionable guidance - Add OPENCODE_EMIT_SUBAGENT_EVENTS flag for verbose subagent output - Add emit_subagent_events input to GitHub Action --- github/action.yml | 6 ++ packages/opencode/src/cli/cmd/github.ts | 98 +++++++++++++++++------ packages/opencode/src/flag/flag.ts | 1 + packages/opencode/src/permission/next.ts | 20 ++++- packages/opencode/src/util/interactive.ts | 6 ++ packages/opencode/test/preload.ts | 3 + 6 files changed, 105 insertions(+), 29 deletions(-) create mode 100644 packages/opencode/src/util/interactive.ts diff --git a/github/action.yml b/github/action.yml index 8652bb8c1517..a322d0356d96 100644 --- a/github/action.yml +++ b/github/action.yml @@ -30,6 +30,11 @@ inputs: description: "Comma-separated list of trigger phrases (case-insensitive). Defaults to '/opencode,/oc'" required: false + emit_subagent_events: + description: "Emit subagent tool calls and events to workflow logs (verbose output)" + required: false + default: "false" + oidc_base_url: description: "Base URL for OIDC token exchange API. Only required when running a custom GitHub App install. Defaults to https://api.opencode.ai" required: false @@ -72,3 +77,4 @@ runs: USE_GITHUB_TOKEN: ${{ inputs.use_github_token }} MENTIONS: ${{ inputs.mentions }} OIDC_BASE_URL: ${{ inputs.oidc_base_url }} + OPENCODE_EMIT_SUBAGENT_EVENTS: ${{ inputs.emit_subagent_events }} diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index 7f9a03d948a0..2cf0caf677c6 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -24,7 +24,9 @@ import { Session } from "../../session" import { Identifier } from "../../id/id" import { Provider } from "../../provider/provider" import { Bus } from "../../bus" +import { Flag } from "../../flag/flag" import { MessageV2 } from "../../session/message-v2" +import { PermissionNext } from "../../permission/next" import { SessionPrompt } from "@/session/prompt" import { $ } from "bun" @@ -467,6 +469,7 @@ export const GithubRunCommand = cmd({ let gitConfig: string let session: { id: string; title: string; version: string } let shareId: string | undefined + let unsubscribeEvents: (() => void) | undefined let exitCode = 0 type PromptFiles = Awaited>["promptFiles"] const triggerCommentId = isCommentEvent @@ -518,7 +521,7 @@ export const GithubRunCommand = cmd({ }, ], }) - subscribeSessionEvents() + unsubscribeEvents = subscribeSessionEvents() shareId = await (async () => { if (share === false) return if (!share && repoData.data.private) return @@ -633,6 +636,7 @@ export const GithubRunCommand = cmd({ // Also output the clean error message for the action to capture //core.setOutput("prepare_error", e.message); } finally { + unsubscribeEvents?.() if (!useGithubToken) { await restoreGitConfig() await revokeAppToken() @@ -828,34 +832,78 @@ export const GithubRunCommand = cmd({ ) } + // Track sessions: main session + subagent sessions when OPENCODE_EMIT_SUBAGENT_EVENTS + const trackedSessions = new Set([session.id]) + const unsubscribes: Array<() => void> = [] + + if (Flag.OPENCODE_EMIT_SUBAGENT_EVENTS) { + unsubscribes.push( + Bus.subscribe(Session.Event.Created, (evt) => { + const info = evt.properties.info + if (info.parentID && trackedSessions.has(info.parentID)) { + trackedSessions.add(info.id) + console.log() + printEvent(UI.Style.TEXT_INFO_BOLD, "Agent", info.title ?? "Subagent started") + } + }), + ) + } + let text = "" - Bus.subscribe(MessageV2.Event.PartUpdated, async (evt) => { - if (evt.properties.part.sessionID !== session.id) return - //if (evt.properties.part.messageID === messageID) return - const part = evt.properties.part - - if (part.type === "tool" && part.state.status === "completed") { - const [tool, color] = TOOL[part.tool] ?? [part.tool, UI.Style.TEXT_INFO_BOLD] - const title = - part.state.title || Object.keys(part.state.input).length > 0 - ? JSON.stringify(part.state.input) - : "Unknown" - console.log() - printEvent(color, tool, title) - } + unsubscribes.push( + Bus.subscribe(MessageV2.Event.PartUpdated, async (evt) => { + const shouldTrack = + evt.properties.part.sessionID === session.id || + (Flag.OPENCODE_EMIT_SUBAGENT_EVENTS && trackedSessions.has(evt.properties.part.sessionID)) + if (!shouldTrack) return + + const part = evt.properties.part + + if (part.type === "tool" && part.state.status === "completed") { + const [tool, color] = TOOL[part.tool] ?? [part.tool, UI.Style.TEXT_INFO_BOLD] + const title = + part.state.title || Object.keys(part.state.input).length > 0 + ? JSON.stringify(part.state.input) + : "Unknown" + console.log() + printEvent(color, tool, title) + } - if (part.type === "text") { - text = part.text + if (part.type === "text") { + text = part.text - if (part.time?.end) { - UI.empty() - UI.println(UI.markdown(text)) - UI.empty() - text = "" - return + if (part.time?.end) { + UI.empty() + UI.println(UI.markdown(text)) + UI.empty() + text = "" + return + } } - } - }) + }), + ) + + // Subscribe to permission events (auto-denied in non-interactive mode) + unsubscribes.push( + Bus.subscribe(PermissionNext.Event.Asked, async (evt) => { + const shouldTrack = + evt.properties.sessionID === session.id || + (Flag.OPENCODE_EMIT_SUBAGENT_EVENTS && trackedSessions.has(evt.properties.sessionID)) + if (!shouldTrack) return + + console.log() + printEvent( + UI.Style.TEXT_WARNING_BOLD, + "Denied", + `${evt.properties.permission}: ${evt.properties.patterns.join(", ")}`, + ) + console.log( + ` To allow, add to opencode.json: { "permission": { "${evt.properties.permission}": "allow" } }`, + ) + }), + ) + + return () => unsubscribes.forEach((unsub) => unsub()) } async function summarize(response: string) { diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index dfcb88bc51a5..1f6c2e703bff 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -30,6 +30,7 @@ export namespace Flag { export declare const OPENCODE_CLIENT: string export const OPENCODE_SERVER_PASSWORD = process.env["OPENCODE_SERVER_PASSWORD"] export const OPENCODE_SERVER_USERNAME = process.env["OPENCODE_SERVER_USERNAME"] + export const OPENCODE_EMIT_SUBAGENT_EVENTS = truthy("OPENCODE_EMIT_SUBAGENT_EVENTS") // Experimental export const OPENCODE_EXPERIMENTAL = truthy("OPENCODE_EXPERIMENTAL") diff --git a/packages/opencode/src/permission/next.ts b/packages/opencode/src/permission/next.ts index 1e1df62a3ce9..669999d51f18 100644 --- a/packages/opencode/src/permission/next.ts +++ b/packages/opencode/src/permission/next.ts @@ -6,6 +6,7 @@ import { Instance } from "@/project/instance" import { Database, eq } from "@/storage/db" import { PermissionTable } from "@/session/session.sql" import { fn } from "@/util/fn" +import { isInteractive } from "@/util/interactive" import { Log } from "@/util/log" import { Wildcard } from "@/util/wildcard" import os from "os" @@ -142,11 +143,22 @@ export namespace PermissionNext { throw new DeniedError(ruleset.filter((r) => Wildcard.match(request.permission, r.permission))) if (rule.action === "ask") { const id = input.id ?? Identifier.ascending("permission") + const info: Request = { + id, + ...request, + } + + // Non-interactive mode: no one to approve, auto-deny + if (!isInteractive()) { + Bus.publish(Event.Asked, info) + log.warn("auto-denied permission in non-interactive mode", { + permission: request.permission, + patterns: request.patterns, + }) + throw new DeniedError([{ permission: request.permission, pattern: "*", action: "deny" }]) + } + return new Promise((resolve, reject) => { - const info: Request = { - id, - ...request, - } s.pending[id] = { info, resolve, diff --git a/packages/opencode/src/util/interactive.ts b/packages/opencode/src/util/interactive.ts new file mode 100644 index 000000000000..ca5f7714dc03 --- /dev/null +++ b/packages/opencode/src/util/interactive.ts @@ -0,0 +1,6 @@ +export function isInteractive(): boolean { + // Allow tests to override interactive detection + if (process.env.OPENCODE_FORCE_INTERACTIVE === "true") return true + if (process.env.CI === "true" || process.env.CI === "1") return false + return process.stdin.isTTY === true && process.stdout.isTTY === true +} diff --git a/packages/opencode/test/preload.ts b/packages/opencode/test/preload.ts index dee7045707ea..e6ba2e808db2 100644 --- a/packages/opencode/test/preload.ts +++ b/packages/opencode/test/preload.ts @@ -34,6 +34,9 @@ const cacheDir = path.join(dir, "cache", "opencode") await fs.mkdir(cacheDir, { recursive: true }) await fs.writeFile(path.join(cacheDir, "version"), "14") +// Force interactive mode for tests that test permission prompts +process.env["OPENCODE_FORCE_INTERACTIVE"] = "true" + // Clear provider env vars to ensure clean test state delete process.env["ANTHROPIC_API_KEY"] delete process.env["OPENAI_API_KEY"] From b5e259bddcd070bd57b40fcbe5dd797221c0ad2c Mon Sep 17 00:00:00 2001 From: Matt Silverlock Date: Sun, 4 Jan 2026 12:48:05 -0500 Subject: [PATCH 2/4] fix: respect OPENCODE_CLIENT for non-CLI clients (desktop app) GUI clients like the desktop app handle permissions through their own UI, so we should not auto-deny when OPENCODE_CLIENT is set to something other than 'cli'. --- packages/opencode/src/util/interactive.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/opencode/src/util/interactive.ts b/packages/opencode/src/util/interactive.ts index ca5f7714dc03..adb0e83acaf0 100644 --- a/packages/opencode/src/util/interactive.ts +++ b/packages/opencode/src/util/interactive.ts @@ -2,5 +2,7 @@ export function isInteractive(): boolean { // Allow tests to override interactive detection if (process.env.OPENCODE_FORCE_INTERACTIVE === "true") return true if (process.env.CI === "true" || process.env.CI === "1") return false + // Desktop and other GUI clients handle permissions through their own UI + if (process.env.OPENCODE_CLIENT && process.env.OPENCODE_CLIENT !== "cli") return true return process.stdin.isTTY === true && process.stdout.isTTY === true } From 1b409da31c230c18f49c894f60af9ac5786a0b1d Mon Sep 17 00:00:00 2001 From: Matt Silverlock Date: Tue, 13 Jan 2026 18:56:13 -0500 Subject: [PATCH 3/4] fix: variable shadowing, case-insensitive CI check, add tests - Fix unsubscribeEvents shadowing bug in github.ts (cleanup was never called) - Make CI env var check case-insensitive (CI=TRUE now works) - Use bracket notation for env var access (codebase consistency) - Add unit tests for isInteractive() covering all code paths --- packages/opencode/src/util/interactive.ts | 8 +- .../opencode/test/util/interactive.test.ts | 103 ++++++++++++++++++ 2 files changed, 108 insertions(+), 3 deletions(-) create mode 100644 packages/opencode/test/util/interactive.test.ts diff --git a/packages/opencode/src/util/interactive.ts b/packages/opencode/src/util/interactive.ts index adb0e83acaf0..dc013e4251aa 100644 --- a/packages/opencode/src/util/interactive.ts +++ b/packages/opencode/src/util/interactive.ts @@ -1,8 +1,10 @@ export function isInteractive(): boolean { // Allow tests to override interactive detection - if (process.env.OPENCODE_FORCE_INTERACTIVE === "true") return true - if (process.env.CI === "true" || process.env.CI === "1") return false + if (process.env["OPENCODE_FORCE_INTERACTIVE"] === "true") return true + const ci = process.env["CI"]?.toLowerCase() + if (ci === "true" || ci === "1") return false // Desktop and other GUI clients handle permissions through their own UI - if (process.env.OPENCODE_CLIENT && process.env.OPENCODE_CLIENT !== "cli") return true + const client = process.env["OPENCODE_CLIENT"] + if (client && client !== "cli") return true return process.stdin.isTTY === true && process.stdout.isTTY === true } diff --git a/packages/opencode/test/util/interactive.test.ts b/packages/opencode/test/util/interactive.test.ts new file mode 100644 index 000000000000..bacca671d6e6 --- /dev/null +++ b/packages/opencode/test/util/interactive.test.ts @@ -0,0 +1,103 @@ +import { describe, test, expect, beforeEach, afterEach } from "bun:test" + +// Store original env values +const originalEnv: Record = {} + +function saveEnv(...keys: string[]) { + for (const key of keys) { + originalEnv[key] = process.env[key] + } +} + +function restoreEnv() { + for (const [key, value] of Object.entries(originalEnv)) { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } +} + +function clearEnv(...keys: string[]) { + for (const key of keys) { + delete process.env[key] + } +} + +// Dynamic import to get fresh module state +async function getIsInteractive() { + // Clear module cache to get fresh evaluation + const path = "../../src/util/interactive" + delete require.cache[require.resolve(path)] + const { isInteractive } = await import(path) + return isInteractive +} + +describe("isInteractive", () => { + beforeEach(() => { + saveEnv("OPENCODE_FORCE_INTERACTIVE", "CI", "OPENCODE_CLIENT") + clearEnv("OPENCODE_FORCE_INTERACTIVE", "CI", "OPENCODE_CLIENT") + }) + + afterEach(() => { + restoreEnv() + }) + + test("returns true when OPENCODE_FORCE_INTERACTIVE=true", async () => { + process.env["OPENCODE_FORCE_INTERACTIVE"] = "true" + process.env["CI"] = "true" // Should be overridden + const isInteractive = await getIsInteractive() + expect(isInteractive()).toBe(true) + }) + + test("returns false when CI=true", async () => { + process.env["CI"] = "true" + const isInteractive = await getIsInteractive() + expect(isInteractive()).toBe(false) + }) + + test("returns false when CI=1", async () => { + process.env["CI"] = "1" + const isInteractive = await getIsInteractive() + expect(isInteractive()).toBe(false) + }) + + test("returns false when CI=TRUE (case insensitive)", async () => { + process.env["CI"] = "TRUE" + const isInteractive = await getIsInteractive() + expect(isInteractive()).toBe(false) + }) + + test("returns false when CI=True (case insensitive)", async () => { + process.env["CI"] = "True" + const isInteractive = await getIsInteractive() + expect(isInteractive()).toBe(false) + }) + + test("returns true when OPENCODE_CLIENT=desktop", async () => { + process.env["OPENCODE_CLIENT"] = "desktop" + const isInteractive = await getIsInteractive() + expect(isInteractive()).toBe(true) + }) + + test("returns true when OPENCODE_CLIENT=vscode", async () => { + process.env["OPENCODE_CLIENT"] = "vscode" + const isInteractive = await getIsInteractive() + expect(isInteractive()).toBe(true) + }) + + test("falls through to TTY check when OPENCODE_CLIENT=cli", async () => { + process.env["OPENCODE_CLIENT"] = "cli" + const isInteractive = await getIsInteractive() + // In test environment, TTY is typically false + const expected = process.stdin.isTTY === true && process.stdout.isTTY === true + expect(isInteractive()).toBe(expected) + }) + + test("falls through to TTY check when no env vars set", async () => { + const isInteractive = await getIsInteractive() + const expected = process.stdin.isTTY === true && process.stdout.isTTY === true + expect(isInteractive()).toBe(expected) + }) +}) From feebf8b78604ca55ad6bfae81c34ab694fb76b61 Mon Sep 17 00:00:00 2001 From: Matt Silverlock Date: Thu, 19 Feb 2026 07:19:57 -0500 Subject: [PATCH 4/4] use Flag getter, simplify tests, pass sessionID explicitly --- packages/opencode/src/cli/cmd/github.ts | 10 ++--- packages/opencode/src/util/interactive.ts | 5 ++- .../opencode/test/util/interactive.test.ts | 39 +++++-------------- 3 files changed, 18 insertions(+), 36 deletions(-) diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index 4fce9ec8d6f8..ef32ba6d9c99 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -534,7 +534,7 @@ export const GithubRunCommand = cmd({ }, ], }) - unsubscribeEvents = subscribeSessionEvents() + unsubscribeEvents = subscribeSessionEvents(session.id) shareId = await (async () => { if (share === false) return if (!share && repoData.data.private) return @@ -823,7 +823,7 @@ export const GithubRunCommand = cmd({ return { userPrompt: prompt, promptFiles: imgData } } - function subscribeSessionEvents() { + function subscribeSessionEvents(sessionID: string) { const TOOL: Record = { todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD], todoread: ["Todo", UI.Style.TEXT_WARNING_BOLD], @@ -847,7 +847,7 @@ export const GithubRunCommand = cmd({ } // Track sessions: main session + subagent sessions when OPENCODE_EMIT_SUBAGENT_EVENTS - const trackedSessions = new Set([session.id]) + const trackedSessions = new Set([sessionID]) const unsubscribes: Array<() => void> = [] if (Flag.OPENCODE_EMIT_SUBAGENT_EVENTS) { @@ -867,7 +867,7 @@ export const GithubRunCommand = cmd({ unsubscribes.push( Bus.subscribe(MessageV2.Event.PartUpdated, async (evt) => { const shouldTrack = - evt.properties.part.sessionID === session.id || + evt.properties.part.sessionID === sessionID || (Flag.OPENCODE_EMIT_SUBAGENT_EVENTS && trackedSessions.has(evt.properties.part.sessionID)) if (!shouldTrack) return @@ -901,7 +901,7 @@ export const GithubRunCommand = cmd({ unsubscribes.push( Bus.subscribe(PermissionNext.Event.Asked, async (evt) => { const shouldTrack = - evt.properties.sessionID === session.id || + evt.properties.sessionID === sessionID || (Flag.OPENCODE_EMIT_SUBAGENT_EVENTS && trackedSessions.has(evt.properties.sessionID)) if (!shouldTrack) return diff --git a/packages/opencode/src/util/interactive.ts b/packages/opencode/src/util/interactive.ts index dc013e4251aa..40b0648e5f06 100644 --- a/packages/opencode/src/util/interactive.ts +++ b/packages/opencode/src/util/interactive.ts @@ -1,10 +1,11 @@ +import { Flag } from "@/flag/flag" + export function isInteractive(): boolean { // Allow tests to override interactive detection if (process.env["OPENCODE_FORCE_INTERACTIVE"] === "true") return true const ci = process.env["CI"]?.toLowerCase() if (ci === "true" || ci === "1") return false // Desktop and other GUI clients handle permissions through their own UI - const client = process.env["OPENCODE_CLIENT"] - if (client && client !== "cli") return true + if (Flag.OPENCODE_CLIENT !== "cli") return true return process.stdin.isTTY === true && process.stdout.isTTY === true } diff --git a/packages/opencode/test/util/interactive.test.ts b/packages/opencode/test/util/interactive.test.ts index bacca671d6e6..4f4f77f2cc71 100644 --- a/packages/opencode/test/util/interactive.test.ts +++ b/packages/opencode/test/util/interactive.test.ts @@ -1,6 +1,6 @@ import { describe, test, expect, beforeEach, afterEach } from "bun:test" +import { isInteractive } from "../../src/util/interactive" -// Store original env values const originalEnv: Record = {} function saveEnv(...keys: string[]) { @@ -25,15 +25,6 @@ function clearEnv(...keys: string[]) { } } -// Dynamic import to get fresh module state -async function getIsInteractive() { - // Clear module cache to get fresh evaluation - const path = "../../src/util/interactive" - delete require.cache[require.resolve(path)] - const { isInteractive } = await import(path) - return isInteractive -} - describe("isInteractive", () => { beforeEach(() => { saveEnv("OPENCODE_FORCE_INTERACTIVE", "CI", "OPENCODE_CLIENT") @@ -44,59 +35,49 @@ describe("isInteractive", () => { restoreEnv() }) - test("returns true when OPENCODE_FORCE_INTERACTIVE=true", async () => { + test("returns true when OPENCODE_FORCE_INTERACTIVE=true", () => { process.env["OPENCODE_FORCE_INTERACTIVE"] = "true" process.env["CI"] = "true" // Should be overridden - const isInteractive = await getIsInteractive() expect(isInteractive()).toBe(true) }) - test("returns false when CI=true", async () => { + test("returns false when CI=true", () => { process.env["CI"] = "true" - const isInteractive = await getIsInteractive() expect(isInteractive()).toBe(false) }) - test("returns false when CI=1", async () => { + test("returns false when CI=1", () => { process.env["CI"] = "1" - const isInteractive = await getIsInteractive() expect(isInteractive()).toBe(false) }) - test("returns false when CI=TRUE (case insensitive)", async () => { + test("returns false when CI=TRUE (case insensitive)", () => { process.env["CI"] = "TRUE" - const isInteractive = await getIsInteractive() expect(isInteractive()).toBe(false) }) - test("returns false when CI=True (case insensitive)", async () => { + test("returns false when CI=True (case insensitive)", () => { process.env["CI"] = "True" - const isInteractive = await getIsInteractive() expect(isInteractive()).toBe(false) }) - test("returns true when OPENCODE_CLIENT=desktop", async () => { + test("returns true when OPENCODE_CLIENT=desktop", () => { process.env["OPENCODE_CLIENT"] = "desktop" - const isInteractive = await getIsInteractive() expect(isInteractive()).toBe(true) }) - test("returns true when OPENCODE_CLIENT=vscode", async () => { + test("returns true when OPENCODE_CLIENT=vscode", () => { process.env["OPENCODE_CLIENT"] = "vscode" - const isInteractive = await getIsInteractive() expect(isInteractive()).toBe(true) }) - test("falls through to TTY check when OPENCODE_CLIENT=cli", async () => { + test("falls through to TTY check when OPENCODE_CLIENT=cli", () => { process.env["OPENCODE_CLIENT"] = "cli" - const isInteractive = await getIsInteractive() - // In test environment, TTY is typically false const expected = process.stdin.isTTY === true && process.stdout.isTTY === true expect(isInteractive()).toBe(expected) }) - test("falls through to TTY check when no env vars set", async () => { - const isInteractive = await getIsInteractive() + test("falls through to TTY check when no env vars set", () => { const expected = process.stdin.isTTY === true && process.stdout.isTTY === true expect(isInteractive()).toBe(expected) })