mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-27 22:10:11 -04:00
1550 lines
64 KiB
TypeScript
1550 lines
64 KiB
TypeScript
import fs from "fs/promises"
|
|
import { realpathSync } from "node:fs"
|
|
import os from "os"
|
|
import path from "path"
|
|
import { describe, expect } from "bun:test"
|
|
import { Cause, Deferred, Duration, Effect, Exit, Fiber, Layer, Queue, Scope, Stream } from "effect"
|
|
import { Money } from "@opencode-ai/schema/money"
|
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
|
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
|
import { makeGlobalNode, makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
|
import { filesystem } from "@opencode-ai/util/effect/app-node-platform"
|
|
import { Database } from "@opencode-ai/core/database/database"
|
|
import { Bus } from "@opencode-ai/core/bus"
|
|
import { Config } from "@opencode-ai/core/config"
|
|
import { Environment } from "@opencode-ai/core/environment/index"
|
|
import { FSUtil } from "@opencode-ai/util/fs-util"
|
|
import { Global } from "@opencode-ai/util/global"
|
|
import { Location } from "@opencode-ai/core/location"
|
|
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
|
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
|
import { Model } from "@opencode-ai/core/model"
|
|
import { Provider } from "@opencode-ai/core/provider"
|
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
|
import { Agent } from "@opencode-ai/core/agent"
|
|
import { Job } from "@opencode-ai/core/job"
|
|
import { Session } from "@opencode-ai/core/session"
|
|
import { SessionEvent } from "@opencode-ai/core/session/event"
|
|
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
|
import { SessionMessage } from "@opencode-ai/core/session/message"
|
|
import { SessionStore } from "@opencode-ai/core/session/store"
|
|
import { Permission } from "@opencode-ai/core/permission"
|
|
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
|
|
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
|
|
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
|
import { Shell } from "@opencode-ai/core/shell"
|
|
import { ShellSelect } from "@opencode-ai/core/shell/select"
|
|
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"
|
|
import { Tool } from "@opencode-ai/core/tool"
|
|
import { tmpdir } from "./fixture/tmpdir"
|
|
import { tempGlobalLayer } from "./fixture/global"
|
|
import { testEffect } from "./lib/effect"
|
|
import { permissionLayer } from "./lib/permission"
|
|
import { Expected } from "./lib/session-message"
|
|
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
|
|
|
|
const sessionID = Session.ID.make("ses_shell_tool_test")
|
|
const sessionModel = Model.Ref.make({ id: Model.ID.make("test"), providerID: Provider.ID.make("test") })
|
|
const assertions: Permission.AssertInput[] = []
|
|
let denyAction: string | undefined
|
|
let afterPermission = (_input: Permission.AssertInput): Effect.Effect<void> => Effect.void
|
|
|
|
const permission = permissionLayer({
|
|
assert: (input) =>
|
|
Effect.sync(() => assertions.push(input)).pipe(
|
|
Effect.andThen(Effect.suspend(() => afterPermission(input))),
|
|
Effect.andThen(
|
|
input.action === denyAction
|
|
? Effect.fail(
|
|
new Permission.BlockedError({
|
|
rules: [],
|
|
permission: input.action,
|
|
resources: input.resources,
|
|
}),
|
|
)
|
|
: Effect.void,
|
|
),
|
|
),
|
|
})
|
|
|
|
const reset = () => {
|
|
assertions.length = 0
|
|
denyAction = undefined
|
|
afterPermission = () => Effect.void
|
|
}
|
|
|
|
const executionNode = makeGlobalNode({
|
|
service: SessionExecution.Service,
|
|
layer: Layer.effect(
|
|
SessionExecution.Service,
|
|
Effect.gen(function* () {
|
|
const bus = yield* Bus.Service
|
|
const store = yield* SessionStore.Service
|
|
const complete = Effect.fn("ShellTest.complete")(function* (id: Session.ID) {
|
|
const session = yield* store.get(id)
|
|
if (!session) return
|
|
const assistantMessageID = SessionMessage.ID.create()
|
|
yield* bus.publish(SessionEvent.Step.Started, {
|
|
sessionID: id,
|
|
assistantMessageID,
|
|
agent: session.agent ?? Agent.ID.make("code"),
|
|
model: sessionModel,
|
|
})
|
|
yield* bus.publish(SessionEvent.Text.Started, {
|
|
sessionID: id,
|
|
assistantMessageID,
|
|
ordinal: 0,
|
|
})
|
|
yield* bus.publish(SessionEvent.Text.Ended, {
|
|
sessionID: id,
|
|
assistantMessageID,
|
|
ordinal: 0,
|
|
text: "ok",
|
|
})
|
|
yield* bus.publish(SessionEvent.Step.Ended, {
|
|
sessionID: id,
|
|
assistantMessageID,
|
|
finish: "stop",
|
|
cost: Money.USD.zero,
|
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
})
|
|
})
|
|
return SessionExecution.Service.of({
|
|
active: Effect.succeed(new Set()),
|
|
resume: complete,
|
|
wake: () => Effect.void,
|
|
interrupt: () => Effect.succeed(false),
|
|
awaitIdle: (id) => complete(id).pipe(Effect.exit, Effect.asVoid),
|
|
})
|
|
}),
|
|
),
|
|
deps: [Bus.node, SessionStore.node],
|
|
})
|
|
|
|
const shellPluginSupervisor = makeLocationNode({
|
|
service: PluginSupervisor.Service,
|
|
layer: Layer.effect(
|
|
PluginSupervisor.Service,
|
|
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,
|
|
ShellSelect.node,
|
|
Tool.node,
|
|
],
|
|
})
|
|
|
|
const nodes = LayerNode.group([
|
|
Database.node,
|
|
Bus.node,
|
|
Job.node,
|
|
Session.node,
|
|
SessionExecution.node,
|
|
PluginRuntime.providerNode,
|
|
LocationServiceMap.node,
|
|
filesystem,
|
|
FSUtil.node,
|
|
Global.node,
|
|
])
|
|
const replacements = [
|
|
[SessionExecution.node, executionNode],
|
|
[Permission.node, permission],
|
|
[Global.node, tempGlobalLayer],
|
|
] satisfies LayerNode.Replacements
|
|
const productionIt = testEffect(AppNodeBuilder.build(nodes, replacements))
|
|
const it = testEffect(AppNodeBuilder.build(nodes, [...replacements, [PluginSupervisor.node, shellPluginSupervisor]]))
|
|
const permissionIt = testEffect(
|
|
AppNodeBuilder.build(LayerNode.group([nodes, PermissionSaved.node]), [
|
|
[SessionExecution.node, executionNode],
|
|
[Global.node, tempGlobalLayer],
|
|
[PluginSupervisor.node, shellPluginSupervisor],
|
|
]),
|
|
)
|
|
|
|
const call = (input: typeof ShellTool.Input.Type, id = "call-shell") => ({
|
|
sessionID,
|
|
...toolIdentity,
|
|
call: { type: "tool-call" as const, id, name: "shell", input },
|
|
})
|
|
|
|
const isWindows = process.platform === "win32"
|
|
const cwdCommand = isWindows ? "(Get-Location).Path; Start-Sleep -Milliseconds 100" : "pwd"
|
|
const helloCommand = isWindows ? "[Console]::Out.Write('hello'); Start-Sleep -Milliseconds 100" : "printf hello"
|
|
const stderrCommand = isWindows
|
|
? "[Console]::Error.Write('stderr only'); Start-Sleep -Milliseconds 100"
|
|
: "printf 'stderr only' >&2"
|
|
const mixedOutputCommand = isWindows
|
|
? "[Console]::Out.Write('stdout'); Start-Sleep -Milliseconds 50; [Console]::Error.Write('stderr'); Start-Sleep -Milliseconds 100"
|
|
: "printf stdout; sleep 0.05; printf stderr >&2"
|
|
const idleCommand = isWindows ? "Start-Sleep -Seconds 60" : "sleep 60"
|
|
const timeoutOutputCommand = isWindows
|
|
? "[Console]::Out.Write('before timeout'); Start-Sleep -Seconds 60"
|
|
: "printf 'before timeout'; sleep 60"
|
|
const bodyExitCommand = isWindows
|
|
? "[Console]::Out.Write('body'); Start-Sleep -Milliseconds 100; exit 7"
|
|
: "printf body && exit 7"
|
|
const overflowCommand = (bytes: number) =>
|
|
isWindows
|
|
? `[Console]::Out.Write('output-start' + ('x' * ${bytes}) + 'output-end'); Start-Sleep -Milliseconds 100`
|
|
: `printf output-start; head -c ${bytes} /dev/zero | tr '\\0' 'x'; printf output-end`
|
|
const lineOverflowCommand = isWindows
|
|
? "[Console]::Out.Write('one' + [Environment]::NewLine + 'two' + [Environment]::NewLine + 'three')"
|
|
: "printf 'one\\ntwo\\nthree'"
|
|
const progressOverflowCommand = (bytes: number, release: string) =>
|
|
isWindows
|
|
? `[Console]::Out.Write(('x' * ${bytes})); while (!(Test-Path -LiteralPath '${release}')) { Start-Sleep -Milliseconds 50 }`
|
|
: `head -c ${bytes} /dev/zero | tr '\\0' 'x'; while [ ! -e '${release}' ]; do sleep 0.05; done`
|
|
|
|
const withSession = <A, E, R>(directory: string, body: (registry: Tool.Interface) => Effect.Effect<A, E, R>) =>
|
|
Effect.gen(function* () {
|
|
const sessions = yield* Session.Service
|
|
const location = Location.Ref.make({ directory: AbsolutePath.make(directory) })
|
|
yield* sessions.create({
|
|
id: sessionID,
|
|
title: "shell test",
|
|
location,
|
|
model: sessionModel,
|
|
})
|
|
const locations = yield* LocationServiceMap.Service
|
|
const locationLayer = locations.get(location)
|
|
return yield* Effect.gen(function* () {
|
|
yield* (yield* PluginSupervisor.Service).flush
|
|
const registry = yield* Tool.Service
|
|
return yield* body(registry)
|
|
}).pipe(Effect.provide(locationLayer), Effect.ensuring(locations.invalidate(location)))
|
|
})
|
|
|
|
const withScanner = <A, E, R>(
|
|
portable: boolean,
|
|
body: (registry: Tool.Interface, fixture: { active: string; outside: string }) => Effect.Effect<A, E, R>,
|
|
shell = "sh",
|
|
) =>
|
|
Effect.acquireUseRelease(
|
|
Effect.promise(() => tmpdir()),
|
|
(tmp) =>
|
|
Effect.gen(function* () {
|
|
const fixture = { active: path.join(tmp.path, "active"), outside: path.join(tmp.path, "outside") }
|
|
yield* Effect.promise(() => Promise.all([fs.mkdir(fixture.active), fs.mkdir(fixture.outside)]))
|
|
yield* Effect.promise(() =>
|
|
Bun.write(
|
|
path.join(fixture.active, "opencode.json"),
|
|
JSON.stringify({ experimental: { portable_shell_scanner: portable } }),
|
|
),
|
|
)
|
|
return yield* withSession(fixture.active, (registry) =>
|
|
Effect.gen(function* () {
|
|
const selection = yield* ShellSelect.Service
|
|
yield* selection.transform((draft) => draft.configure(shell))
|
|
const agents = yield* Agent.Service
|
|
yield* agents.transform((draft) =>
|
|
draft.update(toolIdentity.agent, (agent) => {
|
|
agent.permissions = []
|
|
}),
|
|
)
|
|
return yield* body(registry, fixture)
|
|
}),
|
|
)
|
|
}),
|
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
|
)
|
|
|
|
const runPermissionCommand = (
|
|
registry: Tool.Interface,
|
|
command: string,
|
|
marker: string,
|
|
replies: ReadonlyArray<Permission.Reply>,
|
|
) =>
|
|
Effect.gen(function* () {
|
|
const permission = yield* Permission.Service
|
|
const bus = yield* Bus.Service
|
|
const queue = yield* Queue.unbounded<Permission.Request>()
|
|
yield* bus.subscribe(Permission.Event.Asked).pipe(
|
|
Stream.runForEach((event) => Queue.offer(queue, event.data)),
|
|
Effect.forkScoped({ startImmediately: true }),
|
|
)
|
|
const execution = yield* executeTool(registry, call({ command }, `call-${Permission.ID.create()}`)).pipe(
|
|
Effect.forkScoped,
|
|
)
|
|
const requests = yield* Effect.forEach(replies, (reply) =>
|
|
Effect.gen(function* () {
|
|
const request = yield* Queue.take(queue)
|
|
expect(yield* permission.forSession(sessionID)).toEqual([request])
|
|
expect(yield* Effect.promise(() => Bun.file(marker).exists())).toBe(false)
|
|
yield* permission.reply({ requestID: request.id, reply })
|
|
return request
|
|
}),
|
|
)
|
|
const exit = yield* Fiber.await(execution)
|
|
expect(yield* permission.list()).toEqual([])
|
|
expect(yield* Queue.size(queue)).toBe(0)
|
|
return { exit, requests }
|
|
}).pipe(Effect.scoped, Effect.timeout(Duration.seconds(5)))
|
|
|
|
// Directory cases still document inherited limitations; fixed scanner cases require matching behavior.
|
|
describe("ShellTool scanner permissions", () => {
|
|
const test = isWindows || !Bun.which("sh") ? permissionIt.live.skip : permissionIt.live
|
|
for (const portable of [false, true]) {
|
|
const scanner = portable ? "native" : "legacy"
|
|
|
|
test(`${scanner}: declarations reuse approvals while substitutions retain reject/once/always behavior`, () =>
|
|
withScanner(portable, (registry, fixture) =>
|
|
Effect.gen(function* () {
|
|
const saved = yield* PermissionSaved.Service
|
|
const location = yield* Location.Service
|
|
yield* saved.add({ projectID: location.project.id, action: "shell", resources: ["printf *"] })
|
|
const marker = path.join(fixture.active, "marker")
|
|
const approved = yield* runPermissionCommand(
|
|
registry,
|
|
"export SCAN_TEST=hello; unset SCAN_TEST; printf hello > marker",
|
|
marker,
|
|
[],
|
|
)
|
|
expect(approved.requests).toEqual([])
|
|
expect(approved.exit).toMatchObject({
|
|
_tag: "Success",
|
|
value: { status: "completed", metadata: { exit: 0 } },
|
|
})
|
|
expect(yield* Effect.promise(() => Bun.file(marker).text())).toBe("hello")
|
|
expect((yield* saved.list()).map((item) => item.resource)).toEqual(["printf *"])
|
|
|
|
yield* Effect.forEach(yield* saved.list(), (item) => saved.remove(item.id))
|
|
const command = 'export SCAN_TEST=$(printf hello); printf %s "$SCAN_TEST" > marker'
|
|
const prompts: Permission.Request[] = []
|
|
for (const reply of ["reject", "once", "always", undefined] as const) {
|
|
yield* Effect.promise(() => fs.rm(marker, { force: true }))
|
|
const result = yield* runPermissionCommand(registry, command, marker, reply ? [reply] : [])
|
|
prompts.push(...result.requests)
|
|
if (reply === "reject") {
|
|
expect(Exit.isFailure(result.exit)).toBe(true)
|
|
if (Exit.isFailure(result.exit))
|
|
expect(
|
|
result.exit.cause.reasons.some(
|
|
(reason) => Cause.isDieReason(reason) && reason.defect instanceof Permission.DeclinedError,
|
|
),
|
|
).toBe(true)
|
|
expect(yield* Effect.promise(() => Bun.file(marker).exists())).toBe(false)
|
|
continue
|
|
}
|
|
expect(result.exit).toMatchObject({
|
|
_tag: "Success",
|
|
value: { status: "completed", metadata: { exit: 0 } },
|
|
})
|
|
expect(yield* Effect.promise(() => Bun.file(marker).text())).toBe("hello")
|
|
if (reply === "once") expect(yield* saved.list()).toEqual([])
|
|
}
|
|
expect(prompts).toHaveLength(3)
|
|
for (const request of prompts) {
|
|
expect(request).toMatchObject({
|
|
action: "shell",
|
|
resources: ["printf hello", 'printf %s "$SCAN_TEST" > marker'],
|
|
save: ["printf *", "printf *"],
|
|
})
|
|
}
|
|
expect((yield* saved.list()).map((item) => item.resource)).toEqual(["printf *"])
|
|
|
|
const agents = yield* Agent.Service
|
|
yield* agents.transform((draft) =>
|
|
draft.update(toolIdentity.agent, (agent) => {
|
|
agent.permissions = [{ action: "shell", resource: "printf hello", effect: "deny" }]
|
|
}),
|
|
)
|
|
yield* Effect.promise(() => fs.rm(marker))
|
|
const denied = yield* runPermissionCommand(registry, command, marker, [])
|
|
expect(denied.exit).toMatchObject({
|
|
_tag: "Success",
|
|
value: { status: "error", error: { message: expect.stringContaining("Permission denied: shell") } },
|
|
})
|
|
expect(yield* Effect.promise(() => Bun.file(marker).exists())).toBe(false)
|
|
}),
|
|
))
|
|
|
|
test(`${scanner}: pipeline redirect preserves exact approval and denial despite broad saved approval`, () =>
|
|
withScanner(portable, (registry, fixture) =>
|
|
Effect.gen(function* () {
|
|
const saved = yield* PermissionSaved.Service
|
|
const location = yield* Location.Service
|
|
yield* saved.add({ projectID: location.project.id, action: "shell", resources: ["printf hello", "cat"] })
|
|
const marker = path.join(fixture.active, "marker")
|
|
const command = "printf hello | cat > marker"
|
|
const exact = yield* runPermissionCommand(registry, command, marker, [])
|
|
expect(exact.requests).toEqual([])
|
|
expect(exact.exit).toMatchObject({ _tag: "Success", value: { status: "completed", metadata: { exit: 0 } } })
|
|
expect(yield* Effect.promise(() => Bun.file(marker).text())).toBe("hello")
|
|
|
|
yield* saved.add({ projectID: location.project.id, action: "shell", resources: ["printf *", "cat *"] })
|
|
yield* Effect.promise(() => fs.rm(marker))
|
|
const broad = yield* runPermissionCommand(registry, command, marker, [])
|
|
expect(broad.requests).toEqual([])
|
|
expect(broad.exit).toMatchObject({ _tag: "Success", value: { status: "completed", metadata: { exit: 0 } } })
|
|
expect(yield* Effect.promise(() => Bun.file(marker).text())).toBe("hello")
|
|
|
|
const agents = yield* Agent.Service
|
|
yield* agents.transform((draft) =>
|
|
draft.update(toolIdentity.agent, (agent) => {
|
|
agent.permissions = [{ action: "shell", resource: "cat", effect: "deny" }]
|
|
}),
|
|
)
|
|
yield* Effect.promise(() => fs.rm(marker))
|
|
const denied = yield* runPermissionCommand(registry, command, marker, [])
|
|
expect(denied.requests).toEqual([])
|
|
expect(denied.exit).toMatchObject({
|
|
_tag: "Success",
|
|
value: { status: "error", error: { message: expect.stringContaining("Permission denied: shell") } },
|
|
})
|
|
expect(yield* Effect.promise(() => Bun.file(marker).exists())).toBe(false)
|
|
}),
|
|
))
|
|
|
|
test(`${scanner}: external-directory rejection stops execution before a workspace marker is written`, () =>
|
|
withScanner(portable, (registry, fixture) =>
|
|
Effect.gen(function* () {
|
|
const agents = yield* Agent.Service
|
|
yield* agents.transform((draft) =>
|
|
draft.update(toolIdentity.agent, (agent) => {
|
|
agent.permissions = [{ action: "shell", resource: "*", effect: "allow" }]
|
|
}),
|
|
)
|
|
const marker = path.join(fixture.active, "marker")
|
|
const command = `cd '${fixture.outside}' && pwd -P && printf reached > '${marker}'`
|
|
for (const reply of ["reject", "once"] as const) {
|
|
const result = yield* runPermissionCommand(registry, command, marker, [reply])
|
|
expect(result.requests).toMatchObject([
|
|
{ action: "external_directory", resources: [path.join(fixture.outside, "*")] },
|
|
])
|
|
if (reply === "reject") {
|
|
expect(Exit.isFailure(result.exit)).toBe(true)
|
|
if (Exit.isFailure(result.exit))
|
|
expect(
|
|
result.exit.cause.reasons.some(
|
|
(reason) => Cause.isDieReason(reason) && reason.defect instanceof Permission.DeclinedError,
|
|
),
|
|
).toBe(true)
|
|
expect(yield* Effect.promise(() => Bun.file(marker).exists())).toBe(false)
|
|
continue
|
|
}
|
|
expect(result.exit).toMatchObject({
|
|
_tag: "Success",
|
|
value: {
|
|
status: "completed",
|
|
metadata: { exit: 0 },
|
|
content: [{ type: "text", text: `${fixture.outside}\n` }, { type: "text" }],
|
|
},
|
|
})
|
|
expect(yield* Effect.promise(() => Bun.file(marker).text())).toBe("reached")
|
|
}
|
|
}),
|
|
))
|
|
|
|
test(`${scanner}: a numeric symlink operand still reaches outside without an external-directory prompt`, () =>
|
|
withScanner(portable, (registry, fixture) =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.promise(() => fs.symlink(fixture.outside, path.join(fixture.active, "123")))
|
|
const agents = yield* Agent.Service
|
|
yield* agents.transform((draft) =>
|
|
draft.update(toolIdentity.agent, (agent) => {
|
|
agent.permissions = [
|
|
{ action: "shell", resource: "*", effect: "allow" },
|
|
{ action: "external_directory", resource: "*", effect: "deny" },
|
|
]
|
|
}),
|
|
)
|
|
const marker = path.join(fixture.active, "marker")
|
|
const result = yield* runPermissionCommand(
|
|
registry,
|
|
`cd 123 && pwd -P && printf reached > '${marker}'`,
|
|
marker,
|
|
[],
|
|
)
|
|
expect(result.requests).toEqual([])
|
|
expect(result.exit).toMatchObject({
|
|
_tag: "Success",
|
|
value: {
|
|
status: "completed",
|
|
metadata: { exit: 0 },
|
|
content: [{ type: "text", text: `${fixture.outside}\n` }, { type: "text" }],
|
|
},
|
|
})
|
|
expect(yield* Effect.promise(() => Bun.file(marker).text())).toBe("reached")
|
|
}),
|
|
))
|
|
|
|
test(`${scanner}: a continued directory operand asks for the wrong path and misses the destination deny`, () =>
|
|
withScanner(portable, (registry, fixture) =>
|
|
Effect.gen(function* () {
|
|
const agents = yield* Agent.Service
|
|
yield* agents.transform((draft) =>
|
|
draft.update(toolIdentity.agent, (agent) => {
|
|
agent.permissions = [
|
|
{ action: "shell", resource: "*", effect: "allow" },
|
|
{ action: "external_directory", resource: path.join(fixture.outside, "*"), effect: "deny" },
|
|
]
|
|
}),
|
|
)
|
|
const marker = path.join(fixture.active, "marker")
|
|
const command = `cd ../out\\\nside && pwd -P && printf reached > '${marker}'`
|
|
for (const reply of ["reject", "once"] as const) {
|
|
const result = yield* runPermissionCommand(registry, command, marker, [reply])
|
|
expect(result.requests).toMatchObject([
|
|
{
|
|
action: "external_directory",
|
|
resources: [
|
|
path.join(fixture.active, "..", portable ? "out\\\nside" : "out", "*").replaceAll("\\", "/"),
|
|
],
|
|
},
|
|
])
|
|
if (reply === "reject") {
|
|
expect(Exit.isFailure(result.exit)).toBe(true)
|
|
if (Exit.isFailure(result.exit))
|
|
expect(
|
|
result.exit.cause.reasons.some(
|
|
(reason) => Cause.isDieReason(reason) && reason.defect instanceof Permission.DeclinedError,
|
|
),
|
|
).toBe(true)
|
|
expect(yield* Effect.promise(() => Bun.file(marker).exists())).toBe(false)
|
|
continue
|
|
}
|
|
expect(result.exit).toMatchObject({
|
|
_tag: "Success",
|
|
value: {
|
|
status: "completed",
|
|
metadata: { exit: 0 },
|
|
content: [{ type: "text", text: `${fixture.outside}\n` }, { type: "text" }],
|
|
},
|
|
})
|
|
expect(yield* Effect.promise(() => Bun.file(marker).text())).toBe("reached")
|
|
}
|
|
}),
|
|
))
|
|
}
|
|
})
|
|
|
|
describe("ShellTool ordinary shell syntax", () => {
|
|
for (const shell of ["bash", "zsh"]) {
|
|
const test = isWindows || !Bun.which(shell) ? permissionIt.live.skip : permissionIt.live
|
|
for (const portable of [false, true]) {
|
|
for (const fixture of [
|
|
{ name: "quoted heredoc", command: "cat <<'EOF'\nhello\nEOF", output: "hello\n", saved: ["cat *"] },
|
|
{
|
|
name: "heredoc substitution",
|
|
command: "cat <<EOF\n$(printf hello)\nEOF",
|
|
output: "hello\n",
|
|
saved: ["cat *", "printf *"],
|
|
},
|
|
{
|
|
name: "loop with a conditional",
|
|
command: 'for value in a b; do if test -n "$value"; then printf %s "$value"; fi; done',
|
|
output: "ab",
|
|
saved: ["test *", "printf *"],
|
|
},
|
|
{
|
|
name: "function and case",
|
|
command: 'greet() { case "$1" in a) printf hello;; *) printf other;; esac; }; greet a',
|
|
output: "hello",
|
|
saved: ["greet *", "printf *"],
|
|
},
|
|
{
|
|
name: "parameter fallback",
|
|
command: 'value=; printf %s "${value:-fallback}"',
|
|
output: "fallback",
|
|
saved: ["printf *"],
|
|
},
|
|
{
|
|
name: "arithmetic statement",
|
|
command: 'count=1; ((count += 1)); printf %s "$count"',
|
|
output: "2",
|
|
saved: ["((count += 1)) *", "printf *"],
|
|
},
|
|
{ name: "ANSI-C quoting", command: "printf %s $'a\\nb'", output: "a\nb", saved: ["printf *"] },
|
|
]) {
|
|
test(`${shell} ${portable ? "native" : "legacy"}: ${fixture.name} reuses existing approvals`, () =>
|
|
withScanner(
|
|
portable,
|
|
(registry, directory) =>
|
|
Effect.gen(function* () {
|
|
const saved = yield* PermissionSaved.Service
|
|
const location = yield* Location.Service
|
|
yield* saved.add({ projectID: location.project.id, action: "shell", resources: fixture.saved })
|
|
const result = yield* runPermissionCommand(
|
|
registry,
|
|
fixture.command,
|
|
path.join(directory.active, "marker"),
|
|
[],
|
|
)
|
|
expect(result.requests).toEqual([])
|
|
expect(result.exit).toMatchObject({
|
|
_tag: "Success",
|
|
value: {
|
|
status: "completed",
|
|
metadata: { exit: 0 },
|
|
content: [{ type: "text", text: fixture.output }, { type: "text" }],
|
|
},
|
|
})
|
|
}),
|
|
shell,
|
|
))
|
|
}
|
|
|
|
test(`${shell} ${portable ? "native" : "legacy"}: a loop body deny prevents execution`, () =>
|
|
withScanner(
|
|
portable,
|
|
(registry, directory) =>
|
|
Effect.gen(function* () {
|
|
const agents = yield* Agent.Service
|
|
yield* agents.transform((draft) =>
|
|
draft.update(toolIdentity.agent, (agent) => {
|
|
agent.permissions = [
|
|
{ action: "shell", resource: "*", effect: "allow" },
|
|
{ action: "shell", resource: "printf *", effect: "deny" },
|
|
]
|
|
}),
|
|
)
|
|
const marker = path.join(directory.active, "marker")
|
|
const result = yield* runPermissionCommand(
|
|
registry,
|
|
"for value in a; do printf body > marker; done",
|
|
marker,
|
|
[],
|
|
)
|
|
expect(result.exit).toMatchObject({
|
|
_tag: "Success",
|
|
value: { status: "error", error: { message: expect.stringContaining("Permission denied: shell") } },
|
|
})
|
|
expect(yield* Effect.promise(() => Bun.file(marker).exists())).toBe(false)
|
|
}),
|
|
shell,
|
|
))
|
|
}
|
|
}
|
|
|
|
const pwsh = process.env.SHELL_SCAN_PWSH ?? Bun.which("pwsh") ?? Bun.which("powershell")
|
|
const test = pwsh ? permissionIt.live : permissionIt.live.skip
|
|
for (const portable of [false, true]) {
|
|
for (const command of [
|
|
'Write-Output "$(Write-Output hello)"',
|
|
'$value = "hello"; Write-Output $value',
|
|
"if ($true) { Write-Output hello } else { Write-Output other }",
|
|
"foreach ($value in @('hello')) { Write-Output $value }",
|
|
"ForEach-Object { Write-Output hello }",
|
|
"function Show-Value { Write-Output hello }; Show-Value",
|
|
"Write-Output `\n hello",
|
|
"Write-Output @'\nhello\n'@",
|
|
]) {
|
|
test(`PowerShell ${portable ? "native" : "legacy"}: ordinary syntax reuses approvals: ${command}`, () =>
|
|
withScanner(
|
|
portable,
|
|
(registry, directory) =>
|
|
Effect.gen(function* () {
|
|
const saved = yield* PermissionSaved.Service
|
|
const location = yield* Location.Service
|
|
yield* saved.add({
|
|
projectID: location.project.id,
|
|
action: "shell",
|
|
resources: ["Write-Output *", "Show-Value *"],
|
|
})
|
|
const result = yield* runPermissionCommand(registry, command, path.join(directory.active, "marker"), [])
|
|
expect(result.requests).toEqual([])
|
|
expect(result.exit).toMatchObject({
|
|
_tag: "Success",
|
|
value: { status: "completed", metadata: { exit: 0 } },
|
|
})
|
|
if (Exit.isSuccess(result.exit))
|
|
expect(result.exit.value.content?.[0]).toEqual(Expected.text(isWindows ? "hello\r\n" : "hello\n"))
|
|
}),
|
|
pwsh ?? "pwsh",
|
|
))
|
|
}
|
|
}
|
|
|
|
for (const [command, pattern] of [
|
|
["Write-Output\thello", "Write-Output\t*"],
|
|
["& 'Write-Output' hello", "& 'Write-Output' *"],
|
|
["Write-Output `\n hello", "Write-Output *"],
|
|
]) {
|
|
test(`PowerShell native: always allow covers repeat execution and preserves exact deny: ${command}`, () =>
|
|
withScanner(
|
|
true,
|
|
(registry, directory) =>
|
|
Effect.gen(function* () {
|
|
const marker = path.join(directory.active, "marker")
|
|
const first = yield* runPermissionCommand(registry, command, marker, ["always"])
|
|
expect(first.requests).toMatchObject([{ action: "shell", resources: [command], save: [pattern] }])
|
|
expect(first.exit).toMatchObject({ _tag: "Success", value: { status: "completed", metadata: { exit: 0 } } })
|
|
const repeat = yield* runPermissionCommand(registry, command, marker, [])
|
|
expect(repeat.requests).toEqual([])
|
|
expect(repeat.exit).toMatchObject({
|
|
_tag: "Success",
|
|
value: { status: "completed", metadata: { exit: 0 } },
|
|
})
|
|
|
|
const agents = yield* Agent.Service
|
|
yield* agents.transform((draft) =>
|
|
draft.update(toolIdentity.agent, (agent) => {
|
|
agent.permissions = [{ action: "shell", resource: command, effect: "deny" }]
|
|
}),
|
|
)
|
|
const denied = yield* runPermissionCommand(registry, command, marker, [])
|
|
expect(denied.exit).toMatchObject({
|
|
_tag: "Success",
|
|
value: { status: "error", error: { message: expect.stringContaining("Permission denied: shell") } },
|
|
})
|
|
}),
|
|
pwsh ?? "pwsh",
|
|
))
|
|
}
|
|
})
|
|
|
|
describe("ShellTool", () => {
|
|
productionIt.live(
|
|
"registers and returns real successful output from the active Location",
|
|
() =>
|
|
Effect.acquireUseRelease(
|
|
Effect.promise(() => tmpdir()),
|
|
(tmp) => {
|
|
reset()
|
|
return withSession(tmp.path, (registry) =>
|
|
Effect.gen(function* () {
|
|
const definitions = yield* toolDefinitions(registry)
|
|
const definition = definitions.find((tool) => tool.name === "shell")
|
|
expect(definition?.description).toStartWith("Execute a shell command and return its output.")
|
|
expect(definition?.inputSchema).not.toHaveProperty("properties.timeout.maximum")
|
|
// Code Mode receives the declared output schema, including the command output text.
|
|
expect(definition?.outputSchema).toHaveProperty("properties.output")
|
|
expect(
|
|
(yield* toolDefinitions(registry, [{ action: "shell", resource: "*", effect: "deny" }])).map(
|
|
(tool) => tool.name,
|
|
),
|
|
).not.toContain("shell")
|
|
|
|
const settled = yield* executeTool(registry, call({ command: helloCommand }))
|
|
expect(settled.status).toBe("completed")
|
|
expect(settled.metadata).toMatchObject({ exit: 0, truncated: false })
|
|
expect(settled.content?.[0]).toEqual({ type: "text", text: "hello" })
|
|
expect(settled.content?.[1]).toMatchObject(
|
|
Expected.text(expect.stringContaining("Command exited with code 0.")),
|
|
)
|
|
expect(assertions).toMatchObject([
|
|
{
|
|
sessionID,
|
|
action: "shell",
|
|
resources: [isWindows ? "Start-Sleep -Milliseconds 100" : helloCommand],
|
|
},
|
|
])
|
|
expect(assertions[0]?.save).toEqual([isWindows ? "Start-Sleep *" : "printf *"])
|
|
}),
|
|
)
|
|
},
|
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
|
),
|
|
{ timeout: 15_000 },
|
|
)
|
|
|
|
productionIt.live(
|
|
"uses the session environment instead of the server environment",
|
|
() =>
|
|
Effect.acquireUseRelease(
|
|
Effect.promise(() => tmpdir()),
|
|
(tmp) => {
|
|
reset()
|
|
return withSession(tmp.path, (registry) =>
|
|
Effect.gen(function* () {
|
|
const sessions = yield* Session.Service
|
|
yield* sessions.environment({
|
|
sessionID,
|
|
variables: { OPENCODE_SESSION_ENV_TEST: "from-session" },
|
|
})
|
|
const command = isWindows
|
|
? "[Console]::Out.Write($env:OPENCODE_SESSION_ENV_TEST)"
|
|
: 'printf %s "$OPENCODE_SESSION_ENV_TEST"'
|
|
|
|
const settled = yield* executeTool(registry, call({ command }))
|
|
|
|
expect(settled.status).toBe("completed")
|
|
expect(settled.content?.[0]).toEqual({ type: "text", text: "from-session" })
|
|
}),
|
|
)
|
|
},
|
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
|
),
|
|
{ timeout: 15_000 },
|
|
)
|
|
|
|
it.live("resolves a relative workdir from the active Location", () =>
|
|
Effect.acquireUseRelease(
|
|
Effect.promise(() => tmpdir()),
|
|
(tmp) => {
|
|
reset()
|
|
return Effect.promise(() => fs.mkdir(path.join(tmp.path, "src"))).pipe(
|
|
Effect.andThen(
|
|
withSession(tmp.path, (registry) => executeTool(registry, call({ command: cwdCommand, workdir: "src" }))),
|
|
),
|
|
Effect.andThen((settled) =>
|
|
Effect.sync(() =>
|
|
expect(settled.content?.[0]).toMatchObject(
|
|
Expected.text(expect.stringContaining(realpathSync(path.join(tmp.path, "src")))),
|
|
),
|
|
),
|
|
),
|
|
)
|
|
},
|
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
|
),
|
|
)
|
|
|
|
it.live("reports a missing workdir", () =>
|
|
Effect.acquireUseRelease(
|
|
Effect.promise(() => tmpdir()),
|
|
(tmp) => {
|
|
reset()
|
|
return withSession(tmp.path, (registry) =>
|
|
executeTool(registry, call({ command: cwdCommand, workdir: "missing" })),
|
|
).pipe(
|
|
Effect.andThen((settled) =>
|
|
Effect.sync(() =>
|
|
expect(settled).toEqual({
|
|
status: "error",
|
|
error: {
|
|
type: "unknown",
|
|
message: `Working directory does not exist: ${path.join(tmp.path, "missing")}`,
|
|
},
|
|
}),
|
|
),
|
|
),
|
|
)
|
|
},
|
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
|
),
|
|
)
|
|
|
|
it.live(
|
|
"permissions compound commands separately",
|
|
() =>
|
|
Effect.acquireUseRelease(
|
|
Effect.promise(() => tmpdir()),
|
|
(tmp) => {
|
|
reset()
|
|
return withSession(tmp.path, (registry) =>
|
|
executeTool(registry, call({ command: "printf one && printf two" }, "call-compound")),
|
|
).pipe(
|
|
Effect.andThen(
|
|
Effect.sync(() => {
|
|
expect(assertions).toHaveLength(1)
|
|
expect(assertions[0]).toMatchObject({
|
|
resources: ["printf one", "printf two"],
|
|
save: ["printf *", "printf *"],
|
|
})
|
|
}),
|
|
),
|
|
)
|
|
},
|
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
|
),
|
|
{ timeout: 15_000 },
|
|
)
|
|
|
|
it.live(
|
|
"captures stderr-only and mixed stdout/stderr output",
|
|
() =>
|
|
Effect.acquireUseRelease(
|
|
Effect.promise(() => tmpdir()),
|
|
(tmp) => {
|
|
reset()
|
|
return withSession(tmp.path, (registry) =>
|
|
Effect.gen(function* () {
|
|
const stderr = yield* executeTool(registry, call({ command: stderrCommand }, "call-stderr"))
|
|
expect(stderr.metadata).toMatchObject({ exit: 0, truncated: false })
|
|
expect(stderr.content?.[0]).toEqual({ type: "text", text: "stderr only" })
|
|
|
|
const mixed = yield* executeTool(registry, call({ command: mixedOutputCommand }, "call-mixed"))
|
|
expect(mixed.metadata).toMatchObject({ exit: 0, truncated: false })
|
|
const output = mixed.content?.[0]?.type === "text" ? mixed.content[0].text : ""
|
|
expect(output).toContain("stdout")
|
|
expect(output).toContain("stderr")
|
|
}),
|
|
)
|
|
},
|
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
|
),
|
|
{ timeout: 15_000 },
|
|
)
|
|
|
|
it.live("rejects a workdir that stops being a directory during approval", () =>
|
|
Effect.acquireUseRelease(
|
|
Effect.promise(() => tmpdir()),
|
|
(tmp) => {
|
|
reset()
|
|
const workdir = path.join(tmp.path, "src")
|
|
afterPermission = (input) =>
|
|
input.action === "shell"
|
|
? Effect.promise(async () => {
|
|
await fs.rm(workdir, { recursive: true })
|
|
await fs.writeFile(workdir, "not a directory")
|
|
}).pipe(Effect.orDie)
|
|
: Effect.void
|
|
return Effect.promise(() => fs.mkdir(workdir)).pipe(
|
|
Effect.andThen(
|
|
withSession(tmp.path, (registry) => executeTool(registry, call({ command: cwdCommand, workdir: "src" }))),
|
|
),
|
|
Effect.andThen(Effect.sync(() => expect(assertions.map((input) => input.action)).toEqual(["shell"]))),
|
|
)
|
|
},
|
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
|
),
|
|
)
|
|
|
|
it.live(
|
|
"approves an explicit external workdir before shell execution",
|
|
() =>
|
|
Effect.acquireUseRelease(
|
|
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
|
([active, outside]) => {
|
|
reset()
|
|
return withSession(active.path, (registry) =>
|
|
executeTool(registry, call({ command: cwdCommand, workdir: outside.path })),
|
|
).pipe(
|
|
Effect.andThen(
|
|
Effect.sync(() => {
|
|
expect(assertions.map((item) => item.action)).toEqual(["external_directory", "shell"])
|
|
expect(assertions[0]).toMatchObject({
|
|
resources: [path.join(realpathSync(outside.path), "*").replaceAll("\\", "/")],
|
|
})
|
|
}),
|
|
),
|
|
)
|
|
},
|
|
([active, outside]) =>
|
|
Effect.promise(() =>
|
|
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
|
|
),
|
|
),
|
|
{ timeout: 15_000 },
|
|
)
|
|
|
|
it.live(
|
|
"approves an external directory used by a directory-change command",
|
|
() =>
|
|
Effect.acquireUseRelease(
|
|
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
|
([active, outside]) => {
|
|
reset()
|
|
const command = isWindows
|
|
? `Set-Location -LiteralPath '${outside.path}'; (Get-Location).Path`
|
|
: `cd '${outside.path}' && pwd`
|
|
return withSession(active.path, (registry) =>
|
|
executeTool(registry, call({ command }, "call-external-cd")),
|
|
).pipe(
|
|
Effect.andThen(
|
|
Effect.sync(() => {
|
|
expect(assertions.map((item) => item.action)).toEqual(["external_directory", "shell"])
|
|
expect(assertions[0]).toMatchObject({
|
|
resources: [path.join(realpathSync(outside.path), "*").replaceAll("\\", "/")],
|
|
})
|
|
}),
|
|
),
|
|
)
|
|
},
|
|
([active, outside]) =>
|
|
Effect.promise(() =>
|
|
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
|
|
),
|
|
),
|
|
{ timeout: 15_000 },
|
|
)
|
|
|
|
it.live("approves an expanded external home directory", () =>
|
|
Effect.acquireUseRelease(
|
|
Effect.promise(() => tmpdir()),
|
|
(tmp) => {
|
|
reset()
|
|
const command = isWindows ? "Set-Location $HOME; (Get-Location).Path" : "cd ~ && pwd"
|
|
return withSession(tmp.path, (registry) => executeTool(registry, call({ command }, "call-external-home"))).pipe(
|
|
Effect.andThen(
|
|
Effect.sync(() => {
|
|
expect(assertions.map((item) => item.action)).toEqual(["external_directory", "shell"])
|
|
expect(assertions[0]?.resources[0]).toStartWith(os.homedir().replaceAll("\\", "/"))
|
|
}),
|
|
),
|
|
)
|
|
},
|
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
|
),
|
|
)
|
|
|
|
it.live(
|
|
"does not execute after external-directory or shell denial",
|
|
() =>
|
|
Effect.acquireUseRelease(
|
|
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
|
([active, outside]) =>
|
|
Effect.gen(function* () {
|
|
reset()
|
|
denyAction = "external_directory"
|
|
yield* withSession(active.path, (registry) =>
|
|
executeTool(registry, call({ command: cwdCommand, workdir: outside.path })),
|
|
)
|
|
expect(assertions.map((item) => item.action)).toEqual(["external_directory"])
|
|
|
|
reset()
|
|
denyAction = "shell"
|
|
yield* withSession(active.path, (registry) => executeTool(registry, call({ command: cwdCommand })))
|
|
expect(assertions.map((item) => item.action)).toEqual(["shell"])
|
|
}),
|
|
([active, outside]) =>
|
|
Effect.promise(() =>
|
|
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
|
|
),
|
|
),
|
|
{ timeout: 15_000 },
|
|
)
|
|
|
|
it.live("exposes malformed native syntax without fallback or partial execution", () =>
|
|
Effect.gen(function* () {
|
|
if (isWindows) return
|
|
for (const portable of [false, true]) {
|
|
yield* Effect.acquireUseRelease(
|
|
Effect.promise(() => tmpdir()),
|
|
(tmp) =>
|
|
Effect.gen(function* () {
|
|
reset()
|
|
yield* Effect.promise(() =>
|
|
Bun.write(
|
|
path.join(tmp.path, "opencode.json"),
|
|
JSON.stringify({ experimental: { portable_shell_scanner: portable } }),
|
|
),
|
|
)
|
|
const settled = yield* withSession(tmp.path, (registry) =>
|
|
Effect.gen(function* () {
|
|
const selection = yield* ShellSelect.Service
|
|
yield* selection.transform((draft) => draft.configure("sh"))
|
|
return yield* executeTool(
|
|
registry,
|
|
call({ command: 'printf hello > marker\necho "' }, "call-portable-malformed"),
|
|
)
|
|
}),
|
|
)
|
|
if (portable) {
|
|
expect(settled).toMatchObject({
|
|
status: "error",
|
|
error: { message: expect.stringContaining("unterminated-quote") },
|
|
})
|
|
expect(assertions).toEqual([])
|
|
expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, "marker")).exists())).toBe(false)
|
|
return
|
|
}
|
|
expect(settled.status).toBe("completed")
|
|
expect(settled.metadata?.exit).not.toBe(0)
|
|
expect(assertions.map((item) => item.action)).toEqual(["shell"])
|
|
expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, "marker")).text())).toBe("hello")
|
|
}),
|
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
|
)
|
|
}
|
|
}),
|
|
)
|
|
|
|
for (const shell of ["sh", "zsh"]) {
|
|
const test = isWindows || !Bun.which(shell) ? it.live.skip : it.live
|
|
test(
|
|
`preserves arithmetic and directory permissions with scanner flag on and off in ${shell}`,
|
|
() =>
|
|
Effect.gen(function* () {
|
|
const results = yield* Effect.forEach([false, true], (portable) =>
|
|
Effect.acquireUseRelease(
|
|
Effect.promise(() => tmpdir()),
|
|
(tmp) =>
|
|
Effect.gen(function* () {
|
|
reset()
|
|
yield* Effect.promise(() =>
|
|
Bun.write(
|
|
path.join(tmp.path, "opencode.json"),
|
|
JSON.stringify({ experimental: { portable_shell_scanner: portable } }),
|
|
),
|
|
)
|
|
yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, "one", "two"), { recursive: true }))
|
|
yield* withSession(tmp.path, (registry) =>
|
|
Effect.gen(function* () {
|
|
const selection = yield* ShellSelect.Service
|
|
yield* selection.transform((draft) => draft.configure(shell))
|
|
for (const [command, output] of [
|
|
["echo $((1 + 1))", "2\n"],
|
|
["cd ~ && pwd", `${realpathSync(os.homedir())}\n`],
|
|
["cd one&&cd two&&pwd", `${path.join(tmp.path, "one", "two")}\n`],
|
|
]) {
|
|
const settled = yield* executeTool(registry, call({ command }, `call-parity-${command}`))
|
|
expect(settled.status).toBe("completed")
|
|
expect(settled.metadata).toMatchObject({ exit: 0 })
|
|
expect(settled.content?.[0]).toMatchObject({ type: "text", text: output })
|
|
}
|
|
}),
|
|
)
|
|
expect(assertions.map((item) => item.action)).toEqual([
|
|
"shell",
|
|
"external_directory",
|
|
"shell",
|
|
"shell",
|
|
])
|
|
expect(assertions[1]?.resources).toEqual([path.join(realpathSync(os.homedir()), "*")])
|
|
expect(assertions[0]).toMatchObject({ resources: ["echo $((1 + 1))"], save: ["echo *"] })
|
|
expect(assertions[2]).toMatchObject({ resources: ["pwd"], save: ["pwd *"] })
|
|
expect(assertions[3]).toMatchObject({ resources: ["pwd"], save: ["pwd *"] })
|
|
return assertions.slice()
|
|
}),
|
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
|
),
|
|
)
|
|
expect(results[1]).toEqual(results[0])
|
|
}),
|
|
{ timeout: 15_000 },
|
|
)
|
|
}
|
|
|
|
it.live("keeps non-zero exits useful", () =>
|
|
Effect.acquireUseRelease(
|
|
Effect.promise(() => tmpdir()),
|
|
(tmp) => {
|
|
reset()
|
|
return withSession(tmp.path, (registry) =>
|
|
executeTool(registry, call({ command: bodyExitCommand }, "call-nonzero")),
|
|
).pipe(
|
|
Effect.andThen((settled) =>
|
|
Effect.sync(() => {
|
|
expect(settled.status).toBe("completed")
|
|
expect(settled.metadata).toMatchObject({ exit: 7, truncated: false })
|
|
expect(settled.content?.[0]).toEqual({ type: "text", text: "body" })
|
|
expect(settled.content?.[1]).toMatchObject(
|
|
Expected.text(expect.stringContaining("Command exited with code 7")),
|
|
)
|
|
}),
|
|
),
|
|
)
|
|
},
|
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
|
),
|
|
)
|
|
|
|
it.live(
|
|
"truncates the model view and points at the saved output file when output overflows",
|
|
() =>
|
|
Effect.acquireUseRelease(
|
|
Effect.promise(() => tmpdir()),
|
|
(tmp) => {
|
|
reset()
|
|
const bytes = ToolOutput.MAX_BYTES + 1024
|
|
return withSession(tmp.path, (registry) =>
|
|
executeTool(registry, call({ command: overflowCommand(bytes) }, "call-overflow")),
|
|
).pipe(
|
|
Effect.andThen((settled) =>
|
|
Effect.sync(() => {
|
|
expect(settled.metadata).toMatchObject({ exit: 0, truncated: true })
|
|
const content = settled.content?.[0]
|
|
if (!content || content.type !== "text") throw new Error("Expected text content")
|
|
expect(content.text.includes("output-start")).toBe(false)
|
|
expect(content.text.includes("output-end")).toBe(true)
|
|
expect(content).toMatchObject(
|
|
Expected.text(expect.stringContaining("output truncated; full output saved to:")),
|
|
)
|
|
}),
|
|
),
|
|
)
|
|
},
|
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
|
),
|
|
{ timeout: 15_000 },
|
|
)
|
|
|
|
it.live("uses configured line limits", () =>
|
|
Effect.acquireUseRelease(
|
|
Effect.promise(() => tmpdir()),
|
|
(tmp) => {
|
|
reset()
|
|
return Effect.gen(function* () {
|
|
yield* Effect.promise(() =>
|
|
Bun.write(
|
|
path.join(tmp.path, "opencode.json"),
|
|
JSON.stringify({ tool_output: { max_lines: 2, max_bytes: 1_000 } }),
|
|
),
|
|
)
|
|
const settled = yield* withSession(tmp.path, (registry) =>
|
|
executeTool(registry, call({ command: lineOverflowCommand }, "call-line-overflow")),
|
|
)
|
|
expect(settled.metadata).toMatchObject({ exit: 0, truncated: true })
|
|
const content = settled.content?.[0]
|
|
if (!content || content.type !== "text") throw new Error("Expected text content")
|
|
expect(content.text).not.toContain("one")
|
|
// Windows shells emit CRLF; the assertion targets line limits, not line endings.
|
|
expect(content.text.replaceAll("\r\n", "\n")).toStartWith("two\nthree")
|
|
expect(content.text).toContain("output truncated; full output saved to:")
|
|
})
|
|
},
|
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
|
),
|
|
)
|
|
|
|
it.live(
|
|
"reports the shell ID for a running command",
|
|
() =>
|
|
Effect.acquireUseRelease(
|
|
Effect.promise(() => tmpdir()),
|
|
(tmp) => {
|
|
reset()
|
|
const release = "shell-progress-release"
|
|
const releasePath = path.join(tmp.path, release)
|
|
return withSession(tmp.path, (registry) =>
|
|
Effect.gen(function* () {
|
|
const observed = yield* Deferred.make<string>()
|
|
yield* executeTool(registry, {
|
|
...call({ command: progressOverflowCommand(ToolOutput.MAX_BYTES + 1024, release) }, "call-progress"),
|
|
progress: (update) =>
|
|
Effect.gen(function* () {
|
|
if (typeof update.shellID !== "string") return
|
|
yield* Deferred.succeed(observed, update.shellID)
|
|
yield* Effect.promise(() => fs.writeFile(releasePath, ""))
|
|
}),
|
|
})
|
|
|
|
expect(yield* Deferred.await(observed)).toMatch(/^sh_/)
|
|
}).pipe(Effect.ensuring(Effect.promise(() => fs.writeFile(releasePath, "")).pipe(Effect.ignore))),
|
|
)
|
|
},
|
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
|
),
|
|
{ timeout: 15_000 },
|
|
)
|
|
|
|
it.live(
|
|
"reports shell ID progress once",
|
|
() =>
|
|
Effect.acquireUseRelease(
|
|
Effect.promise(() => tmpdir()),
|
|
(tmp) => {
|
|
reset()
|
|
return withSession(tmp.path, (registry) =>
|
|
Effect.gen(function* () {
|
|
const updates: Tool.Metadata[] = []
|
|
yield* executeTool(registry, {
|
|
...call({ command: helloCommand }, "call-shell-id-progress"),
|
|
progress: (update) => Effect.sync(() => updates.push(update)),
|
|
})
|
|
expect(updates).toHaveLength(1)
|
|
expect(updates[0]?.shellID).toMatch(/^sh_/)
|
|
}),
|
|
)
|
|
},
|
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
|
),
|
|
{ timeout: 15_000 },
|
|
)
|
|
|
|
it.live(
|
|
"returns a useful timeout outcome",
|
|
() =>
|
|
Effect.acquireUseRelease(
|
|
Effect.promise(() => tmpdir()),
|
|
(tmp) => {
|
|
reset()
|
|
return withSession(tmp.path, (registry) =>
|
|
executeTool(registry, call({ command: timeoutOutputCommand, timeout: isWindows ? 3_000 : 500 })),
|
|
).pipe(
|
|
Effect.andThen((settled) =>
|
|
Effect.sync(() => {
|
|
expect(settled.metadata).toMatchObject({ timeout: true, truncated: false })
|
|
expect(settled.content?.[0]).toMatchObject(Expected.text(expect.stringContaining("before timeout")))
|
|
expect(settled.content?.[1]).toMatchObject(Expected.text(expect.stringContaining("Command timed out")))
|
|
}),
|
|
),
|
|
)
|
|
},
|
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
|
),
|
|
{ timeout: 15_000 },
|
|
)
|
|
|
|
it.live("returns the shell id for a background command", () =>
|
|
Effect.acquireUseRelease(
|
|
Effect.promise(() => tmpdir()),
|
|
(tmp) => {
|
|
reset()
|
|
return withSession(tmp.path, (registry) =>
|
|
Effect.gen(function* () {
|
|
const bus = yield* Bus.Service
|
|
const admitted = yield* bus.subscribe(SessionEvent.InboxEnqueued).pipe(
|
|
Stream.filter((event) => event.data.sessionID === sessionID && event.data.item.type === "synthetic"),
|
|
Stream.runHead,
|
|
Effect.forkScoped({ startImmediately: true }),
|
|
)
|
|
const settled = yield* executeTool(registry, call({ command: idleCommand, timeout: 50, background: true }))
|
|
const shellID = typeof settled.metadata?.shellID === "string" ? settled.metadata.shellID : undefined
|
|
expect(settled.metadata).toMatchObject({ truncated: false })
|
|
expect(shellID).toStartWith("sh_")
|
|
|
|
const shell = yield* Shell.Service
|
|
if (!shellID) return
|
|
const id = ShellSchema.ID.make(shellID)
|
|
const info = yield* shell.get(id)
|
|
expect(settled.content).toEqual([
|
|
{
|
|
type: "text",
|
|
text: `Command moved to the background (shell ID: ${shellID}).\nOutput is streaming to: ${info.file}`,
|
|
},
|
|
{
|
|
type: "text",
|
|
text: "You will be notified automatically when the command finishes. Avoid sleep commands or polling for completion; if you need the output before then, read the file directly.",
|
|
},
|
|
])
|
|
expect((yield* shell.list()).map((info) => info.id)).toContain(id)
|
|
expect((yield* shell.wait(id)).status).toBe("timeout")
|
|
expect((yield* Fiber.join(admitted)).valueOrUndefined?.data.item.payload).toMatchObject({
|
|
text: expect.stringContaining("Command timed out before completion."),
|
|
description: idleCommand,
|
|
metadata: {
|
|
source: "shell",
|
|
shellID,
|
|
state: "completed",
|
|
timeout: true,
|
|
truncated: false,
|
|
},
|
|
})
|
|
}),
|
|
)
|
|
},
|
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
|
),
|
|
)
|
|
|
|
it.live("preserves a background command's non-zero exit", () =>
|
|
Effect.acquireUseRelease(
|
|
Effect.promise(() => tmpdir()),
|
|
(tmp) => {
|
|
reset()
|
|
return withSession(tmp.path, (registry) =>
|
|
Effect.gen(function* () {
|
|
const bus = yield* Bus.Service
|
|
const admitted = yield* bus.subscribe(SessionEvent.InboxEnqueued).pipe(
|
|
Stream.filter((event) => event.data.sessionID === sessionID && event.data.item.type === "synthetic"),
|
|
Stream.runHead,
|
|
Effect.forkScoped({ startImmediately: true }),
|
|
)
|
|
const settled = yield* executeTool(
|
|
registry,
|
|
call({ command: bodyExitCommand, background: true }, "call-background-nonzero"),
|
|
)
|
|
const shellID = settled.metadata?.shellID
|
|
expect(typeof shellID).toBe("string")
|
|
expect((yield* Fiber.join(admitted)).valueOrUndefined?.data.item.payload).toMatchObject({
|
|
text: expect.stringContaining("Command exited with code 7."),
|
|
description: bodyExitCommand,
|
|
metadata: {
|
|
source: "shell",
|
|
jobID: "call-background-nonzero",
|
|
shellID,
|
|
state: "completed",
|
|
exit: 7,
|
|
truncated: false,
|
|
},
|
|
})
|
|
}),
|
|
)
|
|
},
|
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
|
),
|
|
)
|
|
|
|
it.live("persists a silent command that finishes before backgrounding", () =>
|
|
Effect.acquireUseRelease(
|
|
Effect.promise(() => tmpdir()),
|
|
(tmp) => {
|
|
reset()
|
|
return withSession(tmp.path, (registry) =>
|
|
Effect.gen(function* () {
|
|
const bus = yield* Bus.Service
|
|
const jobs = yield* Job.Service
|
|
const shell = yield* Shell.Service
|
|
const persisted = yield* Deferred.make<readonly Job.Background[]>()
|
|
yield* bus.project(SessionEvent.InboxEnqueued, (event) =>
|
|
event.data.sessionID === sessionID && event.data.item.type === "synthetic"
|
|
? jobs.pendingBackground.pipe(
|
|
Effect.flatMap((background) => Deferred.succeed(persisted, background)),
|
|
Effect.asVoid,
|
|
)
|
|
: Effect.void,
|
|
)
|
|
yield* executeTool(registry, {
|
|
...call({ command: "exit 7", background: true }, "call-background-silent-nonzero"),
|
|
// The command can finish while its initial progress update is being published.
|
|
progress: (update) =>
|
|
typeof update.shellID === "string"
|
|
? shell.wait(ShellSchema.ID.make(update.shellID)).pipe(Effect.orDie, Effect.asVoid)
|
|
: Effect.void,
|
|
})
|
|
|
|
expect(yield* Deferred.await(persisted)).toMatchObject([
|
|
{
|
|
id: "call-background-silent-nonzero",
|
|
status: "completed",
|
|
output: "(no output)\n\nCommand exited with code 7.",
|
|
},
|
|
])
|
|
}),
|
|
)
|
|
},
|
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
|
),
|
|
)
|
|
|
|
it.live(
|
|
"updates and clears a running shell timeout",
|
|
() =>
|
|
Effect.acquireUseRelease(
|
|
Effect.promise(() => tmpdir()),
|
|
(tmp) => {
|
|
reset()
|
|
return withSession(tmp.path, (registry) =>
|
|
Effect.gen(function* () {
|
|
const shell = yield* Shell.Service
|
|
const timed = yield* executeTool(
|
|
registry,
|
|
call({ command: idleCommand, background: true }, "call-updated-timeout"),
|
|
)
|
|
const timedID = timed.metadata?.shellID
|
|
expect(typeof timedID).toBe("string")
|
|
if (typeof timedID !== "string") return
|
|
const timedShellID = ShellSchema.ID.make(timedID)
|
|
yield* shell.timeout(timedShellID, 50)
|
|
expect((yield* shell.wait(timedShellID)).status).toBe("timeout")
|
|
|
|
const cleared = yield* executeTool(
|
|
registry,
|
|
call({ command: idleCommand, timeout: 50, background: true }, "call-cleared-timeout"),
|
|
)
|
|
const clearedID = cleared.metadata?.shellID
|
|
expect(typeof clearedID).toBe("string")
|
|
if (typeof clearedID !== "string") return
|
|
const clearedShellID = ShellSchema.ID.make(clearedID)
|
|
yield* shell.timeout(clearedShellID, 0)
|
|
yield* Effect.sleep(Duration.millis(100))
|
|
expect((yield* shell.get(clearedShellID)).status).toBe("running")
|
|
yield* shell.remove(clearedShellID)
|
|
}),
|
|
)
|
|
},
|
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
|
),
|
|
{ timeout: 15_000 },
|
|
)
|
|
|
|
it.live("does not retain removed running shells in exit order", () =>
|
|
Effect.acquireUseRelease(
|
|
Effect.promise(() => tmpdir()),
|
|
(tmp) =>
|
|
withSession(tmp.path, () =>
|
|
Effect.gen(function* () {
|
|
const shell = yield* Shell.Service
|
|
yield* Effect.forEach(Array.from({ length: 26 }), () =>
|
|
Effect.gen(function* () {
|
|
const info = yield* shell.create({ command: idleCommand, timeout: 0 })
|
|
yield* shell.remove(info.id)
|
|
yield* Effect.sleep(Duration.millis(10))
|
|
}),
|
|
)
|
|
|
|
const info = yield* shell.create({ command: helloCommand, timeout: 0 })
|
|
const settled = yield* shell.wait(info.id).pipe(Effect.timeoutOption(Duration.seconds(2)))
|
|
expect(settled._tag).toBe("Some")
|
|
}),
|
|
),
|
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
|
),
|
|
)
|
|
|
|
if (!isWindows) {
|
|
it.live("settles a shell terminated by an external signal", () =>
|
|
Effect.acquireUseRelease(
|
|
Effect.promise(() => tmpdir()),
|
|
(tmp) => {
|
|
reset()
|
|
return withSession(tmp.path, (registry) =>
|
|
Effect.gen(function* () {
|
|
const shell = yield* Shell.Service
|
|
const settled = yield* executeTool(
|
|
registry,
|
|
call({ command: idleCommand, background: true }, "call-external-signal"),
|
|
)
|
|
const shellID = settled.metadata?.shellID
|
|
expect(typeof shellID).toBe("string")
|
|
if (typeof shellID !== "string") return
|
|
const id = ShellSchema.ID.make(shellID)
|
|
const info = yield* shell.get(id)
|
|
expect(typeof info.pid).toBe("number")
|
|
if (info.pid === undefined) return
|
|
|
|
process.kill(-info.pid, "SIGTERM")
|
|
const result = yield* shell.wait(id).pipe(Effect.timeoutOption(Duration.seconds(1)))
|
|
expect(result._tag).toBe("Some")
|
|
if (result._tag === "Some") expect(result.value.status).toBe("exited")
|
|
expect((yield* shell.list()).map((item) => item.id)).not.toContain(id)
|
|
}),
|
|
)
|
|
},
|
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
|
),
|
|
)
|
|
}
|
|
|
|
it.live("backgrounds a foreground command when the session is signaled", () =>
|
|
Effect.acquireUseRelease(
|
|
Effect.promise(() => tmpdir()),
|
|
(tmp) => {
|
|
reset()
|
|
return withSession(tmp.path, (registry) =>
|
|
Effect.gen(function* () {
|
|
const jobs = yield* Job.Service
|
|
const scope = yield* Scope.Scope
|
|
const waiting = yield* executeTool(
|
|
registry,
|
|
call({ command: idleCommand, timeout: 50 }, "call-background-signal"),
|
|
).pipe(Effect.forkIn(scope, { startImmediately: true }))
|
|
|
|
const backgroundWhenReady = (remaining = 1000): Effect.Effect<Job.Info[], Error> =>
|
|
Effect.gen(function* () {
|
|
const backgrounded = yield* jobs.backgroundAll({ sessionID })
|
|
if (backgrounded.length > 0) return backgrounded
|
|
if (remaining <= 0) return yield* Effect.fail(new Error("Timed out waiting for foreground shell job"))
|
|
yield* Effect.promise(() => Bun.sleep(1))
|
|
return yield* backgroundWhenReady(remaining - 1)
|
|
})
|
|
expect(yield* backgroundWhenReady()).toMatchObject([{ id: "call-background-signal", type: "shell" }])
|
|
const settled = yield* Fiber.join(waiting)
|
|
const shellID = typeof settled.metadata?.shellID === "string" ? settled.metadata.shellID : undefined
|
|
expect(settled.metadata).toMatchObject({ truncated: false })
|
|
expect(shellID).toStartWith("sh_")
|
|
|
|
const shell = yield* Shell.Service
|
|
if (!shellID) return
|
|
const id = ShellSchema.ID.make(shellID)
|
|
const info = yield* shell.get(id)
|
|
expect(settled.content?.[0]).toEqual({
|
|
type: "text",
|
|
text: `Command moved to the background (shell ID: ${shellID}).\nOutput is streaming to: ${info.file}`,
|
|
})
|
|
expect(settled.content?.[1]).toEqual({
|
|
type: "text",
|
|
text: "You will be notified automatically when the command finishes. Avoid sleep commands or polling for completion; if you need the output before then, read the file directly.",
|
|
})
|
|
yield* Effect.sleep(Duration.millis(100))
|
|
expect((yield* shell.get(id)).status).toBe("running")
|
|
expect((yield* shell.list()).map((info) => info.id)).toContain(id)
|
|
yield* shell.remove(id)
|
|
}),
|
|
)
|
|
},
|
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
|
),
|
|
)
|
|
})
|