feat(core): support background shell tool
This commit is contained in:
@@ -188,6 +188,7 @@ export interface Interface {
|
|||||||
readonly active: Effect.Effect<ReadonlySet<SessionSchema.ID>>
|
readonly active: Effect.Effect<ReadonlySet<SessionSchema.ID>>
|
||||||
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | SessionRunner.RunError>
|
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | SessionRunner.RunError>
|
||||||
readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||||
|
readonly synthetic: (input: { sessionID: SessionSchema.ID; text: string }) => Effect.Effect<void, NotFoundError>
|
||||||
readonly revert: {
|
readonly revert: {
|
||||||
readonly stage: (input: {
|
readonly stage: (input: {
|
||||||
sessionID: SessionSchema.ID
|
sessionID: SessionSchema.ID
|
||||||
@@ -497,6 +498,16 @@ export const layer = Layer.effect(
|
|||||||
yield* result.get(sessionID)
|
yield* result.get(sessionID)
|
||||||
yield* execution.resume(sessionID)
|
yield* execution.resume(sessionID)
|
||||||
}),
|
}),
|
||||||
|
synthetic: Effect.fn("V2Session.synthetic")(function* (input) {
|
||||||
|
yield* result.get(input.sessionID)
|
||||||
|
yield* events.publish(SessionEvent.Synthetic, {
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
messageID: SessionMessage.ID.create(),
|
||||||
|
timestamp: yield* DateTime.now,
|
||||||
|
text: input.text,
|
||||||
|
})
|
||||||
|
yield* execution.wake(input.sessionID)
|
||||||
|
}),
|
||||||
interrupt: Effect.fn("V2Session.interrupt")((sessionID) =>
|
interrupt: Effect.fn("V2Session.interrupt")((sessionID) =>
|
||||||
Effect.uninterruptible(execution.interrupt(sessionID)),
|
Effect.uninterruptible(execution.interrupt(sessionID)),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ export * as BuiltInTools from "./builtins"
|
|||||||
|
|
||||||
import { makeLocationNode } from "../effect/app-node"
|
import { makeLocationNode } from "../effect/app-node"
|
||||||
import { Layer } from "effect"
|
import { Layer } from "effect"
|
||||||
import { ShellTool } from "./shell"
|
|
||||||
import { ApplyPatchTool } from "./apply-patch"
|
import { ApplyPatchTool } from "./apply-patch"
|
||||||
import { EditTool } from "./edit"
|
import { EditTool } from "./edit"
|
||||||
import { GlobTool } from "./glob"
|
import { GlobTool } from "./glob"
|
||||||
@@ -16,7 +15,6 @@ import { WebFetchTool } from "./webfetch"
|
|||||||
import { WebSearchTool } from "./websearch"
|
import { WebSearchTool } from "./websearch"
|
||||||
import { WriteTool } from "./write"
|
import { WriteTool } from "./write"
|
||||||
import { FSUtil } from "../fs-util"
|
import { FSUtil } from "../fs-util"
|
||||||
import { Shell } from "../shell"
|
|
||||||
import { Location } from "../location"
|
import { Location } from "../location"
|
||||||
import { LocationMutation } from "../location-mutation"
|
import { LocationMutation } from "../location-mutation"
|
||||||
import { FileMutation } from "../file-mutation"
|
import { FileMutation } from "../file-mutation"
|
||||||
@@ -44,7 +42,6 @@ import { httpClient } from "../effect/app-node-platform"
|
|||||||
*/
|
*/
|
||||||
export const locationLayer = Layer.mergeAll(
|
export const locationLayer = Layer.mergeAll(
|
||||||
ApplyPatchTool.layer,
|
ApplyPatchTool.layer,
|
||||||
ShellTool.layer,
|
|
||||||
EditTool.layer,
|
EditTool.layer,
|
||||||
GlobTool.layer,
|
GlobTool.layer,
|
||||||
GrepTool.layer,
|
GrepTool.layer,
|
||||||
@@ -63,7 +60,6 @@ export const node = makeLocationNode({
|
|||||||
deps: [
|
deps: [
|
||||||
ToolRegistry.toolsNode,
|
ToolRegistry.toolsNode,
|
||||||
FSUtil.node,
|
FSUtil.node,
|
||||||
Shell.node,
|
|
||||||
Location.node,
|
Location.node,
|
||||||
LocationMutation.node,
|
LocationMutation.node,
|
||||||
FileMutation.node,
|
FileMutation.node,
|
||||||
|
|||||||
+166
-65
@@ -2,20 +2,28 @@ export * as ShellTool from "./shell"
|
|||||||
|
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { ToolFailure } from "@opencode-ai/llm"
|
import { ToolFailure } from "@opencode-ai/llm"
|
||||||
import { Effect, Layer, Schema } from "effect"
|
import { Effect, Layer, Schema, Scope } from "effect"
|
||||||
|
import { BackgroundJob } from "../background-job"
|
||||||
import { FSUtil } from "../fs-util"
|
import { FSUtil } from "../fs-util"
|
||||||
import { LocationMutation } from "../location-mutation"
|
import { LocationMutation } from "../location-mutation"
|
||||||
|
import { LocationServiceMap } from "../location-service-map"
|
||||||
import { PermissionV2 } from "../permission"
|
import { PermissionV2 } from "../permission"
|
||||||
import { PositiveInt } from "../schema"
|
import { PositiveInt } from "../schema"
|
||||||
|
import { SessionV2 } from "../session"
|
||||||
|
import { SessionSchema } from "../session/schema"
|
||||||
import { Shell } from "../shell"
|
import { Shell } from "../shell"
|
||||||
import { Tool } from "./tool"
|
import { Tool, type Content } from "./tool"
|
||||||
import { Tools } from "./tools"
|
import { ApplicationTools } from "./application-tools"
|
||||||
|
import { makeGlobalNode } from "../effect/app-node"
|
||||||
|
|
||||||
export const name = "shell"
|
export const name = "shell"
|
||||||
export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
|
export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
|
||||||
export const MAX_TIMEOUT_MS = 10 * 60 * 1_000
|
export const MAX_TIMEOUT_MS = 10 * 60 * 1_000
|
||||||
export const MAX_CAPTURE_BYTES = 1024 * 1024
|
export const MAX_CAPTURE_BYTES = 1024 * 1024
|
||||||
|
|
||||||
|
const BACKGROUND_STARTED =
|
||||||
|
"The command is running in the background. You will be notified automatically when it completes. DO NOT sleep, poll, or proactively check on its progress."
|
||||||
|
|
||||||
export const Input = Schema.Struct({
|
export const Input = Schema.Struct({
|
||||||
command: Schema.String.annotate({ description: "Shell command string to execute" }),
|
command: Schema.String.annotate({ description: "Shell command string to execute" }),
|
||||||
workdir: Schema.String.pipe(Schema.optional).annotate({
|
workdir: Schema.String.pipe(Schema.optional).annotate({
|
||||||
@@ -26,6 +34,10 @@ export const Input = Schema.Struct({
|
|||||||
.annotate({
|
.annotate({
|
||||||
description: `Timeout in milliseconds. Defaults to ${DEFAULT_TIMEOUT_MS} and may not exceed ${MAX_TIMEOUT_MS}.`,
|
description: `Timeout in milliseconds. Defaults to ${DEFAULT_TIMEOUT_MS} and may not exceed ${MAX_TIMEOUT_MS}.`,
|
||||||
}),
|
}),
|
||||||
|
background: Schema.Boolean.pipe(Schema.optional).annotate({
|
||||||
|
description:
|
||||||
|
"Run the command in the background and return immediately. You will be notified when it completes. DO NOT poll its progress.",
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
const StructuredOutput = Schema.Struct({
|
const StructuredOutput = Schema.Struct({
|
||||||
@@ -37,12 +49,14 @@ const StructuredOutput = Schema.Struct({
|
|||||||
const Output = Schema.Struct({
|
const Output = Schema.Struct({
|
||||||
...StructuredOutput.fields,
|
...StructuredOutput.fields,
|
||||||
output: Schema.String,
|
output: Schema.String,
|
||||||
|
status: Schema.Literals(["completed", "running"]).pipe(Schema.optional),
|
||||||
warnings: Schema.Array(Schema.String).pipe(Schema.optional),
|
warnings: Schema.Array(Schema.String).pipe(Schema.optional),
|
||||||
})
|
})
|
||||||
|
|
||||||
type Output = typeof Output.Type
|
type Output = typeof Output.Type
|
||||||
|
|
||||||
const modelOutput = (output: Output) => {
|
const modelOutput = (output: Output): string | undefined => {
|
||||||
|
if (output.status === "running") return undefined
|
||||||
const warnings = output.warnings?.length
|
const warnings = output.warnings?.length
|
||||||
? `\n\nWarnings:\n${output.warnings.map((warning) => `- ${warning}`).join("\n")}`
|
? `\n\nWarnings:\n${output.warnings.map((warning) => `- ${warning}`).join("\n")}`
|
||||||
: ""
|
: ""
|
||||||
@@ -61,7 +75,6 @@ const modelOutput = (output: Output) => {
|
|||||||
// TODO: Add plugin shell.env environment augmentation once V2 plugin hooks exist.
|
// TODO: Add plugin shell.env environment augmentation once V2 plugin hooks exist.
|
||||||
// TODO: Add durable/live progress metadata streaming for long-running commands once V2 tool invocation progress context is wired.
|
// TODO: Add durable/live progress metadata streaming for long-running commands once V2 tool invocation progress context is wired.
|
||||||
// TODO: Persist background job status and define restart recovery before exposing remote observation.
|
// TODO: Persist background job status and define restart recovery before exposing remote observation.
|
||||||
// TODO: Re-add model-facing background launch only with owner-bound get/wait/cancel tools and completion delivery.
|
|
||||||
// TODO: Add HTTP background-job observation only after durable status, restart recovery, and authorization are defined.
|
// TODO: Add HTTP background-job observation only after durable status, restart recovery, and authorization are defined.
|
||||||
// TODO: Revisit process-group cleanup and platform coverage with shell-specific tests if current AppProcess semantics do not fully cover it.
|
// TODO: Revisit process-group cleanup and platform coverage with shell-specific tests if current AppProcess semantics do not fully cover it.
|
||||||
// TODO: Revisit binary output handling if stdout/stderr decoding is text-only.
|
// TODO: Revisit binary output handling if stdout/stderr decoding is text-only.
|
||||||
@@ -83,16 +96,47 @@ const externalCommandDirectories = (command: string, cwd: string) => {
|
|||||||
|
|
||||||
export const layer = Layer.effectDiscard(
|
export const layer = Layer.effectDiscard(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const tools = yield* Tools.Service
|
const tools = yield* ApplicationTools.Service
|
||||||
const mutation = yield* LocationMutation.Service
|
const sessions = yield* SessionV2.Service
|
||||||
const fs = yield* FSUtil.Service
|
const jobs = yield* BackgroundJob.Service
|
||||||
const shell = yield* Shell.Service
|
const locations = yield* LocationServiceMap.Service
|
||||||
const permission = yield* PermissionV2.Service
|
const scope = yield* Scope.Scope
|
||||||
|
|
||||||
|
const injectWhenDone = Effect.fn("ShellTool.injectWhenDone")(function* (
|
||||||
|
sessionID: SessionSchema.ID,
|
||||||
|
callID: string,
|
||||||
|
command: string,
|
||||||
|
) {
|
||||||
|
yield* jobs.wait({ id: callID }).pipe(
|
||||||
|
Effect.flatMap((result) => {
|
||||||
|
const state =
|
||||||
|
result.info?.status === "completed"
|
||||||
|
? "completed"
|
||||||
|
: result.info?.status === "error"
|
||||||
|
? "error"
|
||||||
|
: result.info?.status === "cancelled"
|
||||||
|
? "cancelled"
|
||||||
|
: undefined
|
||||||
|
if (state === undefined) return Effect.void
|
||||||
|
const text =
|
||||||
|
state === "completed"
|
||||||
|
? result.info!.output ?? ""
|
||||||
|
: state === "error"
|
||||||
|
? result.info!.error ?? "Command failed"
|
||||||
|
: "Command cancelled"
|
||||||
|
return sessions.synthetic({
|
||||||
|
sessionID,
|
||||||
|
text: `<shell id="${callID}" state="${state}" command="${command}">\n${text}\n</shell>`,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
Effect.forkIn(scope, { startImmediately: true }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
yield* tools
|
yield* tools
|
||||||
.register({
|
.register({
|
||||||
[name]: Tool.make({
|
[name]: Tool.make({
|
||||||
description: `Execute one shell command string with the host user's filesystem, process, and network authority. The active Location is the default working directory. Relative workdir values resolve from that Location. External workdir values require external_directory approval; best-effort command-argument path warnings are advisory only. Timeout values are milliseconds (default: ${DEFAULT_TIMEOUT_MS}; maximum: ${MAX_TIMEOUT_MS}). Uses the configured shell when set; otherwise uses /bin/sh on POSIX and COMSPEC or cmd.exe on Windows.`,
|
description: `Execute one shell command string with the host user's filesystem, process, and network authority. The active Location is the default working directory. Relative workdir values resolve from that Location. External workdir values require external_directory approval; best-effort command-argument path warnings are advisory only. Timeout values are milliseconds (default: ${DEFAULT_TIMEOUT_MS}; maximum: ${MAX_TIMEOUT_MS}). Uses the configured shell when set; otherwise uses /bin/sh on POSIX and COMSPEC or cmd.exe on Windows. Background mode (background=true) launches the command asynchronously and returns immediately; you are notified when it finishes.`,
|
||||||
input: Input,
|
input: Input,
|
||||||
output: Output,
|
output: Output,
|
||||||
structured: StructuredOutput,
|
structured: StructuredOutput,
|
||||||
@@ -101,76 +145,133 @@ export const layer = Layer.effectDiscard(
|
|||||||
...(output.exit === undefined ? {} : { exit: output.exit }),
|
...(output.exit === undefined ? {} : { exit: output.exit }),
|
||||||
...(output.timeout === undefined ? {} : { timeout: output.timeout }),
|
...(output.timeout === undefined ? {} : { timeout: output.timeout }),
|
||||||
}),
|
}),
|
||||||
toModelOutput: ({ output }) => [
|
toModelOutput: ({ output }) => {
|
||||||
{ type: "text", text: output.output },
|
const parts: Content[] = [{ type: "text", text: output.output }]
|
||||||
{ type: "text", text: modelOutput(output) },
|
const model = modelOutput(output)
|
||||||
],
|
if (model) parts.push({ type: "text", text: model })
|
||||||
|
return parts
|
||||||
|
},
|
||||||
execute: (input, context) =>
|
execute: (input, context) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const source = {
|
const parent = yield* sessions
|
||||||
type: "tool" as const,
|
.get(context.sessionID)
|
||||||
messageID: context.assistantMessageID,
|
.pipe(
|
||||||
callID: context.toolCallID,
|
Effect.mapError(() => new ToolFailure({ message: `Session not found: ${context.sessionID}` })),
|
||||||
}
|
)
|
||||||
const target = yield* mutation.resolve({ path: input.workdir ?? ".", kind: "directory" })
|
return yield* Effect.gen(function* () {
|
||||||
const external = target.externalDirectory
|
const mutation = yield* LocationMutation.Service
|
||||||
if (external)
|
const fs = yield* FSUtil.Service
|
||||||
|
const shell = yield* Shell.Service
|
||||||
|
const permission = yield* PermissionV2.Service
|
||||||
|
const source = {
|
||||||
|
type: "tool" as const,
|
||||||
|
messageID: context.assistantMessageID,
|
||||||
|
callID: context.toolCallID,
|
||||||
|
}
|
||||||
|
const target = yield* mutation.resolve({ path: input.workdir ?? ".", kind: "directory" })
|
||||||
|
const external = target.externalDirectory
|
||||||
|
if (external)
|
||||||
|
yield* permission.assert({
|
||||||
|
...LocationMutation.externalDirectoryPermission(external),
|
||||||
|
sessionID: context.sessionID,
|
||||||
|
agent: context.agent,
|
||||||
|
source,
|
||||||
|
})
|
||||||
|
const warnings = externalCommandDirectories(input.command, target.canonical).map(
|
||||||
|
(directory) =>
|
||||||
|
`Command argument references external directory ${path.join(directory, "*").replaceAll("\\", "/")}. Shell runs with host-user filesystem, process, and network authority; this scan is advisory only.`,
|
||||||
|
)
|
||||||
yield* permission.assert({
|
yield* permission.assert({
|
||||||
...LocationMutation.externalDirectoryPermission(external),
|
action: name,
|
||||||
|
resources: [input.command],
|
||||||
|
save: [input.command],
|
||||||
sessionID: context.sessionID,
|
sessionID: context.sessionID,
|
||||||
agent: context.agent,
|
agent: context.agent,
|
||||||
source,
|
source,
|
||||||
})
|
})
|
||||||
const warnings = externalCommandDirectories(input.command, target.canonical).map(
|
|
||||||
(directory) =>
|
|
||||||
`Command argument references external directory ${path.join(directory, "*").replaceAll("\\", "/")}. Shell runs with host-user filesystem, process, and network authority; this scan is advisory only.`,
|
|
||||||
)
|
|
||||||
yield* permission.assert({
|
|
||||||
action: name,
|
|
||||||
resources: [input.command],
|
|
||||||
save: [input.command],
|
|
||||||
sessionID: context.sessionID,
|
|
||||||
agent: context.agent,
|
|
||||||
source,
|
|
||||||
})
|
|
||||||
|
|
||||||
if ((yield* fs.stat(target.canonical)).type !== "Directory")
|
if ((yield* fs.stat(target.canonical)).type !== "Directory")
|
||||||
return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.canonical}`))
|
return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.canonical}`))
|
||||||
|
|
||||||
// Delegate spawning, combined-output capture, timeout, and exit tracking to the Shell
|
const timeout = input.timeout ?? DEFAULT_TIMEOUT_MS
|
||||||
// service. The full output is captured to a file; we read a bounded page for the model
|
|
||||||
// and point the agent at the file when it overflows the model cap.
|
|
||||||
const timeout = input.timeout ?? DEFAULT_TIMEOUT_MS
|
|
||||||
const info = yield* shell.create({
|
|
||||||
command: input.command,
|
|
||||||
cwd: target.canonical,
|
|
||||||
timeout,
|
|
||||||
metadata: { sessionID: context.sessionID },
|
|
||||||
})
|
|
||||||
const final = yield* shell.wait(info.id)
|
|
||||||
const page = yield* shell.output(info.id, { limit: MAX_CAPTURE_BYTES })
|
|
||||||
|
|
||||||
if (final.status === "timeout") {
|
if (input.background === true) {
|
||||||
|
const run = Effect.fn("ShellTool.run")(function* () {
|
||||||
|
const info = yield* shell.create({
|
||||||
|
command: input.command,
|
||||||
|
cwd: target.canonical,
|
||||||
|
timeout,
|
||||||
|
metadata: { sessionID: context.sessionID },
|
||||||
|
})
|
||||||
|
const final = yield* shell.wait(info.id)
|
||||||
|
const page = yield* shell.output(info.id, { limit: MAX_CAPTURE_BYTES })
|
||||||
|
|
||||||
|
if (final.status === "timeout")
|
||||||
|
return `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`
|
||||||
|
|
||||||
|
const truncated = page.size > page.cursor
|
||||||
|
const body = page.output || "(no output)"
|
||||||
|
const notice = truncated ? `\n\n[output truncated; full output saved to: ${final.file}]` : ""
|
||||||
|
return `${body}${notice}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const info = yield* jobs.start({
|
||||||
|
id: context.toolCallID,
|
||||||
|
type: name,
|
||||||
|
title: input.command,
|
||||||
|
metadata: { sessionID: context.sessionID },
|
||||||
|
onPromote: injectWhenDone(context.sessionID, context.toolCallID, input.command),
|
||||||
|
run: run(),
|
||||||
|
})
|
||||||
|
yield* injectWhenDone(context.sessionID, context.toolCallID, input.command)
|
||||||
|
return {
|
||||||
|
output: BACKGROUND_STARTED,
|
||||||
|
truncated: false,
|
||||||
|
status: "running" as const,
|
||||||
|
...(warnings.length ? { warnings } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const info = yield* shell.create({
|
||||||
|
command: input.command,
|
||||||
|
cwd: target.canonical,
|
||||||
|
timeout,
|
||||||
|
metadata: { sessionID: context.sessionID },
|
||||||
|
})
|
||||||
|
const final = yield* shell.wait(info.id)
|
||||||
|
const page = yield* shell.output(info.id, { limit: MAX_CAPTURE_BYTES })
|
||||||
|
|
||||||
|
if (final.status === "timeout") {
|
||||||
|
return {
|
||||||
|
exit: final.exit,
|
||||||
|
output: `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`,
|
||||||
|
truncated: false,
|
||||||
|
timeout: true,
|
||||||
|
status: "completed" as const,
|
||||||
|
...(warnings.length ? { warnings } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const truncated = page.size > page.cursor
|
||||||
|
const body = page.output || "(no output)"
|
||||||
|
const notice = truncated ? `\n\n[output truncated; full output saved to: ${final.file}]` : ""
|
||||||
return {
|
return {
|
||||||
output: `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`,
|
exit: final.exit,
|
||||||
truncated: false,
|
output: `${body}${notice}`,
|
||||||
timeout: true,
|
truncated,
|
||||||
|
status: "completed" as const,
|
||||||
...(warnings.length ? { warnings } : {}),
|
...(warnings.length ? { warnings } : {}),
|
||||||
}
|
}
|
||||||
}
|
}).pipe(Effect.provide(locations.get(parent.location))) as Effect.Effect<Schema.Schema.Type<typeof Output>, ToolFailure>
|
||||||
|
|
||||||
const truncated = page.size > page.cursor
|
|
||||||
const body = page.output || "(no output)"
|
|
||||||
const notice = truncated ? `\n\n[output truncated; full output saved to: ${final.file}]` : ""
|
|
||||||
return {
|
|
||||||
exit: final.exit,
|
|
||||||
output: `${body}${notice}`,
|
|
||||||
truncated,
|
|
||||||
...(warnings.length ? { warnings } : {}),
|
|
||||||
}
|
|
||||||
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to execute command: ${input.command}` }))),
|
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to execute command: ${input.command}` }))),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const node = makeGlobalNode({
|
||||||
|
name: "shell-tool",
|
||||||
|
layer,
|
||||||
|
deps: [ApplicationTools.node, SessionV2.node, BackgroundJob.node, LocationServiceMap.node],
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
export * as SubagentTool from "./subagent"
|
export * as SubagentTool from "./subagent"
|
||||||
|
|
||||||
import { ToolFailure } from "@opencode-ai/llm"
|
import { ToolFailure } from "@opencode-ai/llm"
|
||||||
import { DateTime, Effect, Layer, Schema, Scope } from "effect"
|
import { Effect, Layer, Schema, Scope } from "effect"
|
||||||
import { AgentV2 } from "../agent"
|
import { AgentV2 } from "../agent"
|
||||||
import { BackgroundJob } from "../background-job"
|
import { BackgroundJob } from "../background-job"
|
||||||
import { EventV2 } from "../event"
|
|
||||||
import { LocationServiceMap } from "../location-service-map"
|
import { LocationServiceMap } from "../location-service-map"
|
||||||
import { SessionV2 } from "../session"
|
import { SessionV2 } from "../session"
|
||||||
import { SessionEvent } from "../session/event"
|
|
||||||
import { SessionMessage } from "../session/message"
|
|
||||||
import { SessionSchema } from "../session/schema"
|
import { SessionSchema } from "../session/schema"
|
||||||
import { makeGlobalNode } from "../effect/app-node"
|
import { makeGlobalNode } from "../effect/app-node"
|
||||||
import { ApplicationTools } from "./application-tools"
|
import { ApplicationTools } from "./application-tools"
|
||||||
@@ -48,7 +45,6 @@ export const layer = Layer.effectDiscard(
|
|||||||
const tools = yield* ApplicationTools.Service
|
const tools = yield* ApplicationTools.Service
|
||||||
const sessions = yield* SessionV2.Service
|
const sessions = yield* SessionV2.Service
|
||||||
const jobs = yield* BackgroundJob.Service
|
const jobs = yield* BackgroundJob.Service
|
||||||
const events = yield* EventV2.Service
|
|
||||||
const locations = yield* LocationServiceMap.Service
|
const locations = yield* LocationServiceMap.Service
|
||||||
const scope = yield* Scope.Scope
|
const scope = yield* Scope.Scope
|
||||||
|
|
||||||
@@ -75,10 +71,8 @@ export const layer = Layer.effectDiscard(
|
|||||||
state: "completed" | "error" | "cancelled",
|
state: "completed" | "error" | "cancelled",
|
||||||
text: string,
|
text: string,
|
||||||
) {
|
) {
|
||||||
yield* events.publish(SessionEvent.Synthetic, {
|
yield* sessions.synthetic({
|
||||||
sessionID: parentID,
|
sessionID: parentID,
|
||||||
messageID: SessionMessage.ID.create(),
|
|
||||||
timestamp: yield* DateTime.now,
|
|
||||||
text: `<subagent id="${childID}" state="${state}" description="${description}">\n${text}\n</subagent>`,
|
text: `<subagent id="${childID}" state="${state}" description="${description}">\n${text}\n</subagent>`,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -188,5 +182,5 @@ export const layer = Layer.effectDiscard(
|
|||||||
export const node = makeGlobalNode({
|
export const node = makeGlobalNode({
|
||||||
name: "subagent-tool",
|
name: "subagent-tool",
|
||||||
layer,
|
layer,
|
||||||
deps: [ApplicationTools.node, SessionV2.node, BackgroundJob.node, EventV2.node, LocationServiceMap.node],
|
deps: [ApplicationTools.node, SessionV2.node, BackgroundJob.node, LocationServiceMap.node],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,26 +2,38 @@ import fs from "fs/promises"
|
|||||||
import { realpathSync } from "node:fs"
|
import { realpathSync } from "node:fs"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { Effect, Layer } from "effect"
|
import { DateTime, Effect, Layer } from "effect"
|
||||||
import { Config } from "@opencode-ai/core/config"
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
|
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||||
|
import { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
|
||||||
|
import { filesystem } from "@opencode-ai/core/effect/app-node-platform"
|
||||||
|
import { Database } from "@opencode-ai/core/database/database"
|
||||||
import { EventV2 } from "@opencode-ai/core/event"
|
import { EventV2 } from "@opencode-ai/core/event"
|
||||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { Location } from "@opencode-ai/core/location"
|
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 { PermissionV2 } from "@opencode-ai/core/permission"
|
import { ModelV2 } from "@opencode-ai/core/model"
|
||||||
import { AppProcess } from "@opencode-ai/core/process"
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||||
import { Project } from "@opencode-ai/core/project"
|
|
||||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
|
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||||
|
import { BackgroundJob } from "@opencode-ai/core/background-job"
|
||||||
import { SessionV2 } from "@opencode-ai/core/session"
|
import { SessionV2 } from "@opencode-ai/core/session"
|
||||||
import { Shell } from "@opencode-ai/core/shell"
|
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 { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||||
|
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||||
|
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||||
import { ShellTool } from "@opencode-ai/core/tool/shell"
|
import { ShellTool } from "@opencode-ai/core/tool/shell"
|
||||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||||
|
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||||
import { tmpdir } from "./fixture/tmpdir"
|
import { tmpdir } from "./fixture/tmpdir"
|
||||||
import { testEffect } from "./lib/effect"
|
import { testEffect } from "./lib/effect"
|
||||||
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
|
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
|
||||||
|
|
||||||
const sessionID = SessionV2.ID.make("ses_shell_tool_test")
|
const sessionID = SessionV2.ID.make("ses_shell_tool_test")
|
||||||
|
const sessionModel = ModelV2.Ref.make({ id: ModelV2.ID.make("test"), providerID: ProviderV2.ID.make("test") })
|
||||||
const assertions: PermissionV2.AssertInput[] = []
|
const assertions: PermissionV2.AssertInput[] = []
|
||||||
let denyAction: string | undefined
|
let denyAction: string | undefined
|
||||||
let afterPermission = (_input: PermissionV2.AssertInput): Effect.Effect<void> => Effect.void
|
let afterPermission = (_input: PermissionV2.AssertInput): Effect.Effect<void> => Effect.void
|
||||||
@@ -50,37 +62,80 @@ const reset = () => {
|
|||||||
afterPermission = () => Effect.void
|
afterPermission = () => Effect.void
|
||||||
}
|
}
|
||||||
|
|
||||||
const withTool = <A, E, R>(
|
const executionNode = makeGlobalNode({
|
||||||
data: string,
|
service: SessionExecution.Service,
|
||||||
directory: string,
|
layer: Layer.effect(
|
||||||
body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>,
|
SessionExecution.Service,
|
||||||
) => {
|
Effect.gen(function* () {
|
||||||
const filesystem = FSUtil.defaultLayer
|
const events = yield* EventV2.Service
|
||||||
const location = Location.layer(Location.Ref.make({ directory: AbsolutePath.make(directory) })).pipe(
|
const store = yield* SessionStore.Service
|
||||||
Layer.provide(Project.defaultLayer),
|
const complete = Effect.fn("ShellTest.complete")(function* (id: SessionV2.ID) {
|
||||||
)
|
const session = yield* store.get(id)
|
||||||
const global = Global.layerWith({ data, config: path.join(data, "config") })
|
if (!session) return
|
||||||
const mutation = LocationMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(location))
|
const assistantMessageID = SessionMessage.ID.create()
|
||||||
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
|
const textID = "text_shell_test"
|
||||||
const shellService = Shell.layer.pipe(
|
yield* events.publish(SessionEvent.Step.Started, {
|
||||||
Layer.provide(EventV2.defaultLayer),
|
sessionID: id,
|
||||||
Layer.provide(location),
|
assistantMessageID,
|
||||||
Layer.provide(Config.locationLayer.pipe(Layer.provide(location), Layer.provide(filesystem), Layer.provide(global))),
|
timestamp: yield* DateTime.now,
|
||||||
Layer.provide(global),
|
agent: session.agent ?? AgentV2.ID.make("code"),
|
||||||
Layer.provide(filesystem),
|
model: sessionModel,
|
||||||
Layer.provide(AppProcess.defaultLayer),
|
})
|
||||||
)
|
yield* events.publish(SessionEvent.Text.Started, {
|
||||||
const shell = ShellTool.layer.pipe(
|
sessionID: id,
|
||||||
Layer.provide(registry),
|
assistantMessageID,
|
||||||
Layer.provide(permission),
|
timestamp: yield* DateTime.now,
|
||||||
Layer.provide(mutation),
|
textID,
|
||||||
Layer.provide(filesystem),
|
})
|
||||||
Layer.provide(shellService),
|
yield* events.publish(SessionEvent.Text.Ended, {
|
||||||
)
|
sessionID: id,
|
||||||
return Effect.gen(function* () {
|
assistantMessageID,
|
||||||
return yield* body(yield* ToolRegistry.Service)
|
timestamp: yield* DateTime.now,
|
||||||
}).pipe(Effect.provide(Layer.mergeAll(registry, shell, filesystem)))
|
textID,
|
||||||
}
|
text: "ok",
|
||||||
|
})
|
||||||
|
yield* events.publish(SessionEvent.Step.Ended, {
|
||||||
|
sessionID: id,
|
||||||
|
assistantMessageID,
|
||||||
|
timestamp: yield* DateTime.now,
|
||||||
|
finish: "stop",
|
||||||
|
cost: 0,
|
||||||
|
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.void,
|
||||||
|
awaitIdle: (id) => complete(id).pipe(Effect.exit, Effect.asVoid),
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
deps: [EventV2.node, SessionStore.node],
|
||||||
|
})
|
||||||
|
|
||||||
|
const layer = AppNodeBuilder.build(
|
||||||
|
LayerNode.bind(
|
||||||
|
LayerNode.group([
|
||||||
|
Database.node,
|
||||||
|
EventV2.node,
|
||||||
|
BackgroundJob.node,
|
||||||
|
ToolOutputStore.cleanupNode,
|
||||||
|
SessionV2.node,
|
||||||
|
ShellTool.node,
|
||||||
|
LocationServiceMap.node,
|
||||||
|
filesystem,
|
||||||
|
FSUtil.node,
|
||||||
|
Global.node,
|
||||||
|
]),
|
||||||
|
SessionExecution.node,
|
||||||
|
executionNode,
|
||||||
|
),
|
||||||
|
[LayerNode.replace(PermissionV2.layer, permission)],
|
||||||
|
)
|
||||||
|
|
||||||
|
const it = testEffect(layer)
|
||||||
|
|
||||||
const call = (input: typeof ShellTool.Input.Type, id = "call-shell") => ({
|
const call = (input: typeof ShellTool.Input.Type, id = "call-shell") => ({
|
||||||
sessionID,
|
sessionID,
|
||||||
@@ -100,20 +155,42 @@ const overflowCommand = (bytes: number) =>
|
|||||||
? `[Console]::Out.Write(('x' * ${bytes})); Start-Sleep -Milliseconds 100`
|
? `[Console]::Out.Write(('x' * ${bytes})); Start-Sleep -Milliseconds 100`
|
||||||
: `head -c ${bytes} /dev/zero | tr '\\0' 'x'`
|
: `head -c ${bytes} /dev/zero | tr '\\0' 'x'`
|
||||||
|
|
||||||
const it = testEffect(Layer.empty)
|
const withSession = <A, E, R>(
|
||||||
|
directory: string,
|
||||||
|
body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>,
|
||||||
|
) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const sessions = yield* SessionV2.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)
|
||||||
|
const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locationLayer))
|
||||||
|
return yield* body(registry).pipe(Effect.provide(locationLayer))
|
||||||
|
})
|
||||||
|
|
||||||
describe("ShellTool", () => {
|
describe("ShellTool", () => {
|
||||||
it.live("registers and returns real successful output from the active Location", () =>
|
it.live("registers and returns real successful output from the active Location", () =>
|
||||||
Effect.acquireUseRelease(
|
Effect.acquireUseRelease(
|
||||||
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
Effect.promise(() => tmpdir()),
|
||||||
([data, tmp]) => {
|
(tmp) => {
|
||||||
reset()
|
reset()
|
||||||
return withTool(data.path, tmp.path, (registry) =>
|
return withSession(tmp.path, (registry) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const definitions = yield* toolDefinitions(registry)
|
const definitions = yield* toolDefinitions(registry)
|
||||||
expect(definitions.map((tool) => tool.name)).toEqual(["shell"])
|
const shell = definitions.find((tool) => tool.name === "shell")
|
||||||
expect(definitions[0]?.outputSchema).not.toHaveProperty("properties.output")
|
expect(shell).toBeDefined()
|
||||||
expect(yield* toolDefinitions(registry, [{ action: "shell", resource: "*", effect: "deny" }])).toEqual([])
|
expect(shell?.outputSchema).not.toHaveProperty("properties.output")
|
||||||
|
expect(
|
||||||
|
(yield* toolDefinitions(registry, [{ action: "shell", resource: "*", effect: "deny" }])).map(
|
||||||
|
(tool) => tool.name,
|
||||||
|
),
|
||||||
|
).not.toContain("shell")
|
||||||
|
|
||||||
const settled = yield* settleTool(registry, call({ command: helloCommand }))
|
const settled = yield* settleTool(registry, call({ command: helloCommand }))
|
||||||
expect(settled.output?.structured).toMatchObject({ exit: 0, truncated: false })
|
expect(settled.output?.structured).toMatchObject({ exit: 0, truncated: false })
|
||||||
@@ -126,21 +203,18 @@ describe("ShellTool", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
([data, tmp]) =>
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
||||||
Effect.promise(() =>
|
|
||||||
Promise.all([data[Symbol.asyncDispose](), tmp[Symbol.asyncDispose]()]).then(() => undefined),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("resolves a relative workdir from the active Location", () =>
|
it.live("resolves a relative workdir from the active Location", () =>
|
||||||
Effect.acquireUseRelease(
|
Effect.acquireUseRelease(
|
||||||
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
Effect.promise(() => tmpdir()),
|
||||||
([data, tmp]) => {
|
(tmp) => {
|
||||||
reset()
|
reset()
|
||||||
return Effect.promise(() => fs.mkdir(path.join(tmp.path, "src"))).pipe(
|
return Effect.promise(() => fs.mkdir(path.join(tmp.path, "src"))).pipe(
|
||||||
Effect.andThen(
|
Effect.andThen(
|
||||||
withTool(data.path, tmp.path, (registry) =>
|
withSession(tmp.path, (registry) =>
|
||||||
settleTool(registry, call({ command: cwdCommand, workdir: "src" })),
|
settleTool(registry, call({ command: cwdCommand, workdir: "src" })),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -154,17 +228,14 @@ describe("ShellTool", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
([data, tmp]) =>
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
||||||
Effect.promise(() =>
|
|
||||||
Promise.all([data[Symbol.asyncDispose](), tmp[Symbol.asyncDispose]()]).then(() => undefined),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("rejects a workdir that stops being a directory during approval", () =>
|
it.live("rejects a workdir that stops being a directory during approval", () =>
|
||||||
Effect.acquireUseRelease(
|
Effect.acquireUseRelease(
|
||||||
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
Effect.promise(() => tmpdir()),
|
||||||
([data, tmp]) => {
|
(tmp) => {
|
||||||
reset()
|
reset()
|
||||||
const workdir = path.join(tmp.path, "src")
|
const workdir = path.join(tmp.path, "src")
|
||||||
afterPermission = (input) =>
|
afterPermission = (input) =>
|
||||||
@@ -176,26 +247,23 @@ describe("ShellTool", () => {
|
|||||||
: Effect.void
|
: Effect.void
|
||||||
return Effect.promise(() => fs.mkdir(workdir)).pipe(
|
return Effect.promise(() => fs.mkdir(workdir)).pipe(
|
||||||
Effect.andThen(
|
Effect.andThen(
|
||||||
withTool(data.path, tmp.path, (registry) =>
|
withSession(tmp.path, (registry) =>
|
||||||
executeTool(registry, call({ command: cwdCommand, workdir: "src" })),
|
executeTool(registry, call({ command: cwdCommand, workdir: "src" })),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Effect.andThen(Effect.sync(() => expect(assertions.map((input) => input.action)).toEqual(["shell"]))),
|
Effect.andThen(Effect.sync(() => expect(assertions.map((input) => input.action)).toEqual(["shell"]))),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
([data, tmp]) =>
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
||||||
Effect.promise(() =>
|
|
||||||
Promise.all([data[Symbol.asyncDispose](), tmp[Symbol.asyncDispose]()]).then(() => undefined),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("approves an explicit external workdir before shell execution", () =>
|
it.live("approves an explicit external workdir before shell execution", () =>
|
||||||
Effect.acquireUseRelease(
|
Effect.acquireUseRelease(
|
||||||
Effect.promise(() => Promise.all([tmpdir(), tmpdir(), tmpdir()])),
|
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
||||||
([data, active, outside]) => {
|
([active, outside]) => {
|
||||||
reset()
|
reset()
|
||||||
return withTool(data.path, active.path, (registry) =>
|
return withSession(active.path, (registry) =>
|
||||||
executeTool(registry, call({ command: cwdCommand, workdir: outside.path })),
|
executeTool(registry, call({ command: cwdCommand, workdir: outside.path })),
|
||||||
).pipe(
|
).pipe(
|
||||||
Effect.andThen(
|
Effect.andThen(
|
||||||
@@ -208,53 +276,45 @@ describe("ShellTool", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
([data, active, outside]) =>
|
([active, outside]) =>
|
||||||
Effect.promise(() =>
|
Effect.promise(() =>
|
||||||
Promise.all([
|
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
|
||||||
data[Symbol.asyncDispose](),
|
|
||||||
active[Symbol.asyncDispose](),
|
|
||||||
outside[Symbol.asyncDispose](),
|
|
||||||
]).then(() => undefined),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("does not execute after external-directory or shell denial", () =>
|
it.live("does not execute after external-directory or shell denial", () =>
|
||||||
Effect.acquireUseRelease(
|
Effect.acquireUseRelease(
|
||||||
Effect.promise(() => Promise.all([tmpdir(), tmpdir(), tmpdir()])),
|
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
||||||
([data, active, outside]) =>
|
([active, outside]) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
reset()
|
reset()
|
||||||
denyAction = "external_directory"
|
denyAction = "external_directory"
|
||||||
yield* withTool(data.path, active.path, (registry) =>
|
yield* withSession(active.path, (registry) =>
|
||||||
executeTool(registry, call({ command: cwdCommand, workdir: outside.path })),
|
executeTool(registry, call({ command: cwdCommand, workdir: outside.path })),
|
||||||
)
|
)
|
||||||
expect(assertions.map((item) => item.action)).toEqual(["external_directory"])
|
expect(assertions.map((item) => item.action)).toEqual(["external_directory"])
|
||||||
|
|
||||||
reset()
|
reset()
|
||||||
denyAction = "shell"
|
denyAction = "shell"
|
||||||
yield* withTool(data.path, active.path, (registry) => executeTool(registry, call({ command: cwdCommand })))
|
yield* withSession(active.path, (registry) => executeTool(registry, call({ command: cwdCommand })))
|
||||||
expect(assertions.map((item) => item.action)).toEqual(["shell"])
|
expect(assertions.map((item) => item.action)).toEqual(["shell"])
|
||||||
}),
|
}),
|
||||||
([data, active, outside]) =>
|
([active, outside]) =>
|
||||||
Effect.promise(() =>
|
Effect.promise(() =>
|
||||||
Promise.all([
|
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
|
||||||
data[Symbol.asyncDispose](),
|
|
||||||
active[Symbol.asyncDispose](),
|
|
||||||
outside[Symbol.asyncDispose](),
|
|
||||||
]).then(() => undefined),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("reports external command arguments as advisory warnings without enforcing approval", () =>
|
it.live("reports external command arguments as advisory warnings without enforcing approval", () =>
|
||||||
Effect.acquireUseRelease(
|
Effect.acquireUseRelease(
|
||||||
Effect.promise(() => Promise.all([tmpdir(), tmpdir(), tmpdir()])),
|
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
||||||
([data, active, outside]) => {
|
([active, outside]) => {
|
||||||
reset()
|
reset()
|
||||||
denyAction = "external_directory"
|
denyAction = "external_directory"
|
||||||
const target = path.join(outside.path, "secret.txt")
|
const target = path.join(outside.path, "secret.txt")
|
||||||
return withTool(data.path, active.path, (registry) =>
|
return withSession(active.path, (registry) =>
|
||||||
settleTool(registry, call({ command: `cat ${target}` })),
|
settleTool(registry, call({ command: `cat ${target}` })),
|
||||||
).pipe(
|
).pipe(
|
||||||
Effect.andThen((settled) =>
|
Effect.andThen((settled) =>
|
||||||
@@ -269,23 +329,19 @@ describe("ShellTool", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
([data, active, outside]) =>
|
([active, outside]) =>
|
||||||
Effect.promise(() =>
|
Effect.promise(() =>
|
||||||
Promise.all([
|
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
|
||||||
data[Symbol.asyncDispose](),
|
|
||||||
active[Symbol.asyncDispose](),
|
|
||||||
outside[Symbol.asyncDispose](),
|
|
||||||
]).then(() => undefined),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("keeps non-zero exits useful", () =>
|
it.live("keeps non-zero exits useful", () =>
|
||||||
Effect.acquireUseRelease(
|
Effect.acquireUseRelease(
|
||||||
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
Effect.promise(() => tmpdir()),
|
||||||
([data, tmp]) => {
|
(tmp) => {
|
||||||
reset()
|
reset()
|
||||||
return withTool(data.path, tmp.path, (registry) =>
|
return withSession(tmp.path, (registry) =>
|
||||||
settleTool(registry, call({ command: bodyExitCommand }, "call-nonzero")),
|
settleTool(registry, call({ command: bodyExitCommand }, "call-nonzero")),
|
||||||
).pipe(
|
).pipe(
|
||||||
Effect.andThen((settled) =>
|
Effect.andThen((settled) =>
|
||||||
@@ -300,20 +356,17 @@ describe("ShellTool", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
([data, tmp]) =>
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
||||||
Effect.promise(() =>
|
|
||||||
Promise.all([data[Symbol.asyncDispose](), tmp[Symbol.asyncDispose]()]).then(() => undefined),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("truncates the model view and points at the saved output file when output overflows", () =>
|
it.live("truncates the model view and points at the saved output file when output overflows", () =>
|
||||||
Effect.acquireUseRelease(
|
Effect.acquireUseRelease(
|
||||||
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
Effect.promise(() => tmpdir()),
|
||||||
([data, tmp]) => {
|
(tmp) => {
|
||||||
reset()
|
reset()
|
||||||
const bytes = ShellTool.MAX_CAPTURE_BYTES + 1024
|
const bytes = ShellTool.MAX_CAPTURE_BYTES + 1024
|
||||||
return withTool(data.path, tmp.path, (registry) =>
|
return withSession(tmp.path, (registry) =>
|
||||||
settleTool(registry, call({ command: overflowCommand(bytes) }, "call-overflow")),
|
settleTool(registry, call({ command: overflowCommand(bytes) }, "call-overflow")),
|
||||||
).pipe(
|
).pipe(
|
||||||
Effect.andThen((settled) =>
|
Effect.andThen((settled) =>
|
||||||
@@ -327,19 +380,16 @@ describe("ShellTool", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
([data, tmp]) =>
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
||||||
Effect.promise(() =>
|
|
||||||
Promise.all([data[Symbol.asyncDispose](), tmp[Symbol.asyncDispose]()]).then(() => undefined),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("returns a useful timeout settlement", () =>
|
it.live("returns a useful timeout settlement", () =>
|
||||||
Effect.acquireUseRelease(
|
Effect.acquireUseRelease(
|
||||||
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
Effect.promise(() => tmpdir()),
|
||||||
([data, tmp]) => {
|
(tmp) => {
|
||||||
reset()
|
reset()
|
||||||
return withTool(data.path, tmp.path, (registry) =>
|
return withSession(tmp.path, (registry) =>
|
||||||
settleTool(registry, call({ command: idleCommand, timeout: 50 })),
|
settleTool(registry, call({ command: idleCommand, timeout: 50 })),
|
||||||
).pipe(
|
).pipe(
|
||||||
Effect.andThen((settled) =>
|
Effect.andThen((settled) =>
|
||||||
@@ -353,10 +403,7 @@ describe("ShellTool", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
([data, tmp]) =>
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
||||||
Effect.promise(() =>
|
|
||||||
Promise.all([data[Symbol.asyncDispose](), tmp[Symbol.asyncDispose]()]).then(() => undefined),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { SessionExecution } from "@opencode-ai/core/session/execution"
|
|||||||
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||||
import { SessionExecutionLocal } from "@opencode-ai/core/session/execution/local"
|
import { SessionExecutionLocal } from "@opencode-ai/core/session/execution/local"
|
||||||
import { SubagentTool } from "@opencode-ai/core/tool/subagent"
|
import { SubagentTool } from "@opencode-ai/core/tool/subagent"
|
||||||
|
import { ShellTool } from "@opencode-ai/core/tool/shell"
|
||||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||||
import { HttpRouter, HttpServer } from "effect/unstable/http"
|
import { HttpRouter, HttpServer } from "effect/unstable/http"
|
||||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||||
@@ -31,6 +32,7 @@ const applicationServices = LayerNode.group([
|
|||||||
ToolOutputStore.cleanupNode,
|
ToolOutputStore.cleanupNode,
|
||||||
SessionV2.node,
|
SessionV2.node,
|
||||||
SubagentTool.node,
|
SubagentTool.node,
|
||||||
|
ShellTool.node,
|
||||||
PermissionSaved.node,
|
PermissionSaved.node,
|
||||||
PtyTicket.node,
|
PtyTicket.node,
|
||||||
Credential.node,
|
Credential.node,
|
||||||
|
|||||||
Reference in New Issue
Block a user