mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-20 06:53:27 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1af665b1c7 |
@@ -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)
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: [] })
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user