diff --git a/github/action.yml b/github/action.yml index 8652bb8c151..a322d0356d9 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 927c964c9d8..edb6b790f30 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" @@ -475,6 +477,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 @@ -526,7 +529,7 @@ export const GithubRunCommand = cmd({ }, ], }) - subscribeSessionEvents() + unsubscribeEvents = subscribeSessionEvents() shareId = await (async () => { if (share === false) return if (!share && repoData.data.private) return @@ -641,6 +644,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() @@ -836,34 +840,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/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 54248f96f3d..5a9b8d895ee 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -11,6 +11,7 @@ import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2" import { Server } from "../../server/server" import { Provider } from "../../provider/provider" import { Agent } from "../../agent/agent" +import { isInteractive } from "../../util/interactive" const TOOL: Record = { todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD], @@ -209,6 +210,18 @@ export const RunCommand = cmd({ if (event.type === "permission.asked") { const permission = event.properties if (permission.sessionID !== sessionID) continue + + // In non-interactive mode, permission was already auto-denied by the server + // Just log what was denied so users can configure permissions + if (!isInteractive()) { + if (outputJsonEvent("permission_denied", { permission })) continue + UI.println() + UI.println(UI.Style.TEXT_WARNING_BOLD + "Permission denied:", permission.permission) + UI.println(` Patterns: ${permission.patterns.join(", ")}`) + UI.println(` To allow: add { "permission": { "${permission.permission}": "allow" } } to opencode.json`) + continue + } + const result = await select({ message: `Permission required: ${permission.permission} (${permission.patterns.join(", ")})`, options: [ diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index 4cdb549096a..62ddfebb5c4 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -20,6 +20,7 @@ export namespace Flag { OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_SKILLS") export const OPENCODE_FAKE_VCS = process.env["OPENCODE_FAKE_VCS"] export const OPENCODE_CLIENT = process.env["OPENCODE_CLIENT"] ?? "cli" + export const OPENCODE_EMIT_SUBAGENT_EVENTS = truthy("OPENCODE_EMIT_SUBAGENT_EVENTS") export const OPENCODE_SERVER_PASSWORD = process.env["OPENCODE_SERVER_PASSWORD"] export const OPENCODE_SERVER_USERNAME = process.env["OPENCODE_SERVER_USERNAME"] diff --git a/packages/opencode/src/permission/next.ts b/packages/opencode/src/permission/next.ts index f95aaf34525..016b528262c 100644 --- a/packages/opencode/src/permission/next.ts +++ b/packages/opencode/src/permission/next.ts @@ -5,6 +5,7 @@ import { Identifier } from "@/id/id" import { Instance } from "@/project/instance" import { Storage } from "@/storage/storage" import { fn } from "@/util/fn" +import { isInteractive } from "@/util/interactive" import { Log } from "@/util/log" import { Wildcard } from "@/util/wildcard" import z from "zod" @@ -127,11 +128,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 00000000000..dc013e4251a --- /dev/null +++ b/packages/opencode/src/util/interactive.ts @@ -0,0 +1,10 @@ +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 + return process.stdin.isTTY === true && process.stdout.isTTY === true +} diff --git a/packages/opencode/test/preload.ts b/packages/opencode/test/preload.ts index 35b0b6c7642..128bda7c718 100644 --- a/packages/opencode/test/preload.ts +++ b/packages/opencode/test/preload.ts @@ -34,6 +34,9 @@ if (response.ok) { // Disable models.dev refresh to avoid race conditions during tests process.env["OPENCODE_DISABLE_MODELS_FETCH"] = "true" +// 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"] diff --git a/packages/opencode/test/util/interactive.test.ts b/packages/opencode/test/util/interactive.test.ts new file mode 100644 index 00000000000..bacca671d6e --- /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) + }) +})