diff --git a/packages/core/src/config/plugin/shell.ts b/packages/core/src/config/plugin/shell.ts index 61e77eb0e394..c7accb4c0b49 100644 --- a/packages/core/src/config/plugin/shell.ts +++ b/packages/core/src/config/plugin/shell.ts @@ -3,6 +3,7 @@ export * as ConfigShellPlugin from "./shell.js" import { define } from "@opencode-ai/plugin/effect/plugin" import { Effect, Stream } from "effect" import { Config } from "../../config.js" +import { ShellPolicy } from "../../shell/policy.js" import { ShellSelect } from "../../shell/select.js" export const Plugin = define({ @@ -10,10 +11,11 @@ export const Plugin = define({ effect: Effect.fn(function* (ctx) { const config = yield* Config.Service const shell = yield* ShellSelect.Service + const policy = yield* ShellPolicy.Service const loaded = { entries: yield* config.entries() } const reload = config.entries().pipe( Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))), - Effect.andThen(shell.reload()), + Effect.andThen(Effect.all([shell.reload(), policy.reload()], { concurrency: "unbounded", discard: true })), ) yield* ctx.event.subscribe().pipe( Stream.filter((event) => event.type === "config.updated"), @@ -25,5 +27,9 @@ export const Plugin = define({ const configured = Config.latest(loaded.entries, "shell") if (configured) draft.configure(configured) }) + yield* policy.transform((draft) => { + const configured = Config.latest(loaded.entries, "experimental")?.portable_shell_scanner + if (configured !== undefined) draft.configure(configured) + }) }), }) diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts index 45ce6486955c..073a0d49ec32 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -29,6 +29,7 @@ import { PluginSupervisor } from "./plugin/supervisor.js" import { Worktree } from "./worktree.js" import { Pty } from "./pty.js" import { Shell } from "./shell.js" +import { ShellPolicy } from "./shell/policy.js" import { ShellSelect } from "./shell/select.js" import { Reference } from "./reference.js" import { WebSearch } from "./websearch.js" @@ -72,6 +73,7 @@ const locationServiceNodes = [ Worktree.refreshNode, FileSystemSearch.node, FileSystem.node, + ShellPolicy.node, ShellSelect.node, Pty.node, Shell.node, diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index 3205ac8c6453..72036d3dfc16 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -53,6 +53,7 @@ import { Ripgrep } from "../ripgrep.js" import { SessionCompaction } from "../session/compaction.js" import { SessionInstructions } from "../session/instructions.js" import { Shell } from "../shell.js" +import { ShellPolicy } from "../shell/policy.js" import { ShellSelect } from "../shell/select.js" import { Snapshot } from "../snapshot.js" import { Skill } from "../skill.js" @@ -124,6 +125,7 @@ const services = Effect.fn("PluginInternal.services")(function* () { const compaction = yield* SessionCompaction.Service const instructions = yield* SessionInstructions.Service const shell = yield* Shell.Service + const shellPolicy = yield* ShellPolicy.Service const shellSelect = yield* ShellSelect.Service const snapshot = yield* Snapshot.Service const skill = yield* Skill.Service @@ -168,6 +170,7 @@ const services = Effect.fn("PluginInternal.services")(function* () { Context.make(SessionCompaction.Service, compaction), Context.make(SessionInstructions.Service, instructions), Context.make(Shell.Service, shell), + Context.make(ShellPolicy.Service, shellPolicy), Context.make(ShellSelect.Service, shellSelect), Context.make(Snapshot.Service, snapshot), Context.make(Skill.Service, skill), @@ -219,6 +222,7 @@ export const requirements = LayerNode.group([ SessionCompaction.node, SessionInstructions.node, Shell.node, + ShellPolicy.node, ShellSelect.node, Snapshot.node, Skill.node, diff --git a/packages/core/src/shell/policy.ts b/packages/core/src/shell/policy.ts new file mode 100644 index 000000000000..2969d0cdfb79 --- /dev/null +++ b/packages/core/src/shell/policy.ts @@ -0,0 +1,38 @@ +export * as ShellPolicy from "./policy.js" + +import { makeLocationNode } from "@opencode-ai/util/effect/app-node" +import { Context, Layer } from "effect" +import { State } from "../state.js" + +type Data = { + portableScanner: boolean +} + +export type Draft = { + configure: (portableScanner: boolean) => void +} + +export interface Interface extends State.Transformable { + readonly portableScanner: () => boolean +} + +export class Service extends Context.Service()("@opencode/ShellPolicy") {} + +const layer = Layer.sync(Service, () => { + const state = State.create({ + name: "shell-policy", + initial: () => ({ portableScanner: false }), + draft: (draft) => ({ + configure: (portableScanner) => { + draft.portableScanner = portableScanner + }, + }), + }) + return Service.of({ + transform: state.transform, + reload: state.reload, + portableScanner: () => state.get().portableScanner, + }) +}) + +export const node = makeLocationNode({ service: Service, layer, deps: [] }) diff --git a/packages/core/src/tool-output.ts b/packages/core/src/tool-output.ts index 17fe252dfffe..c6629f527a8f 100644 --- a/packages/core/src/tool-output.ts +++ b/packages/core/src/tool-output.ts @@ -16,7 +16,7 @@ export const DIRECTORY = "tool-output" type Result = Tool.Result -type Limits = { +export type Limits = { maxLines: number maxBytes: number } @@ -26,6 +26,7 @@ export type Draft = { } export interface Interface extends State.Transformable { + readonly limits: () => Readonly readonly truncate: (result: Result) => Effect.Effect readonly cleanup: () => Effect.Effect } @@ -132,6 +133,7 @@ const layer = Layer.effect( return Service.of({ transform: state.transform, reload: state.reload, + limits: () => ({ ...state.get() }), truncate, cleanup: () => cleanup(fs, directory), }) diff --git a/packages/core/src/tool/plugin/shell.ts b/packages/core/src/tool/plugin/shell.ts index 67c967c19ed2..84a445f1c28f 100644 --- a/packages/core/src/tool/plugin/shell.ts +++ b/packages/core/src/tool/plugin/shell.ts @@ -5,7 +5,6 @@ import { ToolFailure } from "@opencode-ai/ai" import type { Content } from "@opencode-ai/schema/tool" import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin" import { Deferred, Effect, Schema, Scope } from "effect" -import { Config } from "../../config.js" import { Environment } from "../../environment/index.js" import { LocationMutation } from "../../location-mutation.js" import { Permission } from "../../permission.js" @@ -14,6 +13,7 @@ import { NonNegativeInt } from "../../schema.js" import { SessionSchema } from "../../session/schema.js" import { Shell } from "../../shell.js" import { ShellParse } from "../../shell/parse.js" +import { ShellPolicy } from "../../shell/policy.js" import { ToolOutput } from "../../tool-output.js" export const name = "shell" @@ -86,8 +86,9 @@ export const Plugin = { const environment = yield* Environment.Service const mutation = yield* LocationMutation.Service const shell = yield* Shell.Service + const shellPolicy = yield* ShellPolicy.Service const permission = yield* Permission.Service - const config = yield* Config.Service + const toolOutput = yield* ToolOutput.Service const notifyWhenDone = Effect.fn("ShellTool.notifyWhenDone")(function* ( sessionID: SessionSchema.ID, @@ -163,10 +164,8 @@ export const Plugin = { invocation.cwd = target.absolute finalTimeout = invocation.timeout if (!unrestricted) { - const portable = - Config.latest(yield* config.entries(), "experimental")?.portable_shell_scanner === true const parsed = yield* ShellParse.scan(invocation.command, invocation.shell, target.absolute, { - portable, + portable: shellPolicy.portableScanner(), }) const directories = yield* Effect.forEach(parsed.directories, (directory) => mutation.resolve({ path: path.resolve(target.absolute, directory), kind: "directory" }), @@ -209,18 +208,16 @@ export const Plugin = { yield* context.progress({ shellID: info.id }) const captureShell = Effect.fnUntraced(function* () { - const configured = Config.latest(yield* config.entries(), "tool_output") - const maxLines = configured?.max_lines ?? ToolOutput.MAX_LINES - const maxBytes = configured?.max_bytes ?? ToolOutput.MAX_BYTES + const limits = toolOutput.limits() const latest = yield* shell.output(info.id, { cursor: Number.MAX_SAFE_INTEGER }) const page = yield* shell.output(info.id, { - cursor: Math.max(0, latest.size - maxBytes), - limit: maxBytes, + cursor: Math.max(0, latest.size - limits.maxBytes), + limit: limits.maxBytes, }) const lines = page.output.split("\n") if (page.output.endsWith("\n")) lines.pop() - const truncated = latest.size > maxBytes || lines.length > maxLines - const output = lines.length > maxLines ? lines.slice(-maxLines).join("\n") : page.output + const truncated = latest.size > limits.maxBytes || lines.length > limits.maxLines + const output = lines.length > limits.maxLines ? lines.slice(-limits.maxLines).join("\n") : page.output const notice = truncated ? `\n\n[output truncated; full output saved to: ${info.file}]` : "" return { output: `${output || "(no output)"}${notice}`, diff --git a/packages/core/test/config/shell.test.ts b/packages/core/test/config/shell.test.ts index 3eadf96b9992..dd0cc9961937 100644 --- a/packages/core/test/config/shell.test.ts +++ b/packages/core/test/config/shell.test.ts @@ -3,21 +3,27 @@ import { Bus } from "@opencode-ai/core/bus" import { Config } from "@opencode-ai/core/config" import { ConfigShellPlugin } from "@opencode-ai/core/config/plugin/shell" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { Plugin } from "@opencode-ai/core/plugin" import { PluginHost } from "@opencode-ai/core/plugin/host" +import { ShellPolicy } from "@opencode-ai/core/shell/policy" import { ShellSelect } from "@opencode-ai/core/shell/select" import { Document, Event, Info } from "@opencode-ai/schema/config" +import { ConfigExperimental } from "@opencode-ai/schema/config/experimental" import { FSUtil } from "@opencode-ai/util/fs-util" import { Effect, Layer } from "effect" import { testEffect } from "../lib/effect" import { PluginTestLayer } from "../plugin/fixture" -const it = testEffect(Layer.merge(PluginTestLayer, AppNodeBuilder.build(ShellSelect.node))) +const it = testEffect( + Layer.merge(PluginTestLayer, AppNodeBuilder.build(LayerNode.group([ShellSelect.node, ShellPolicy.node]))), +) describe("ConfigShellPlugin.Plugin", () => { - it.live("applies the preferred shell and reloads changed config", () => + it.live("applies shell policy and reloads changed config", () => Effect.gen(function* () { const shell = yield* ShellSelect.Service + const policy = yield* ShellPolicy.Service const bus = yield* Bus.Service const config = yield* Config.Test const plugins = yield* Plugin.Service @@ -25,17 +31,26 @@ describe("ConfigShellPlugin.Plugin", () => { const configured = process.platform === "win32" ? FSUtil.windowsPath(process.execPath) : process.execPath expect(yield* shell.preferred()).toBe(configured) + expect(policy.portableScanner()).toBe(true) yield* config.setEntries([]) yield* bus.publish(Event.Updated, {}) for (let attempt = 0; attempt < 200; attempt++) { - if ((yield* shell.preferred()) !== configured) return + if ((yield* shell.preferred()) !== configured && !policy.portableScanner()) return yield* Effect.sleep("10 millis") } yield* Effect.die(new Error("Timed out waiting for shell config reload")) }).pipe( Effect.provide( - Config.testLayer([new Document({ type: "document", info: new Info({ shell: process.execPath }) })]), + Config.testLayer([ + new Document({ + type: "document", + info: new Info({ + shell: process.execPath, + experimental: new ConfigExperimental.Info({ portable_shell_scanner: true }), + }), + }), + ]), ), ), ) diff --git a/packages/core/test/config/tool-output.test.ts b/packages/core/test/config/tool-output.test.ts index b6920a97766b..1293b73bdf3b 100644 --- a/packages/core/test/config/tool-output.test.ts +++ b/packages/core/test/config/tool-output.test.ts @@ -26,6 +26,7 @@ describe("ConfigToolOutputPlugin.Plugin", () => { const plugins = yield* Plugin.Service yield* ConfigToolOutputPlugin.Plugin.effect(yield* PluginHost.make(plugins)) + expect(output.limits()).toEqual({ maxLines: 1, maxBytes: ToolOutput.MAX_BYTES }) expect((yield* output.truncate({ content: "one\ntwo" })).metadata?.truncated).toBe(true) yield* config.setEntries([ @@ -39,7 +40,10 @@ describe("ConfigToolOutputPlugin.Plugin", () => { yield* bus.publish(Event.Updated, {}) for (let attempt = 0; attempt < 200; attempt++) { const result = yield* output.truncate({ content: "one\ntwo" }) - if (result.metadata?.truncated === false) return + if (result.metadata?.truncated === false) { + expect(output.limits()).toEqual({ maxLines: 2, maxBytes: 1_000 }) + return + } yield* Effect.sleep("10 millis") } yield* Effect.die(new Error("Timed out waiting for tool output config reload")) diff --git a/packages/core/test/tool-shell.test.ts b/packages/core/test/tool-shell.test.ts index aa6134fd688d..264100658b3e 100644 --- a/packages/core/test/tool-shell.test.ts +++ b/packages/core/test/tool-shell.test.ts @@ -32,6 +32,7 @@ import { Permission } from "@opencode-ai/core/permission" import { PluginRuntime } from "@opencode-ai/core/plugin/runtime" import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor" import { Shell } from "@opencode-ai/core/shell" +import { ShellPolicy } from "@opencode-ai/core/shell/policy" import { Shell as ShellSchema } from "@opencode-ai/schema/shell" import { ShellTool } from "@opencode-ai/core/tool/plugin/shell" import { ToolOutput } from "@opencode-ai/core/tool-output" @@ -131,13 +132,14 @@ const shellPluginSupervisor = makeLocationNode({ registerToolPlugin(ShellTool.Plugin).pipe(Effect.as(PluginSupervisor.Service.of({ flush: Effect.void }))), ), deps: [ - Config.node, Environment.node, LocationMutation.node, Permission.node, PluginRuntime.node, Shell.node, + ShellPolicy.node, Tool.node, + ToolOutput.node, ], }) @@ -543,7 +545,7 @@ describe("ShellTool", () => { { timeout: 15_000 }, ) - it.live("does not add external-directory permission for an experimental portable heredoc", () => + productionIt.live("does not add external-directory permission for an experimental portable heredoc", () => Effect.acquireUseRelease( Effect.promise(() => tmpdir()), (tmp) => @@ -624,7 +626,7 @@ describe("ShellTool", () => { { timeout: 15_000 }, ) - it.live("uses configured line limits", () => + productionIt.live("uses configured line limits", () => Effect.acquireUseRelease( Effect.promise(() => tmpdir()), (tmp) => {