Compare commits

...

1 Commits

Author SHA1 Message Date
Shoubhit Dash 1af665b1c7 refactor(core): move shell tool config into state 2026-08-19 21:38:20 +05:30
9 changed files with 92 additions and 22 deletions
+7 -1
View File
@@ -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)
})
}),
})
+2
View File
@@ -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,
+4
View File
@@ -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,
+38
View File
@@ -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<Draft> {
readonly portableScanner: () => boolean
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ShellPolicy") {}
const layer = Layer.sync(Service, () => {
const state = State.create<Data, Draft>({
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: [] })
+3 -1
View File
@@ -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<Draft> {
readonly limits: () => Readonly<Limits>
readonly truncate: (result: Result) => Effect.Effect<Result>
readonly cleanup: () => Effect.Effect<void>
}
@@ -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),
})
+9 -12
View File
@@ -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}`,
+19 -4
View File
@@ -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 }),
}),
}),
]),
),
),
)
@@ -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"))
+5 -3
View File
@@ -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) => {