diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index 3f2cbf1394..fc9acf7c92 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -56,7 +56,6 @@ export const Flag = { OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT: copy === undefined ? process.platform === "win32" : truthy("OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"), OPENCODE_ENABLE_EXA: truthy("OPENCODE_ENABLE_EXA") || OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_EXA"), - OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS: number("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"), OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX: number("OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX"), OPENCODE_EXPERIMENTAL_OXFMT: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_OXFMT"), OPENCODE_EXPERIMENTAL_LSP_TY: truthy("OPENCODE_EXPERIMENTAL_LSP_TY"), diff --git a/packages/opencode/src/effect/runtime-flags.ts b/packages/opencode/src/effect/runtime-flags.ts index c01779e247..008ea558b8 100644 --- a/packages/opencode/src/effect/runtime-flags.ts +++ b/packages/opencode/src/effect/runtime-flags.ts @@ -2,6 +2,11 @@ import { Config, ConfigProvider, Context, Effect, Layer } from "effect" import { ConfigService } from "@/effect/config-service" const bool = (name: string) => Config.boolean(name).pipe(Config.withDefault(false)) +const positiveInteger = (name: string) => + Config.number(name).pipe( + Config.map((value) => (Number.isInteger(value) && value > 0 ? value : undefined)), + Config.orElse(() => Config.succeed(undefined)), + ) const experimental = bool("OPENCODE_EXPERIMENTAL") const enabledByExperimental = (name: string) => Config.all({ experimental, enabled: bool(name) }).pipe(Config.map((flags) => flags.experimental || flags.enabled)) @@ -30,6 +35,7 @@ export class Service extends ConfigService.Service()("@opencode/Runtime experimentalPlanMode: enabledByExperimental("OPENCODE_EXPERIMENTAL_PLAN_MODE"), experimentalEventSystem: enabledByExperimental("OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"), experimentalWorkspaces: enabledByExperimental("OPENCODE_EXPERIMENTAL_WORKSPACES"), + bashDefaultTimeoutMs: positiveInteger("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"), client: Config.string("OPENCODE_CLIENT").pipe(Config.withDefault("cli")), }) {} diff --git a/packages/opencode/src/tool/shell.ts b/packages/opencode/src/tool/shell.ts index fcfd59f353..1b3a6152ef 100644 --- a/packages/opencode/src/tool/shell.ts +++ b/packages/opencode/src/tool/shell.ts @@ -12,7 +12,7 @@ import { Language, type Node } from "web-tree-sitter" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { fileURLToPath } from "url" import { Config } from "@/config/config" -import { Flag } from "@opencode-ai/core/flag/flag" +import { RuntimeFlags } from "@/effect/runtime-flags" import { Shell } from "@/shell/shell" import { ShellID } from "./shell/id" @@ -26,7 +26,6 @@ import { BashArity } from "@/permission/arity" export { Parameters } from "./shell/prompt" const MAX_METADATA_LENGTH = 30_000 -const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000 const CWD = new Set(["cd", "chdir", "popd", "pushd", "push-location", "set-location"]) const FILES = new Set([ ...CWD, @@ -340,6 +339,8 @@ export const ShellTool = Tool.define( const fs = yield* AppFileSystem.Service const trunc = yield* Truncate.Service const plugin = yield* Plugin.Service + const flags = yield* RuntimeFlags.Service + const defaultTimeout = flags.bashDefaultTimeoutMs ?? 2 * 60 * 1000 const cygpath = Effect.fn("ShellTool.cygpath")(function* (shell: string, text: string) { const lines = yield* spawner @@ -615,7 +616,7 @@ export const ShellTool = Tool.define( if (params.timeout !== undefined && params.timeout < 0) { throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`) } - const timeout = params.timeout ?? DEFAULT_TIMEOUT + const timeout = params.timeout ?? defaultTimeout const ps = Shell.ps(shell) yield* Effect.scoped( Effect.gen(function* () { diff --git a/packages/opencode/test/effect/runtime-flags.test.ts b/packages/opencode/test/effect/runtime-flags.test.ts index 3397adb018..b05a9923ea 100644 --- a/packages/opencode/test/effect/runtime-flags.test.ts +++ b/packages/opencode/test/effect/runtime-flags.test.ts @@ -44,17 +44,49 @@ describe("RuntimeFlags", () => { it.effect("layer accepts partial test overrides and fills defaults from Config definitions", () => Effect.gen(function* () { - const flags = yield* readFlags.pipe(Effect.provide(RuntimeFlags.layer({ disableDefaultPlugins: true }))) + const flags = yield* readFlags.pipe( + Effect.provide(RuntimeFlags.layer({ disableDefaultPlugins: true, bashDefaultTimeoutMs: 1_000 })), + ) expect(flags.pure).toBe(false) expect(flags.disableDefaultPlugins).toBe(true) expect(flags.disableClaudeCodeSkills).toBe(false) expect(flags.enableExa).toBe(false) + expect(flags.bashDefaultTimeoutMs).toBe(1_000) expect(flags.enableExperimentalModels).toBe(false) expect(flags.client).toBe("cli") }), ) + for (const input of [ + { name: "absent", config: {}, expected: undefined }, + { + name: "valid positive integer", + config: { OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS: "1234" }, + expected: 1234, + }, + { + name: "invalid string", + config: { OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS: "nope" }, + expected: undefined, + }, + { name: "zero", config: { OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS: "0" }, expected: undefined }, + { name: "negative", config: { OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS: "-1" }, expected: undefined }, + { + name: "non-integer", + config: { OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS: "1.5" }, + expected: undefined, + }, + ]) { + it.effect(`parses bashDefaultTimeoutMs from config: ${input.name}`, () => + Effect.gen(function* () { + const flags = yield* readFlags.pipe(Effect.provide(fromConfig(input.config))) + + expect(flags.bashDefaultTimeoutMs).toBe(input.expected) + }), + ) + } + it.effect("layer ignores the active ConfigProvider for omitted test overrides", () => Effect.gen(function* () { const flags = yield* readFlags.pipe( @@ -66,6 +98,7 @@ describe("RuntimeFlags", () => { OPENCODE_DISABLE_DEFAULT_PLUGINS: "true", OPENCODE_EXPERIMENTAL: "true", OPENCODE_ENABLE_EXA: "true", + OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS: "1234", OPENCODE_CLIENT: "desktop", }), ), @@ -76,6 +109,7 @@ describe("RuntimeFlags", () => { expect(flags.disableDefaultPlugins).toBe(false) expect(flags.disableClaudeCodeSkills).toBe(false) expect(flags.enableExa).toBe(false) + expect(flags.bashDefaultTimeoutMs).toBeUndefined() expect(flags.client).toBe("cli") }), ) diff --git a/packages/opencode/test/tool/shell.test.ts b/packages/opencode/test/tool/shell.test.ts index 26c04e6bee..fe4f5a4834 100644 --- a/packages/opencode/test/tool/shell.test.ts +++ b/packages/opencode/test/tool/shell.test.ts @@ -17,6 +17,7 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Plugin } from "../../src/plugin" import { testEffect } from "../lib/effect" import { Tool } from "@/tool/tool" +import { RuntimeFlags } from "@/effect/runtime-flags" const shellLayer = Layer.mergeAll( CrossSpawnSpawner.defaultLayer, @@ -25,6 +26,7 @@ const shellLayer = Layer.mergeAll( Truncate.defaultLayer, Config.defaultLayer, Agent.defaultLayer, + RuntimeFlags.defaultLayer, ) const it = testEffect(shellLayer) type ShellTestServices = @@ -1077,6 +1079,23 @@ describe("tool.shell abort", () => { 15_000, ) + it.live( + "uses RuntimeFlags bashDefaultTimeoutMs when timeout is omitted", + () => + runIn( + projectRoot, + Effect.gen(function* () { + const result = yield* run({ + command: `echo started && sleep 60`, + description: "Default timeout test", + }) + expect(result.output).toContain("started") + expect(result.output).toContain("exceeding timeout 500 ms") + }), + ).pipe(Effect.provide(RuntimeFlags.layer({ bashDefaultTimeoutMs: 500 }))), + 15_000, + ) + if (process.platform !== "win32") { it.live("captures stderr in output", () => runIn(