From ef56b77aa3943b69a2b32c6666f24121aaab583a Mon Sep 17 00:00:00 2001 From: rdar-lab Date: Fri, 3 Apr 2026 10:23:19 +0300 Subject: [PATCH] fix: reject permission requests immediately in non-interactive mode --- packages/opencode/src/permission/index.ts | 10 +++++ .../opencode/test/permission/next.test.ts | 37 +++++++++++++++++++ packages/opencode/test/preload.ts | 5 +++ 3 files changed, 52 insertions(+) diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index b2cc0f9bbc07..15ca2aa0863e 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -183,6 +183,16 @@ export namespace Permission { if (!needsAsk) return + // In non-interactive/headless mode (no TTY, e.g. GitHub Actions), auto-reject + // permission requests that would normally block waiting for user input + if (!process.stdout.isTTY) { + log.info("noninteractive mode (no TTY), rejecting permission request", { + permission: request.permission, + patterns: request.patterns, + }) + return yield* new RejectedError() + } + const id = request.id ?? PermissionID.ascending() const info: Request = { id, diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index 043e3257b64f..68c74ffabcbe 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -1146,3 +1146,40 @@ test("ask - abort should clear pending request", async () => { }, }) }) + +test("ask - rejects immediately in non-interactive mode without blocking", async () => { + // Save original TTY state and simulate non-interactive (no TTY) + const originalIsTTY = process.stdout.isTTY + process.stdout.isTTY = false + + try { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const start = Date.now() + // In non-interactive mode (no TTY), "ask" should immediately reject + const err = await Permission.ask({ + sessionID: SessionID.make("session_noninteractive"), + permission: "bash", + patterns: ["ls"], + metadata: {}, + always: [], + ruleset: [{ permission: "bash", pattern: "*", action: "ask" }], + }).then( + () => undefined, + (e) => e, + ) + + // Should reject immediately (within 1 second, not hang) + expect(Date.now() - start).toBeLessThan(1000) + expect(err).toBeInstanceOf(Permission.RejectedError) + // No pending requests should be created + expect(await Permission.list()).toHaveLength(0) + }, + }) + } finally { + // Restore original TTY state + process.stdout.isTTY = originalIsTTY + } +}) diff --git a/packages/opencode/test/preload.ts b/packages/opencode/test/preload.ts index 0ddc797faf7f..536751402e50 100644 --- a/packages/opencode/test/preload.ts +++ b/packages/opencode/test/preload.ts @@ -6,6 +6,11 @@ import fs from "fs/promises" import { setTimeout as sleep } from "node:timers/promises" import { afterAll } from "bun:test" +// Mock TTY to true for tests (simulates having a terminal) +// Tests that need to test non-interactive behavior should override this within their test +process.stdout.isTTY = true +process.stderr.isTTY = true + // Set XDG env vars FIRST, before any src/ imports const dir = path.join(os.tmpdir(), "opencode-test-data-" + process.pid) await fs.mkdir(dir, { recursive: true })