Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion packages/core/src/config/plugin/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,19 @@ 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({
id: "opencode.config.shell",
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"),
Expand All @@ -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 changes: 2 additions & 0 deletions packages/core/src/location-services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -72,6 +73,7 @@ const locationServiceNodes = [
Worktree.refreshNode,
FileSystemSearch.node,
FileSystem.node,
ShellPolicy.node,
ShellSelect.node,
Pty.node,
Shell.node,
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/plugin/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -219,6 +222,7 @@ export const requirements = LayerNode.group([
SessionCompaction.node,
SessionInstructions.node,
Shell.node,
ShellPolicy.node,
ShellSelect.node,
Snapshot.node,
Skill.node,
Expand Down
38 changes: 38 additions & 0 deletions packages/core/src/shell/policy.ts
Original file line number Diff line number Diff line change
@@ -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: [] })
4 changes: 3 additions & 1 deletion packages/core/src/tool-output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export const DIRECTORY = "tool-output"

type Result = Tool.Result

type Limits = {
export type Limits = {
maxLines: number
maxBytes: number
}
Expand All @@ -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>
}
Expand Down Expand Up @@ -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),
})
Expand Down
21 changes: 9 additions & 12 deletions packages/core/src/tool/plugin/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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" }),
Expand Down Expand Up @@ -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}`,
Expand Down
23 changes: 19 additions & 4 deletions packages/core/test/config/shell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,39 +3,54 @@ 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
yield* ConfigShellPlugin.Plugin.effect(yield* PluginHost.make(plugins))

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 }),
}),
}),
]),
),
),
)
Expand Down
6 changes: 5 additions & 1 deletion packages/core/test/config/tool-output.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand All @@ -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"))
Expand Down
8 changes: 5 additions & 3 deletions packages/core/test/tool-shell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
],
})

Expand Down Expand Up @@ -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) =>
Expand Down Expand Up @@ -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) => {
Expand Down
Loading