From 5ae93092aaaa843cf4d361fbdcda4a63cfb5a541 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 29 Jun 2026 00:43:04 -0400 Subject: [PATCH] refactor(core): replace bash tool with shell tool --- .../client/src/generated-effect/client.ts | 165 ++++-- packages/client/src/generated/client.ts | 78 +++ packages/client/src/generated/types.ts | 158 ++++++ packages/core/src/location-services.ts | 2 + packages/core/src/plugin/agent.ts | 9 +- packages/core/src/pty.ts | 6 +- packages/core/src/shell.ts | 468 ++++++++++-------- packages/core/src/shell/select.ts | 226 +++++++++ packages/core/src/tool/builtins.ts | 10 +- packages/core/src/tool/{bash.ts => shell.ts} | 69 +-- packages/core/src/v1/config/migrate.ts | 9 +- packages/core/test/config/config.test.ts | 23 +- packages/core/test/shell.test.ts | 66 +-- packages/core/test/tool-bash.test.ts | 432 ---------------- packages/core/test/tool-shell.test.ts | 366 ++++++++++++++ .../routes/instance/httpapi/handlers/pty.ts | 4 +- packages/opencode/src/session/prompt.ts | 8 +- packages/opencode/src/tool/shell.ts | 14 +- packages/opencode/test/session/prompt.test.ts | 6 +- packages/opencode/test/tool/shell.test.ts | 28 +- packages/protocol/src/api.ts | 2 + packages/protocol/src/errors.ts | 9 + packages/protocol/src/groups/shell.ts | 89 ++++ packages/schema/src/event-manifest.ts | 2 + packages/schema/src/index.ts | 1 + packages/schema/src/shell.ts | 79 +++ packages/sdk/js/src/v2/gen/sdk.gen.ts | 188 +++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 385 ++++++++++++++ packages/server/src/handlers.ts | 2 + packages/server/src/handlers/shell.ts | 69 +++ packages/tui/src/component/prompt/index.tsx | 40 +- packages/tui/src/context/data.tsx | 35 ++ .../src/feature-plugins/home/tips-view.tsx | 6 +- packages/tui/src/routes/session/index.tsx | 19 +- .../tui/src/routes/session/permission.tsx | 13 +- .../inline-tool-wrap-snapshot.test.tsx.snap | 65 --- .../tui/inline-tool-wrap-snapshot.test.tsx | 10 +- 37 files changed, 2260 insertions(+), 901 deletions(-) create mode 100644 packages/core/src/shell/select.ts rename packages/core/src/tool/{bash.ts => shell.ts} (77%) delete mode 100644 packages/core/test/tool-bash.test.ts create mode 100644 packages/core/test/tool-shell.test.ts create mode 100644 packages/protocol/src/groups/shell.ts create mode 100644 packages/schema/src/shell.ts create mode 100644 packages/server/src/handlers/shell.ts diff --git a/packages/client/src/generated-effect/client.ts b/packages/client/src/generated-effect/client.ts index 1484e03466e..bd1ab7845c5 100644 --- a/packages/client/src/generated-effect/client.ts +++ b/packages/client/src/generated-effect/client.ts @@ -614,99 +614,159 @@ const adaptGroup15 = (raw: RawClient["server.pty"]) => ({ remove: Endpoint15_4(raw), }) -type Endpoint16_0Request = Parameters[0] +type Endpoint16_0Request = Parameters[0] type Endpoint16_0Input = { readonly location?: Endpoint16_0Request["query"]["location"] } -const Endpoint16_0 = (raw: RawClient["server.question"]) => (input?: Endpoint16_0Input) => +const Endpoint16_0 = (raw: RawClient["server.shell"]) => (input?: Endpoint16_0Input) => + raw["shell.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint16_1Request = Parameters[0] +type Endpoint16_1Input = { + readonly location?: Endpoint16_1Request["query"]["location"] + readonly command: Endpoint16_1Request["payload"]["command"] + readonly cwd?: Endpoint16_1Request["payload"]["cwd"] + readonly timeout?: Endpoint16_1Request["payload"]["timeout"] + readonly metadata?: Endpoint16_1Request["payload"]["metadata"] +} +const Endpoint16_1 = (raw: RawClient["server.shell"]) => (input: Endpoint16_1Input) => + raw["shell.create"]({ + query: { location: input["location"] }, + payload: { command: input["command"], cwd: input["cwd"], timeout: input["timeout"], metadata: input["metadata"] }, + }).pipe(Effect.mapError(mapClientError)) + +type Endpoint16_2Request = Parameters[0] +type Endpoint16_2Input = { + readonly id: Endpoint16_2Request["params"]["id"] + readonly location?: Endpoint16_2Request["query"]["location"] +} +const Endpoint16_2 = (raw: RawClient["server.shell"]) => (input: Endpoint16_2Input) => + raw["shell.get"]({ params: { id: input["id"] }, query: { location: input["location"] } }).pipe( + Effect.mapError(mapClientError), + ) + +type Endpoint16_3Request = Parameters[0] +type Endpoint16_3Input = { + readonly id: Endpoint16_3Request["params"]["id"] + readonly location?: Endpoint16_3Request["query"]["location"] + readonly cursor?: Endpoint16_3Request["query"]["cursor"] + readonly limit?: Endpoint16_3Request["query"]["limit"] +} +const Endpoint16_3 = (raw: RawClient["server.shell"]) => (input: Endpoint16_3Input) => + raw["shell.output"]({ + params: { id: input["id"] }, + query: { location: input["location"], cursor: input["cursor"], limit: input["limit"] }, + }).pipe(Effect.mapError(mapClientError)) + +type Endpoint16_4Request = Parameters[0] +type Endpoint16_4Input = { + readonly id: Endpoint16_4Request["params"]["id"] + readonly location?: Endpoint16_4Request["query"]["location"] +} +const Endpoint16_4 = (raw: RawClient["server.shell"]) => (input: Endpoint16_4Input) => + raw["shell.remove"]({ params: { id: input["id"] }, query: { location: input["location"] } }).pipe( + Effect.mapError(mapClientError), + ) + +const adaptGroup16 = (raw: RawClient["server.shell"]) => ({ + list: Endpoint16_0(raw), + create: Endpoint16_1(raw), + get: Endpoint16_2(raw), + output: Endpoint16_3(raw), + remove: Endpoint16_4(raw), +}) + +type Endpoint17_0Request = Parameters[0] +type Endpoint17_0Input = { readonly location?: Endpoint17_0Request["query"]["location"] } +const Endpoint17_0 = (raw: RawClient["server.question"]) => (input?: Endpoint17_0Input) => raw["question.request.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint16_1Request = Parameters[0] -type Endpoint16_1Input = { readonly sessionID: Endpoint16_1Request["params"]["sessionID"] } -const Endpoint16_1 = (raw: RawClient["server.question"]) => (input: Endpoint16_1Input) => +type Endpoint17_1Request = Parameters[0] +type Endpoint17_1Input = { readonly sessionID: Endpoint17_1Request["params"]["sessionID"] } +const Endpoint17_1 = (raw: RawClient["server.question"]) => (input: Endpoint17_1Input) => raw["session.question.list"]({ params: { sessionID: input["sessionID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ) -type Endpoint16_2Request = Parameters[0] -type Endpoint16_2Input = { - readonly sessionID: Endpoint16_2Request["params"]["sessionID"] - readonly requestID: Endpoint16_2Request["params"]["requestID"] - readonly answers: Endpoint16_2Request["payload"]["answers"] +type Endpoint17_2Request = Parameters[0] +type Endpoint17_2Input = { + readonly sessionID: Endpoint17_2Request["params"]["sessionID"] + readonly requestID: Endpoint17_2Request["params"]["requestID"] + readonly answers: Endpoint17_2Request["payload"]["answers"] } -const Endpoint16_2 = (raw: RawClient["server.question"]) => (input: Endpoint16_2Input) => +const Endpoint17_2 = (raw: RawClient["server.question"]) => (input: Endpoint17_2Input) => raw["session.question.reply"]({ params: { sessionID: input["sessionID"], requestID: input["requestID"] }, payload: { answers: input["answers"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint16_3Request = Parameters[0] -type Endpoint16_3Input = { - readonly sessionID: Endpoint16_3Request["params"]["sessionID"] - readonly requestID: Endpoint16_3Request["params"]["requestID"] +type Endpoint17_3Request = Parameters[0] +type Endpoint17_3Input = { + readonly sessionID: Endpoint17_3Request["params"]["sessionID"] + readonly requestID: Endpoint17_3Request["params"]["requestID"] } -const Endpoint16_3 = (raw: RawClient["server.question"]) => (input: Endpoint16_3Input) => +const Endpoint17_3 = (raw: RawClient["server.question"]) => (input: Endpoint17_3Input) => raw["session.question.reject"]({ params: { sessionID: input["sessionID"], requestID: input["requestID"] } }).pipe( Effect.mapError(mapClientError), ) -const adaptGroup16 = (raw: RawClient["server.question"]) => ({ - listRequests: Endpoint16_0(raw), - list: Endpoint16_1(raw), - reply: Endpoint16_2(raw), - reject: Endpoint16_3(raw), +const adaptGroup17 = (raw: RawClient["server.question"]) => ({ + listRequests: Endpoint17_0(raw), + list: Endpoint17_1(raw), + reply: Endpoint17_2(raw), + reject: Endpoint17_3(raw), }) -type Endpoint17_0Request = Parameters[0] -type Endpoint17_0Input = { readonly location?: Endpoint17_0Request["query"]["location"] } -const Endpoint17_0 = (raw: RawClient["server.reference"]) => (input?: Endpoint17_0Input) => +type Endpoint18_0Request = Parameters[0] +type Endpoint18_0Input = { readonly location?: Endpoint18_0Request["query"]["location"] } +const Endpoint18_0 = (raw: RawClient["server.reference"]) => (input?: Endpoint18_0Input) => raw["reference.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) -const adaptGroup17 = (raw: RawClient["server.reference"]) => ({ list: Endpoint17_0(raw) }) +const adaptGroup18 = (raw: RawClient["server.reference"]) => ({ list: Endpoint18_0(raw) }) -type Endpoint18_0Request = Parameters[0] -type Endpoint18_0Input = { - readonly projectID: Endpoint18_0Request["params"]["projectID"] - readonly location?: Endpoint18_0Request["query"]["location"] - readonly strategy: Endpoint18_0Request["payload"]["strategy"] - readonly directory: Endpoint18_0Request["payload"]["directory"] - readonly name?: Endpoint18_0Request["payload"]["name"] +type Endpoint19_0Request = Parameters[0] +type Endpoint19_0Input = { + readonly projectID: Endpoint19_0Request["params"]["projectID"] + readonly location?: Endpoint19_0Request["query"]["location"] + readonly strategy: Endpoint19_0Request["payload"]["strategy"] + readonly directory: Endpoint19_0Request["payload"]["directory"] + readonly name?: Endpoint19_0Request["payload"]["name"] } -const Endpoint18_0 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint18_0Input) => +const Endpoint19_0 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint19_0Input) => raw["projectCopy.create"]({ params: { projectID: input["projectID"] }, query: { location: input["location"] }, payload: { strategy: input["strategy"], directory: input["directory"], name: input["name"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint18_1Request = Parameters[0] -type Endpoint18_1Input = { - readonly projectID: Endpoint18_1Request["params"]["projectID"] - readonly location?: Endpoint18_1Request["query"]["location"] - readonly directory: Endpoint18_1Request["payload"]["directory"] - readonly force: Endpoint18_1Request["payload"]["force"] +type Endpoint19_1Request = Parameters[0] +type Endpoint19_1Input = { + readonly projectID: Endpoint19_1Request["params"]["projectID"] + readonly location?: Endpoint19_1Request["query"]["location"] + readonly directory: Endpoint19_1Request["payload"]["directory"] + readonly force: Endpoint19_1Request["payload"]["force"] } -const Endpoint18_1 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint18_1Input) => +const Endpoint19_1 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint19_1Input) => raw["projectCopy.remove"]({ params: { projectID: input["projectID"] }, query: { location: input["location"] }, payload: { directory: input["directory"], force: input["force"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint18_2Request = Parameters[0] -type Endpoint18_2Input = { - readonly projectID: Endpoint18_2Request["params"]["projectID"] - readonly location?: Endpoint18_2Request["query"]["location"] +type Endpoint19_2Request = Parameters[0] +type Endpoint19_2Input = { + readonly projectID: Endpoint19_2Request["params"]["projectID"] + readonly location?: Endpoint19_2Request["query"]["location"] } -const Endpoint18_2 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint18_2Input) => +const Endpoint19_2 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint19_2Input) => raw["projectCopy.refresh"]({ params: { projectID: input["projectID"] }, query: { location: input["location"] }, }).pipe(Effect.mapError(mapClientError)) -const adaptGroup18 = (raw: RawClient["server.projectCopy"]) => ({ - create: Endpoint18_0(raw), - remove: Endpoint18_1(raw), - refresh: Endpoint18_2(raw), +const adaptGroup19 = (raw: RawClient["server.projectCopy"]) => ({ + create: Endpoint19_0(raw), + remove: Endpoint19_1(raw), + refresh: Endpoint19_2(raw), }) const adaptClient = (raw: RawClient) => ({ @@ -726,9 +786,10 @@ const adaptClient = (raw: RawClient) => ({ skills: adaptGroup13(raw["server.skill"]), events: adaptGroup14(raw["server.event"]), ptys: adaptGroup15(raw["server.pty"]), - questions: adaptGroup16(raw["server.question"]), - references: adaptGroup17(raw["server.reference"]), - projectCopies: adaptGroup18(raw["server.projectCopy"]), + "server.shell": adaptGroup16(raw["server.shell"]), + questions: adaptGroup17(raw["server.question"]), + references: adaptGroup18(raw["server.reference"]), + projectCopies: adaptGroup19(raw["server.projectCopy"]), }) export const make = (options?: { readonly baseUrl?: URL | string }) => diff --git a/packages/client/src/generated/client.ts b/packages/client/src/generated/client.ts index 245b7fbe74e..56802f8c6c3 100644 --- a/packages/client/src/generated/client.ts +++ b/packages/client/src/generated/client.ts @@ -100,6 +100,16 @@ import type { PtysUpdateOutput, PtysRemoveInput, PtysRemoveOutput, + ServerShellListInput, + ServerShellListOutput, + ServerShellCreateInput, + ServerShellCreateOutput, + ServerShellGetInput, + ServerShellGetOutput, + ServerShellOutputInput, + ServerShellOutputOutput, + ServerShellRemoveInput, + ServerShellRemoveOutput, QuestionsListRequestsInput, QuestionsListRequestsOutput, QuestionsListInput, @@ -916,6 +926,74 @@ export function make(options: ClientOptions) { requestOptions, ), }, + "server.shell": { + list: (input?: ServerShellListInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/shell`, + query: { location: input?.["location"] }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ), + create: (input: ServerShellCreateInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/shell`, + query: { location: input["location"] }, + body: { + command: input["command"], + cwd: input["cwd"], + timeout: input["timeout"], + metadata: input["metadata"], + }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ), + get: (input: ServerShellGetInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/shell/${encodeURIComponent(input.id)}`, + query: { location: input["location"] }, + successStatus: 200, + declaredStatuses: [404, 401, 400], + empty: false, + }, + requestOptions, + ), + output: (input: ServerShellOutputInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/shell/${encodeURIComponent(input.id)}/output`, + query: { location: input["location"], cursor: input["cursor"], limit: input["limit"] }, + successStatus: 200, + declaredStatuses: [404, 401, 400], + empty: false, + }, + requestOptions, + ), + remove: (input: ServerShellRemoveInput, requestOptions?: RequestOptions) => + request( + { + method: "DELETE", + path: `/api/shell/${encodeURIComponent(input.id)}`, + query: { location: input["location"] }, + successStatus: 204, + declaredStatuses: [404, 401, 400], + empty: true, + }, + requestOptions, + ), + }, questions: { listRequests: (input?: QuestionsListRequestsInput, requestOptions?: RequestOptions) => request( diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts index 91ac8d631ee..cffc6fac5c5 100644 --- a/packages/client/src/generated/types.ts +++ b/packages/client/src/generated/types.ts @@ -94,6 +94,10 @@ export type PtyNotFoundError = { readonly _tag: "PtyNotFoundError"; readonly pty export const isPtyNotFoundError = (value: unknown): value is PtyNotFoundError => typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "PtyNotFoundError" +export type ShellNotFoundError = { readonly _tag: "ShellNotFoundError"; readonly id: string; readonly message: string } +export const isShellNotFoundError = (value: unknown): value is ShellNotFoundError => + typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ShellNotFoundError" + export type QuestionNotFoundError = { readonly _tag: "QuestionNotFoundError" readonly requestID: string @@ -2734,6 +2738,160 @@ export type PtysRemoveInput = { export type PtysRemoveOutput = void +export type ServerShellListInput = { + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] +} + +export type ServerShellListOutput = { + readonly location: { + readonly directory: string + readonly workspaceID?: string + readonly project: { readonly id: string; readonly directory: string } + } + readonly data: ReadonlyArray<{ + readonly id: string + readonly status: "running" | "exited" | "timeout" | "killed" + readonly command: string + readonly cwd: string + readonly shell: string + readonly file: string + readonly pid?: number + readonly exit?: number | "Infinity" | "-Infinity" | "NaN" + readonly metadata: { readonly [x: string]: JsonValue } + readonly time: { + readonly started: number | "Infinity" | "-Infinity" | "NaN" + readonly completed?: number | "Infinity" | "-Infinity" | "NaN" + } + }> +} + +export type ServerShellCreateInput = { + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] + readonly command: { + readonly command: string + readonly cwd?: string + readonly timeout?: number + readonly metadata?: { readonly [x: string]: JsonValue } + }["command"] + readonly cwd?: { + readonly command: string + readonly cwd?: string + readonly timeout?: number + readonly metadata?: { readonly [x: string]: JsonValue } + }["cwd"] + readonly timeout?: { + readonly command: string + readonly cwd?: string + readonly timeout?: number + readonly metadata?: { readonly [x: string]: JsonValue } + }["timeout"] + readonly metadata?: { + readonly command: string + readonly cwd?: string + readonly timeout?: number + readonly metadata?: { readonly [x: string]: JsonValue } + }["metadata"] +} + +export type ServerShellCreateOutput = { + readonly location: { + readonly directory: string + readonly workspaceID?: string + readonly project: { readonly id: string; readonly directory: string } + } + readonly data: { + readonly id: string + readonly status: "running" | "exited" | "timeout" | "killed" + readonly command: string + readonly cwd: string + readonly shell: string + readonly file: string + readonly pid?: number + readonly exit?: number | "Infinity" | "-Infinity" | "NaN" + readonly metadata: { readonly [x: string]: JsonValue } + readonly time: { + readonly started: number | "Infinity" | "-Infinity" | "NaN" + readonly completed?: number | "Infinity" | "-Infinity" | "NaN" + } + } +} + +export type ServerShellGetInput = { + readonly id: { readonly id: string }["id"] + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] +} + +export type ServerShellGetOutput = { + readonly location: { + readonly directory: string + readonly workspaceID?: string + readonly project: { readonly id: string; readonly directory: string } + } + readonly data: { + readonly id: string + readonly status: "running" | "exited" | "timeout" | "killed" + readonly command: string + readonly cwd: string + readonly shell: string + readonly file: string + readonly pid?: number + readonly exit?: number | "Infinity" | "-Infinity" | "NaN" + readonly metadata: { readonly [x: string]: JsonValue } + readonly time: { + readonly started: number | "Infinity" | "-Infinity" | "NaN" + readonly completed?: number | "Infinity" | "-Infinity" | "NaN" + } + } +} + +export type ServerShellOutputInput = { + readonly id: { readonly id: string }["id"] + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + readonly cursor?: number | undefined + readonly limit?: number | undefined + }["location"] + readonly cursor?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + readonly cursor?: number | undefined + readonly limit?: number | undefined + }["cursor"] + readonly limit?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + readonly cursor?: number | undefined + readonly limit?: number | undefined + }["limit"] +} + +export type ServerShellOutputOutput = { + readonly location: { + readonly directory: string + readonly workspaceID?: string + readonly project: { readonly id: string; readonly directory: string } + } + readonly data: { + readonly output: string + readonly cursor: number + readonly size: number + readonly truncated: boolean + } +} + +export type ServerShellRemoveInput = { + readonly id: { readonly id: string }["id"] + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] +} + +export type ServerShellRemoveOutput = void + export type QuestionsListRequestsInput = { readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts index da9ebc9680f..a0ae0228c7f 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -23,6 +23,7 @@ import { Policy } from "./policy" import { ProjectCopy } from "./project/copy" import { Pty } from "./pty" import { QuestionV2 } from "./question" +import { Shell } from "./shell" import { Reference } from "./reference" import { ReferenceGuidance } from "./reference/guidance" import * as SessionRunnerLLM from "./session/runner/llm" @@ -59,6 +60,7 @@ export const locationServices = LayerNode.group([ FileSystem.node, Watcher.node, Pty.node, + Shell.node, SkillV2.node, SystemContextRegistry.node, SystemContextBuiltIns.node, diff --git a/packages/core/src/plugin/agent.ts b/packages/core/src/plugin/agent.ts index 287e3d43531..4d4b0353ed6 100644 --- a/packages/core/src/plugin/agent.ts +++ b/packages/core/src/plugin/agent.ts @@ -8,7 +8,12 @@ import { Global } from "../global" import { Location } from "../location" import { PermissionV2 } from "../permission" -const TRUNCATION_GLOB = path.join(Global.Path.data, "tool-output", "*") +// Combined output files written by the Shell service, e.g. `/shell//.out`. +// Whitelisted so agents can read a command's full captured output without an external-directory prompt. +const SHELL_OUTPUT_GLOB = path.join(Global.Path.data, "shell", "*", "*") +const BUILD_SYSTEM = + "You are an AI coding agent. Help the user accomplish software engineering tasks by inspecting the workspace, making targeted changes, and using tools according to the configured permissions." + const PROMPT_EXPLORE = `You are a file search specialist. You excel at thoroughly navigating and exploring codebases. Your strengths: @@ -99,7 +104,7 @@ export const Plugin = define({ effect: Effect.fn(function* (ctx) { const location = yield* Location.Service const worktree = location.directory - const whitelistedDirs = [TRUNCATION_GLOB, path.join(Global.Path.tmp, "*")] + const whitelistedDirs = [SHELL_OUTPUT_GLOB, path.join(Global.Path.tmp, "*")] const readonlyExternalDirectory: PermissionV2.Ruleset = [ { action: "external_directory", resource: "*", effect: "ask" }, ...whitelistedDirs.map( diff --git a/packages/core/src/pty.ts b/packages/core/src/pty.ts index 828161b427e..64cc99d16da 100644 --- a/packages/core/src/pty.ts +++ b/packages/core/src/pty.ts @@ -8,7 +8,7 @@ import { Config } from "./config" import { EventV2 } from "./event" import { Location } from "./location" import { PtyID } from "./pty/schema" -import { Shell } from "./shell" +import { ShellSelect } from "./shell/select" import { lazy } from "./util/lazy" const BUFFER_LIMIT = 1024 * 1024 * 2 @@ -164,8 +164,8 @@ export const layer = Layer.effect( const create = Effect.fn("Pty.create")(function* (input: CreateInput) { const id = PtyID.ascending() - const command = input.command || Shell.preferred(Config.latest(yield* config.entries(), "shell")) - const args = Shell.login(command) ? [...(input.args ?? []), "-l"] : [...(input.args ?? [])] + const command = input.command || ShellSelect.preferred(Config.latest(yield* config.entries(), "shell")) + const args = ShellSelect.login(command) ? [...(input.args ?? []), "-l"] : [...(input.args ?? [])] const cwd = input.cwd || location.directory const env = { ...process.env, diff --git a/packages/core/src/shell.ts b/packages/core/src/shell.ts index 29089106d90..1340b345e44 100644 --- a/packages/core/src/shell.ts +++ b/packages/core/src/shell.ts @@ -1,226 +1,300 @@ export * as Shell from "./shell" import path from "path" -import { spawn, type ChildProcess } from "child_process" -import { readFile } from "fs/promises" -import { statSync } from "fs" -import { setTimeout as sleep } from "node:timers/promises" -import { Flag } from "./flag/flag" -import { FSUtil } from "./fs-util" -import { which } from "./util/which" +import { Context, Deferred, Duration, Effect, Fiber, Layer, Schema, Stream } from "effect" +import { ChildProcess } from "effect/unstable/process" +import { produce } from "immer" +import { Shell } from "@opencode-ai/schema/shell" +import { makeLocationNode } from "./effect/app-node" +import { AppProcess } from "./process" +import { Config } from "./config" +import { EventV2 } from "./event" +import { Location } from "./location" +import { Global } from "./global" +import { ShellSelect } from "./shell/select" -const SIGKILL_TIMEOUT_MS = 200 -const META: Record = { - bash: { login: true, posix: true }, - dash: { login: true, posix: true }, - fish: { deny: true, login: true }, - ksh: { login: true, posix: true }, - nu: { deny: true }, - powershell: { ps: true }, - pwsh: { ps: true }, - sh: { login: true, posix: true }, - zsh: { login: true, posix: true }, +export class NotFoundError extends Schema.TaggedErrorClass()("Shell.NotFoundError", { + id: Shell.ID, +}) {} + +// Exited processes stay observable (status, exit code, retained output) until removed explicitly. +// Cap retention so abandoned commands do not accumulate unbounded state and output files. +const EXITED_LIMIT = 25 + +type Info = Shell.Info + +type Active = { + // Immutable snapshot; lifecycle updates replace it via immer `produce`. + info: Info + file: string + size: number + // Resolves with the terminal Info once the command exits, times out, or is killed. A wait + // started after termination resolves immediately from the already-completed deferred. + done: Deferred.Deferred + timeoutFiber?: Fiber.Fiber } -export type Item = { - path: string - name: string - acceptable: boolean +/** + * Location-owned non-interactive shell command process service. + * + * Each `create` spawns one shell command, captures combined stdout/stderr to a + * file, and returns an ID. Clients poll `get` for status and `output` for + * file-backed output by cursor. No session, message, or permission state lives + * here; callers (e.g. `ShellTool`) own that association and store the shell ID. + */ +export interface Interface { + readonly create: (input: Shell.CreateInput) => Effect.Effect + // Currently running commands only; exited shells are retained for get/output but excluded here. + readonly list: () => Effect.Effect + readonly get: (id: Shell.ID) => Effect.Effect + // Resolves once the command reaches a terminal status, returning its final Info. Fails with + // NotFoundError if the command is unknown or is removed before it terminates. + readonly wait: (id: Shell.ID) => Effect.Effect + readonly output: (id: Shell.ID, input?: Shell.OutputInput) => Effect.Effect + readonly remove: (id: Shell.ID) => Effect.Effect } -export async function killTree(proc: ChildProcess, opts?: { exited?: () => boolean }): Promise { - const pid = proc.pid - if (!pid || opts?.exited?.()) return +export class Service extends Context.Service()("@opencode/v2/Shell") {} - if (process.platform === "win32") { - await new Promise((resolve) => { - const killer = spawn("taskkill", ["/pid", String(pid), "/f", "/t"], { - stdio: "ignore", - windowsHide: true, - }) - killer.once("exit", () => resolve()) - killer.once("error", () => resolve()) +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const events = yield* EventV2.Service + const location = yield* Location.Service + const config = yield* Config.Service + const global = yield* Global.Service + const appProcess = yield* AppProcess.Service + const context = yield* Effect.context() + const runFork = Effect.runForkWith(context) + const sessions = new Map() + const exitOrder: string[] = [] + + const outputDir = path.join(global.data, "shell", location.project.id) + const { mkdir, unlink } = yield* Effect.promise(() => import("fs/promises")) + const { createWriteStream, createReadStream } = yield* Effect.promise(() => import("fs")) + yield* Effect.promise(() => mkdir(outputDir, { recursive: true })) + + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + for (const session of sessions.values()) { + if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber) + // Unblock waiters still pending at teardown; succeed is a no-op once already resolved. + yield* Deferred.fail(session.done, new NotFoundError({ id: Shell.ID.make(session.info.id) })) + } + sessions.clear() + exitOrder.length = 0 + }), + ) + + const require = Effect.fn("Shell.require")(function* (id: Shell.ID) { + const session = sessions.get(id) + if (!session) return yield* new NotFoundError({ id }) + return session }) - return - } - try { - process.kill(-pid, "SIGTERM") - await sleep(SIGKILL_TIMEOUT_MS) - if (!opts?.exited?.()) { - process.kill(-pid, "SIGKILL") - } - } catch { - proc.kill("SIGTERM") - await sleep(SIGKILL_TIMEOUT_MS) - if (!opts?.exited?.()) { - proc.kill("SIGKILL") - } - } -} + const removeSession = Effect.fnUntraced(function* (id: Shell.ID) { + const session = sessions.get(id) + if (!session) return + sessions.delete(id) + const index = exitOrder.indexOf(id) + if (index !== -1) exitOrder.splice(index, 1) + if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber) + // Unblock any wait still pending when the command is removed before it terminated. + yield* Deferred.fail(session.done, new NotFoundError({ id })) + yield* Effect.promise(() => unlink(session.file).catch(() => {})) + yield* events.publish(Shell.Event.Deleted, { id }) + }) -function stat(file: string) { - return statSync(file, { throwIfNoEntry: false }) ?? undefined -} + const remove = Effect.fn("Shell.remove")(function* (id: Shell.ID) { + yield* require(id) + yield* removeSession(id) + }) -function full(file: string) { - if (process.platform !== "win32") return file - const shell = FSUtil.windowsPath(file) - if (path.win32.dirname(shell) !== ".") { - if (shell.startsWith("/") && name(shell) === "bash") return gitbash() || shell - return shell - } - if (name(shell) === "bash") return gitbash() || which(shell) || shell - return which(shell) || shell -} + const list = Effect.fn("Shell.list")(function* () { + return Array.from(sessions.values()) + .filter((session) => session.info.status === "running") + .map((session) => session.info) + }) -function meta(file: string) { - return META[name(file)] -} + const get = Effect.fn("Shell.get")(function* (id: Shell.ID) { + return (yield* require(id)).info + }) -function ok(file: string) { - return meta(file)?.deny !== true -} + const wait = Effect.fn("Shell.wait")(function* (id: Shell.ID) { + return yield* Deferred.await((yield* require(id)).done) + }) -function rooted(file: string) { - return path.isAbsolute(FSUtil.windowsPath(file)) -} + const output = Effect.fn("Shell.output")(function* (id: Shell.ID, input?: Shell.OutputInput) { + const session = yield* require(id) + const cursor = input?.cursor ?? 0 + const limit = input?.limit ?? 65536 + if (cursor >= session.size) return { output: "", cursor: session.size, size: session.size, truncated: false } + const start = Math.max(0, cursor) + const length = Math.min(limit, session.size - start) + const buffer = Buffer.alloc(length) + const bytesRead = yield* Effect.promise( + () => + new Promise((resolve) => { + const stream = createReadStream(session.file, { start, end: start + length - 1 }) + let offset = 0 + stream.on("data", (chunk: string | Buffer) => { + const bytes = Buffer.from(chunk) + bytes.copy(buffer, offset) + offset += bytes.length + }) + stream.on("end", () => resolve(offset)) + stream.on("error", () => resolve(0)) + }), + ) + return { + output: buffer.subarray(0, bytesRead).toString("utf8"), + cursor: start + bytesRead, + size: session.size, + truncated: false, + } + }) -function resolve(file: string) { - const shell = full(file) - if (rooted(shell)) { - if (stat(shell)?.isFile()) return shell - return - } - return which(shell) ?? undefined -} + const create = Effect.fn("Shell.create")(function* (input: Shell.CreateInput) { + const id = Shell.ID.ascending() + const cwd = input.cwd ?? location.directory + const configShell = Config.latest(yield* config.entries(), "shell") + const shell = ShellSelect.preferred(configShell) + const args = ShellSelect.args(shell, input.command, cwd) + const file = path.join(outputDir, `${id}.out`) + const env = { + ...process.env, + TERM: "xterm-256color", + OPENCODE_TERMINAL: "1", + } as Record -function win() { - return Array.from( - new Set( - [which("pwsh"), which("powershell"), gitbash(), process.env.COMSPEC || "cmd.exe"] - .filter((item): item is string => Boolean(item)) - .map(full), - ), - ) -} + const info: Info = { + id, + status: "running", + command: input.command, + cwd, + shell, + file, + metadata: input.metadata ?? {}, + time: { started: Date.now() }, + } -async function unix() { - const text = await readFile("/etc/shells", "utf8").catch(() => "") - if (text) return Array.from(new Set(text.split("\n").filter((line) => line.trim() && !line.startsWith("#")))) - return ["/bin/bash", "/bin/zsh", "/bin/sh"] -} + // Spawn via AppProcess and stream combined output to the file. The handle is scope-bound, so + // the managing fiber keeps its scope open until the command terminates (it awaits `done` at the + // end). `create` returns once `ready` resolves with the registered session. + const ready = Deferred.makeUnsafe() + runFork( + Effect.scoped( + Effect.gen(function* () { + const handle = yield* appProcess.spawn( + ChildProcess.make(shell, args, { + cwd, + env, + stdin: "ignore", + detached: process.platform !== "win32", + forceKillAfter: Duration.seconds(3), + }), + ) + const session: Active = { + info: produce(info, (draft) => { + draft.pid = handle.pid + }), + file, + size: 0, + done: Deferred.makeUnsafe(), + } + sessions.set(id, session) -function select(file: string | undefined, opts?: { acceptable?: boolean }) { - if (file && (!opts?.acceptable || ok(file))) { - const shell = resolve(file) - if (shell) return shell - } - if (process.platform === "win32") return win()[0] - return fallback() -} + const stream = createWriteStream(file) + yield* Effect.promise( + () => + new Promise((resolve) => { + stream.once("open", () => resolve()) + stream.once("error", () => resolve()) + }), + ) -export function gitbash() { - if (process.platform !== "win32") return - if (Flag.OPENCODE_GIT_BASH_PATH) return Flag.OPENCODE_GIT_BASH_PATH - const git = which("git") - if (!git) return - const file = path.join(git, "..", "..", "bin", "bash.exe") - if (stat(file)?.size) return file -} + const pump = handle.all.pipe( + Stream.runForEach((chunk: Uint8Array) => + Effect.sync(() => { + stream.write(chunk) + session.size += chunk.length + }), + ), + ) + runFork(pump.pipe(Effect.catch(() => Effect.void))) -function fallback() { - if (process.platform === "darwin") return "/bin/zsh" - const bash = which("bash") - if (bash) return bash - return "/bin/sh" -} + const finish = (status: Info["status"], exit?: number) => + Effect.gen(function* () { + if (session.info.status !== "running") return + session.info = produce(session.info, (draft) => { + draft.status = status + if (exit !== undefined) draft.exit = exit + draft.time.completed = Date.now() + }) + stream.end() + // Resolve waiters with the terminal Info before any retention eviction, so an evicted + // session still reports success rather than the removal NotFoundError. This runs before + // the timeout-fiber interrupt below, which on the timeout path would otherwise cancel + // this very fiber (finish is invoked by the timeout fiber) before waiters are resolved. + yield* Deferred.succeed(session.done, session.info) + yield* events.publish(Shell.Event.Exited, { + id, + ...(exit !== undefined ? { exit } : {}), + status, + }) + exitOrder.push(id) + while (exitOrder.length > EXITED_LIMIT) { + const oldest = exitOrder[0] + if (!oldest) break + yield* removeSession(Shell.ID.make(oldest)) + } + // Cancel a pending timeout once the command exits on its own. Interrupting last avoids + // aborting finish when finish itself runs on the timeout fiber. + if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber) + }) -export function name(file: string) { - if (process.platform === "win32") return path.win32.parse(FSUtil.windowsPath(file)).name.toLowerCase() - return path.basename(file).toLowerCase() -} + if (input.timeout) { + session.timeoutFiber = runFork( + Effect.sleep(Duration.millis(input.timeout)).pipe( + Effect.flatMap(() => + Effect.gen(function* () { + yield* finish("timeout") + yield* handle.kill().pipe(Effect.catch(() => Effect.void)) + }), + ), + Effect.catch(() => Effect.void), + ), + ) + } -export function login(file: string) { - return meta(file)?.login === true -} + runFork( + handle.exitCode.pipe( + Effect.flatMap((code) => finish("exited", code)), + Effect.catch(() => Effect.void), + ), + ) -export function posix(file: string) { - return meta(file)?.posix === true -} + yield* events.publish(Shell.Event.Created, { info }) + yield* Deferred.succeed(ready, session) + // Hold the handle's scope open until the command terminates; closing it earlier would + // release (kill) the process before its exit is observed. + yield* Deferred.await(session.done).pipe(Effect.catch(() => Effect.void)) + }), + ).pipe(Effect.catch(() => Effect.void)), + ) -export function ps(file: string) { - return meta(file)?.ps === true -} + const session = yield* Deferred.await(ready) + return session.info + }) -function info(file: string): Item { - const item = full(file) - const n = name(item) - return { - path: item, - name: resolve(n) ? n : item, - acceptable: ok(item), - } -} + return Service.of({ create, list, get, wait, output, remove }) + }), +) -export function args(file: string, command: string, cwd: string) { - const n = name(file) - if (n === "nu" || n === "fish") return ["-c", command] - if (n === "zsh") { - return [ - "-l", - "-c", - ` - [[ -f ~/.zshenv ]] && source ~/.zshenv >/dev/null 2>&1 || true - [[ -f "\${ZDOTDIR:-$HOME}/.zshrc" ]] && source "\${ZDOTDIR:-$HOME}/.zshrc" >/dev/null 2>&1 || true - cd -- "$1" - eval ${JSON.stringify(command)} - `, - "opencode", - cwd, - ] - } - if (n === "bash") { - return [ - "-l", - "-c", - ` - shopt -s expand_aliases - [[ -f ~/.bashrc ]] && source ~/.bashrc >/dev/null 2>&1 || true - cd -- "$1" - eval ${JSON.stringify(command)} - `, - "opencode", - cwd, - ] - } - if (n === "cmd") return ["/c", command] - if (ps(file)) return ["-NoProfile", "-Command", command] - return ["-c", command] -} +export const locationLayer = layer.pipe(Layer.provide(Config.locationLayer)) -let defaultPreferred: string | undefined -let defaultAcceptable: string | undefined - -export function preferred(configShell?: string) { - if (configShell) return select(configShell) - defaultPreferred ??= select(process.env.SHELL) - return defaultPreferred -} -preferred.reset = () => { - defaultPreferred = undefined -} - -export function acceptable(configShell?: string) { - if (configShell) return select(configShell, { acceptable: true }) - defaultAcceptable ??= select(process.env.SHELL, { acceptable: true }) - return defaultAcceptable -} -acceptable.reset = () => { - defaultAcceptable = undefined -} - -export async function list(): Promise { - const shells = process.platform === "win32" ? win() : await unix() - return shells.filter((s) => resolve(s)).map(info) -} +export const node = makeLocationNode({ + service: Service, + layer, + deps: [EventV2.node, Location.node, Config.node, Global.node, AppProcess.node], +}) diff --git a/packages/core/src/shell/select.ts b/packages/core/src/shell/select.ts new file mode 100644 index 00000000000..6bef02c2036 --- /dev/null +++ b/packages/core/src/shell/select.ts @@ -0,0 +1,226 @@ +export * as ShellSelect from "./select" + +import path from "path" +import { spawn, type ChildProcess } from "child_process" +import { readFile } from "fs/promises" +import { statSync } from "fs" +import { setTimeout as sleep } from "node:timers/promises" +import { Flag } from "../flag/flag" +import { FSUtil } from "../fs-util" +import { which } from "../util/which" + +const SIGKILL_TIMEOUT_MS = 200 +const META: Record = { + bash: { login: true, posix: true }, + dash: { login: true, posix: true }, + fish: { deny: true, login: true }, + ksh: { login: true, posix: true }, + nu: { deny: true }, + powershell: { ps: true }, + pwsh: { ps: true }, + sh: { login: true, posix: true }, + zsh: { login: true, posix: true }, +} + +export type Item = { + path: string + name: string + acceptable: boolean +} + +export async function killTree(proc: ChildProcess, opts?: { exited?: () => boolean }): Promise { + const pid = proc.pid + if (!pid || opts?.exited?.()) return + + if (process.platform === "win32") { + await new Promise((resolve) => { + const killer = spawn("taskkill", ["/pid", String(pid), "/f", "/t"], { + stdio: "ignore", + windowsHide: true, + }) + killer.once("exit", () => resolve()) + killer.once("error", () => resolve()) + }) + return + } + + try { + process.kill(-pid, "SIGTERM") + await sleep(SIGKILL_TIMEOUT_MS) + if (!opts?.exited?.()) { + process.kill(-pid, "SIGKILL") + } + } catch { + proc.kill("SIGTERM") + await sleep(SIGKILL_TIMEOUT_MS) + if (!opts?.exited?.()) { + proc.kill("SIGKILL") + } + } +} + +function stat(file: string) { + return statSync(file, { throwIfNoEntry: false }) ?? undefined +} + +function full(file: string) { + if (process.platform !== "win32") return file + const shell = FSUtil.windowsPath(file) + if (path.win32.dirname(shell) !== ".") { + if (shell.startsWith("/") && name(shell) === "bash") return gitbash() || shell + return shell + } + if (name(shell) === "bash") return gitbash() || which(shell) || shell + return which(shell) || shell +} + +function meta(file: string) { + return META[name(file)] +} + +function ok(file: string) { + return meta(file)?.deny !== true +} + +function rooted(file: string) { + return path.isAbsolute(FSUtil.windowsPath(file)) +} + +function resolve(file: string) { + const shell = full(file) + if (rooted(shell)) { + if (stat(shell)?.isFile()) return shell + return + } + return which(shell) ?? undefined +} + +function win() { + return Array.from( + new Set( + [which("pwsh"), which("powershell"), gitbash(), process.env.COMSPEC || "cmd.exe"] + .filter((item): item is string => Boolean(item)) + .map(full), + ), + ) +} + +async function unix() { + const text = await readFile("/etc/shells", "utf8").catch(() => "") + if (text) return Array.from(new Set(text.split("\n").filter((line) => line.trim() && !line.startsWith("#")))) + return ["/bin/bash", "/bin/zsh", "/bin/sh"] +} + +function select(file: string | undefined, opts?: { acceptable?: boolean }) { + if (file && (!opts?.acceptable || ok(file))) { + const shell = resolve(file) + if (shell) return shell + } + if (process.platform === "win32") return win()[0] + return fallback() +} + +export function gitbash() { + if (process.platform !== "win32") return + if (Flag.OPENCODE_GIT_BASH_PATH) return Flag.OPENCODE_GIT_BASH_PATH + const git = which("git") + if (!git) return + const file = path.join(git, "..", "..", "bin", "bash.exe") + if (stat(file)?.size) return file +} + +function fallback() { + if (process.platform === "darwin") return "/bin/zsh" + const bash = which("bash") + if (bash) return bash + return "/bin/sh" +} + +export function name(file: string) { + if (process.platform === "win32") return path.win32.parse(FSUtil.windowsPath(file)).name.toLowerCase() + return path.basename(file).toLowerCase() +} + +export function login(file: string) { + return meta(file)?.login === true +} + +export function posix(file: string) { + return meta(file)?.posix === true +} + +export function ps(file: string) { + return meta(file)?.ps === true +} + +function info(file: string): Item { + const item = full(file) + const n = name(item) + return { + path: item, + name: resolve(n) ? n : item, + acceptable: ok(item), + } +} + +export function args(file: string, command: string, cwd: string) { + const n = name(file) + if (n === "nu" || n === "fish") return ["-c", command] + if (n === "zsh") { + return [ + "-l", + "-c", + ` + [[ -f ~/.zshenv ]] && source ~/.zshenv >/dev/null 2>&1 || true + [[ -f "\${ZDOTDIR:-$HOME}/.zshrc" ]] && source "\${ZDOTDIR:-$HOME}/.zshrc" >/dev/null 2>&1 || true + cd -- "$1" + eval ${JSON.stringify(command)} + `, + "opencode", + cwd, + ] + } + if (n === "bash") { + return [ + "-l", + "-c", + ` + shopt -s expand_aliases + [[ -f ~/.bashrc ]] && source ~/.bashrc >/dev/null 2>&1 || true + cd -- "$1" + eval ${JSON.stringify(command)} + `, + "opencode", + cwd, + ] + } + if (n === "cmd") return ["/c", command] + if (ps(file)) return ["-NoProfile", "-Command", command] + return ["-c", command] +} + +let defaultPreferred: string | undefined +let defaultAcceptable: string | undefined + +export function preferred(configShell?: string) { + if (configShell) return select(configShell) + defaultPreferred ??= select(process.env.SHELL) + return defaultPreferred +} +preferred.reset = () => { + defaultPreferred = undefined +} + +export function acceptable(configShell?: string) { + if (configShell) return select(configShell, { acceptable: true }) + defaultAcceptable ??= select(process.env.SHELL, { acceptable: true }) + return defaultAcceptable +} +acceptable.reset = () => { + defaultAcceptable = undefined +} + +export async function list(): Promise { + const shells = process.platform === "win32" ? win() : await unix() + return shells.filter((s) => resolve(s)).map(info) +} diff --git a/packages/core/src/tool/builtins.ts b/packages/core/src/tool/builtins.ts index 1551e278ff9..8fa53b48af8 100644 --- a/packages/core/src/tool/builtins.ts +++ b/packages/core/src/tool/builtins.ts @@ -2,7 +2,7 @@ export * as BuiltInTools from "./builtins" import { makeLocationNode } from "../effect/app-node" import { Layer } from "effect" -import { BashTool } from "./bash" +import { ShellTool } from "./shell" import { ApplyPatchTool } from "./apply-patch" import { EditTool } from "./edit" import { GlobTool } from "./glob" @@ -16,8 +16,7 @@ import { WebFetchTool } from "./webfetch" import { WebSearchTool } from "./websearch" import { WriteTool } from "./write" import { FSUtil } from "../fs-util" -import { AppProcess } from "../process" -import { Config } from "../config" +import { Shell } from "../shell" import { Location } from "../location" import { LocationMutation } from "../location-mutation" import { FileMutation } from "../file-mutation" @@ -45,7 +44,7 @@ import { httpClient } from "../effect/app-node-platform" */ export const locationLayer = Layer.mergeAll( ApplyPatchTool.layer, - BashTool.layer, + ShellTool.layer, EditTool.layer, GlobTool.layer, GrepTool.layer, @@ -64,8 +63,7 @@ export const node = makeLocationNode({ deps: [ ToolRegistry.toolsNode, FSUtil.node, - AppProcess.node, - Config.node, + Shell.node, Location.node, LocationMutation.node, FileMutation.node, diff --git a/packages/core/src/tool/bash.ts b/packages/core/src/tool/shell.ts similarity index 77% rename from packages/core/src/tool/bash.ts rename to packages/core/src/tool/shell.ts index 19507310c24..fbc884b6967 100644 --- a/packages/core/src/tool/bash.ts +++ b/packages/core/src/tool/shell.ts @@ -1,19 +1,17 @@ -export * as BashTool from "./bash" +export * as ShellTool from "./shell" import path from "path" import { ToolFailure } from "@opencode-ai/llm" -import { Duration, Effect, Layer, Schema } from "effect" -import { ChildProcess } from "effect/unstable/process" -import { Config } from "../config" +import { Effect, Layer, Schema } from "effect" import { FSUtil } from "../fs-util" import { LocationMutation } from "../location-mutation" -import { AppProcess } from "../process" import { PermissionV2 } from "../permission" import { PositiveInt } from "../schema" +import { Shell } from "../shell" import { Tool } from "./tool" import { Tools } from "./tools" -export const name = "bash" +export const name = "shell" export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000 export const MAX_TIMEOUT_MS = 10 * 60 * 1_000 export const MAX_CAPTURE_BYTES = 1024 * 1024 @@ -44,8 +42,6 @@ const Output = Schema.Struct({ type Output = typeof Output.Type -const defaultShell = () => (process.platform === "win32" ? (process.env.COMSPEC ?? "cmd.exe") : "/bin/sh") - const modelOutput = (output: Output) => { const warnings = output.warnings?.length ? `\n\nWarnings:\n${output.warnings.map((warning) => `- ${warning}`).join("\n")}` @@ -54,9 +50,6 @@ const modelOutput = (output: Output) => { return `${warnings.trimStart()}${warnings ? "\n\n" : ""}Command exited with code ${output.exit}.` } -const isTimeout = (error: AppProcess.AppProcessError) => - error.cause instanceof Error && error.cause.message === "Timed out" - /** * Minimal V2 core shell boundary. Keep parity debt visible without pulling the * legacy shell runtime into core. @@ -93,8 +86,7 @@ export const layer = Layer.effectDiscard( const tools = yield* Tools.Service const mutation = yield* LocationMutation.Service const fs = yield* FSUtil.Service - const appProcess = yield* AppProcess.Service - const config = yield* Config.Service + const shell = yield* Shell.Service const permission = yield* PermissionV2.Service yield* tools @@ -131,7 +123,7 @@ export const layer = Layer.effectDiscard( }) const warnings = externalCommandDirectories(input.command, target.canonical).map( (directory) => - `Command argument references external directory ${path.join(directory, "*").replaceAll("\\", "/")}. Bash runs with host-user filesystem, process, and network authority; this scan is advisory only.`, + `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, @@ -145,30 +137,20 @@ export const layer = Layer.effectDiscard( if ((yield* fs.stat(target.canonical)).type !== "Directory") return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.canonical}`)) - const entries = yield* config.entries() - const shell = - Object.assign({}, ...entries.flatMap((entry) => (entry.type === "document" ? [entry.info] : []))) - .shell ?? defaultShell() - const command = ChildProcess.make(input.command, [], { - cwd: target.canonical, - shell, - stdin: "ignore", - detached: process.platform !== "win32", - forceKillAfter: Duration.seconds(3), - }) + // Delegate spawning, combined-output capture, timeout, and exit tracking to the Shell + // 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 result = yield* appProcess - .run(command, { - combineOutput: true, - timeout: Duration.millis(timeout), - maxOutputBytes: MAX_CAPTURE_BYTES, - }) - .pipe( - Effect.catchTag("AppProcessError", (error) => - isTimeout(error) ? Effect.succeed(undefined) : Effect.fail(error), - ), - ) - if (!result) { + 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 { output: `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`, truncated: false, @@ -177,14 +159,13 @@ export const layer = Layer.effectDiscard( } } - const output = result.output?.toString("utf8") || "(no output)" - const notice = result.outputTruncated - ? "[output capture truncated at the in-memory safety limit]" - : undefined + 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: result.exitCode, - output: notice ? `${output}\n\n${notice}` : output, - truncated: result.outputTruncated === true, + exit: final.exit, + output: `${body}${notice}`, + truncated, ...(warnings.length ? { warnings } : {}), } }).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to execute command: ${input.command}` }))), diff --git a/packages/core/src/v1/config/migrate.ts b/packages/core/src/v1/config/migrate.ts index ebe1b905ff8..5f62858e3cb 100644 --- a/packages/core/src/v1/config/migrate.ts +++ b/packages/core/src/v1/config/migrate.ts @@ -79,8 +79,9 @@ function permissions(info?: ConfigPermissionV1.Info, tools?: Readonly { }), ) + it.effect("normalizes renamed permission actions when migrating v1 permissions", () => + Effect.sync(() => { + expect( + ConfigMigrateV1.migrate({ + permission: { + task: "ask", + bash: { "git status": "allow", "*": "deny" }, + write: "deny", + read: "allow", + }, + }).permissions, + ).toEqual([ + { action: "subagent", resource: "*", effect: "ask" }, + { action: "shell", resource: "git status", effect: "allow" }, + { action: "shell", resource: "*", effect: "deny" }, + { action: "edit", resource: "*", effect: "deny" }, + { action: "read", resource: "*", effect: "allow" }, + ]) + }), + ) + it.live("returns an empty configuration when directory files do not exist", () => Effect.acquireRelease( Effect.promise(() => tmpdir()), @@ -566,7 +587,7 @@ describe("Config", () => { expect(documents[0]?.info.snapshots).toBe(false) expect(documents[0]?.info.share).toBe("auto") expect(documents[0]?.info.permissions).toEqual([ - { action: "bash", resource: "*", effect: "ask" }, + { action: "shell", resource: "*", effect: "ask" }, { action: "edit", resource: "*.md", effect: "allow" }, { action: "edit", resource: "*", effect: "deny" }, { action: "question", resource: "*", effect: "deny" }, diff --git a/packages/core/test/shell.test.ts b/packages/core/test/shell.test.ts index 1cc47a79f6c..d38c160d01b 100644 --- a/packages/core/test/shell.test.ts +++ b/packages/core/test/shell.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import path from "path" -import { Shell } from "@opencode-ai/core/shell" +import { ShellSelect } from "@opencode-ai/core/shell/select" import { FSUtil } from "@opencode-ai/core/fs-util" import { which } from "@opencode-ai/core/util/which" @@ -8,56 +8,56 @@ const withShell = async (shell: string | undefined, fn: () => void | Promise { test("normalizes shell names", () => { - expect(Shell.name("/bin/bash")).toBe("bash") + expect(ShellSelect.name("/bin/bash")).toBe("bash") if (process.platform === "win32") { - expect(Shell.name("C:/tools/NU.EXE")).toBe("nu") - expect(Shell.name("C:/tools/PWSH.EXE")).toBe("pwsh") + expect(ShellSelect.name("C:/tools/NU.EXE")).toBe("nu") + expect(ShellSelect.name("C:/tools/PWSH.EXE")).toBe("pwsh") } }) test("detects login shells", () => { - expect(Shell.login("/bin/bash")).toBe(true) - expect(Shell.login("C:/tools/pwsh.exe")).toBe(false) + expect(ShellSelect.login("/bin/bash")).toBe(true) + expect(ShellSelect.login("C:/tools/pwsh.exe")).toBe(false) }) test("detects posix shells", () => { - expect(Shell.posix("/bin/bash")).toBe(true) - expect(Shell.posix("/bin/fish")).toBe(false) - expect(Shell.posix("C:/tools/pwsh.exe")).toBe(false) + expect(ShellSelect.posix("/bin/bash")).toBe(true) + expect(ShellSelect.posix("/bin/fish")).toBe(false) + expect(ShellSelect.posix("C:/tools/pwsh.exe")).toBe(false) }) test("falls back when configured shell cannot be resolved", async () => { await withShell(undefined, async () => { - const preferred = Shell.preferred() - const acceptable = Shell.acceptable() - expect(Shell.preferred("opencode-missing-shell")).toBe(preferred) - expect(Shell.acceptable("opencode-missing-shell")).toBe(acceptable) + const preferred = ShellSelect.preferred() + const acceptable = ShellSelect.acceptable() + expect(ShellSelect.preferred("opencode-missing-shell")).toBe(preferred) + expect(ShellSelect.acceptable("opencode-missing-shell")).toBe(acceptable) }) }) test("falls back for terminal-only acceptable shells", () => { - expect(Shell.name(Shell.acceptable("fish"))).not.toBe("fish") - expect(Shell.name(Shell.acceptable("nu"))).not.toBe("nu") + expect(ShellSelect.name(ShellSelect.acceptable("fish"))).not.toBe("fish") + expect(ShellSelect.name(ShellSelect.acceptable("nu"))).not.toBe("nu") }) test("builds command args per shell family", () => { - expect(Shell.args("/bin/sh", "echo hi", "/tmp")).toEqual(["-c", "echo hi"]) - expect(Shell.args("/usr/bin/fish", "echo hi", "/tmp")).toEqual(["-c", "echo hi"]) - const zsh = Shell.args("/bin/zsh", "echo hi", "/tmp") + expect(ShellSelect.args("/bin/sh", "echo hi", "/tmp")).toEqual(["-c", "echo hi"]) + expect(ShellSelect.args("/usr/bin/fish", "echo hi", "/tmp")).toEqual(["-c", "echo hi"]) + const zsh = ShellSelect.args("/bin/zsh", "echo hi", "/tmp") expect(zsh[0]).toBe("-l") expect(zsh[1]).toBe("-c") expect(zsh.at(-1)).toBe("/tmp") @@ -66,34 +66,34 @@ describe("shell", () => { if (process.platform === "win32") { test("rejects blacklisted shells case-insensitively", async () => { await withShell("NU.EXE", async () => { - expect(Shell.name(Shell.acceptable())).not.toBe("nu") + expect(ShellSelect.name(ShellSelect.acceptable())).not.toBe("nu") }) }) test("normalizes Git Bash shell paths from env", async () => { const shell = "/cygdrive/c/Program Files/Git/bin/bash.exe" await withShell(shell, async () => { - expect(Shell.preferred()).toBe(FSUtil.windowsPath(shell)) + expect(ShellSelect.preferred()).toBe(FSUtil.windowsPath(shell)) }) }) test("resolves /usr/bin/bash from env to Git Bash", async () => { - const bash = Shell.gitbash() + const bash = ShellSelect.gitbash() if (!bash) return await withShell("/usr/bin/bash", async () => { - expect(Shell.acceptable()).toBe(bash) - expect(Shell.preferred()).toBe(bash) + expect(ShellSelect.acceptable()).toBe(bash) + expect(ShellSelect.preferred()).toBe(bash) }) }) test("resolves bare bash to Git Bash before PATH", async () => { - const bash = Shell.gitbash() + const bash = ShellSelect.gitbash() if (!bash) return - expect(Shell.acceptable("bash")).toBe(bash) - expect(Shell.preferred("bash")).toBe(bash) + expect(ShellSelect.acceptable("bash")).toBe(bash) + expect(ShellSelect.preferred("bash")).toBe(bash) await withShell("bash", async () => { - expect(Shell.acceptable()).toBe(bash) - expect(Shell.preferred()).toBe(bash) + expect(ShellSelect.acceptable()).toBe(bash) + expect(ShellSelect.preferred()).toBe(bash) }) }) @@ -101,7 +101,7 @@ describe("shell", () => { const shell = which("pwsh") || which("powershell") if (!shell) return await withShell(path.win32.basename(shell), async () => { - expect(Shell.preferred()).toBe(shell) + expect(ShellSelect.preferred()).toBe(shell) }) }) } diff --git a/packages/core/test/tool-bash.test.ts b/packages/core/test/tool-bash.test.ts deleted file mode 100644 index f15621ef582..00000000000 --- a/packages/core/test/tool-bash.test.ts +++ /dev/null @@ -1,432 +0,0 @@ -import fs from "fs/promises" -import { realpathSync } from "node:fs" -import path from "path" -import { describe, expect, test } from "bun:test" -import { Effect, Layer } from "effect" -import { ChildProcess } from "effect/unstable/process" -import { FSUtil } from "@opencode-ai/core/fs-util" -import { Config } from "@opencode-ai/core/config" -import { Location } from "@opencode-ai/core/location" -import { LocationMutation } from "@opencode-ai/core/location-mutation" -import { PermissionV2 } from "@opencode-ai/core/permission" -import { AppProcess } from "@opencode-ai/core/process" -import { AbsolutePath } from "@opencode-ai/core/schema" -import { SessionV2 } from "@opencode-ai/core/session" -import { BashTool } from "@opencode-ai/core/tool/bash" -import { ToolRegistry } from "@opencode-ai/core/tool/registry" -import { location } from "./fixture/location" -import { tmpdir } from "./fixture/tmpdir" -import { testEffect } from "./lib/effect" -import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool" - -const sessionID = SessionV2.ID.make("ses_bash_tool_test") -const assertions: PermissionV2.AssertInput[] = [] -const runs: Array<{ - readonly command: string - readonly cwd?: string - readonly shell?: string | boolean - readonly options?: AppProcess.RunOptions -}> = [] -let denyAction: string | undefined -let result: AppProcess.RunResult = { - command: "mock", - exitCode: 0, - output: Buffer.from("hello\n"), - stdout: Buffer.from("hello\n"), - stderr: Buffer.alloc(0), - outputTruncated: false, - stdoutTruncated: false, - stderrTruncated: false, -} -let runFailure: AppProcess.AppProcessError | undefined -let afterPermission = (_input: PermissionV2.AssertInput): Effect.Effect => Effect.void - -const permission = Layer.succeed( - PermissionV2.Service, - PermissionV2.Service.of({ - assert: (input) => - Effect.sync(() => assertions.push(input)).pipe( - Effect.andThen(Effect.suspend(() => afterPermission(input))), - Effect.andThen( - input.action === denyAction ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void, - ), - ), - ask: () => Effect.die("unused"), - reply: () => Effect.die("unused"), - get: () => Effect.die("unused"), - forSession: () => Effect.die("unused"), - list: () => Effect.die("unused"), - }), -) -const appProcess = Layer.succeed( - AppProcess.Service, - AppProcess.Service.of({ - run: (command: ChildProcess.Command, options?: AppProcess.RunOptions) => - Effect.suspend(() => { - if (command._tag !== "StandardCommand") throw new Error("expected standard command") - runs.push({ command: command.command, cwd: command.options.cwd, shell: command.options.shell, options }) - return runFailure ? Effect.fail(runFailure) : Effect.succeed(result) - }), - } as unknown as AppProcess.Interface), -) -const config = Layer.succeed( - Config.Service, - Config.Service.of({ - entries: () => Effect.succeed([]), - }), -) - -const reset = () => { - assertions.length = 0 - runs.length = 0 - denyAction = undefined - runFailure = undefined - afterPermission = () => Effect.void - result = { - command: "mock", - exitCode: 0, - output: Buffer.from("hello\n"), - stdout: Buffer.from("hello\n"), - stderr: Buffer.alloc(0), - outputTruncated: false, - stdoutTruncated: false, - stderrTruncated: false, - } -} - -const withTool = ( - directory: string, - body: (registry: ToolRegistry.Interface) => Effect.Effect, - processLayer: Layer.Layer = appProcess, -) => { - const filesystem = FSUtil.defaultLayer - const activeLocation = Layer.succeed( - Location.Service, - Location.Service.of(location({ directory: AbsolutePath.make(directory) })), - ) - const mutation = LocationMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(activeLocation)) - const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission)) - const bash = BashTool.layer.pipe( - Layer.provide(registry), - Layer.provide(permission), - Layer.provide(mutation), - Layer.provide(filesystem), - Layer.provide(processLayer), - Layer.provide(config), - ) - return Effect.gen(function* () { - return yield* body(yield* ToolRegistry.Service) - }).pipe(Effect.provide(Layer.mergeAll(registry, bash))) -} - -const call = (input: typeof BashTool.Input.Type, id = "call-bash") => ({ - sessionID, - ...toolIdentity, - call: { type: "tool-call" as const, id, name: "bash", input }, -}) - -const it = testEffect(Layer.empty) - -describe("BashTool", () => { - it.live("registers and returns structured successful output from the active Location", () => - Effect.acquireUseRelease( - Effect.promise(() => tmpdir()), - (tmp) => { - reset() - return withTool(tmp.path, (registry) => - Effect.gen(function* () { - const definitions = yield* toolDefinitions(registry) - expect(definitions.map((tool) => tool.name)).toEqual(["bash"]) - expect(definitions[0]?.inputSchema).not.toHaveProperty("properties.background") - expect(definitions[0]?.inputSchema).not.toHaveProperty("properties.description") - expect(definitions[0]?.outputSchema).not.toHaveProperty("properties.output") - expect(definitions[0]?.outputSchema).not.toHaveProperty("properties.command") - expect(definitions[0]?.outputSchema).not.toHaveProperty("properties.cwd") - expect(yield* toolDefinitions(registry, [{ action: "bash", resource: "*", effect: "deny" }])).toEqual([]) - expect(yield* settleTool(registry, call({ command: "pwd" }))).toEqual({ - result: { - type: "content", - value: [ - { type: "text", text: "hello\n" }, - { type: "text", text: "Command exited with code 0." }, - ], - }, - output: { - structured: { - exit: 0, - truncated: false, - }, - content: [ - { type: "text", text: "hello\n" }, - { type: "text", text: "Command exited with code 0." }, - ], - }, - }) - expect(runs).toMatchObject([{ command: "pwd", cwd: realpathSync(tmp.path) }]) - expect(runs[0]?.options).toMatchObject({ - combineOutput: true, - maxOutputBytes: BashTool.MAX_CAPTURE_BYTES, - }) - expect(assertions).toMatchObject([{ sessionID, action: "bash", resources: ["pwd"], save: ["pwd"] }]) - }), - ) - }, - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), - ), - ) - - 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( - withTool(tmp.path, (registry) => executeTool(registry, call({ command: "pwd", workdir: "src" }))), - ), - Effect.andThen( - Effect.sync(() => expect(runs).toMatchObject([{ cwd: realpathSync(path.join(tmp.path, "src")) }])), - ), - ) - }, - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), - ), - ) - - 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 === "bash" - ? 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( - withTool(tmp.path, (registry) => executeTool(registry, call({ command: "pwd", workdir: "src" }))), - ), - Effect.andThen( - Effect.sync(() => { - expect(runs).toEqual([]) - expect(assertions.map((input) => input.action)).toEqual(["bash"]) - }), - ), - ) - }, - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), - ), - ) - - if (process.platform !== "win32") { - it.live("executes a real shell command through AppProcess", () => - Effect.acquireUseRelease( - Effect.promise(() => tmpdir()), - (tmp) => { - reset() - return withTool( - tmp.path, - (registry) => settleTool(registry, call({ command: "printf core-bash" })), - AppProcess.defaultLayer, - ).pipe( - Effect.andThen((settled) => - Effect.sync(() => { - expect(settled.result).toEqual({ - type: "content", - value: [ - { type: "text", text: "core-bash" }, - { type: "text", text: "Command exited with code 0." }, - ], - }) - expect(settled.output?.structured).toMatchObject({ - exit: 0, - }) - expect(settled.output?.structured).not.toHaveProperty("output") - }), - ), - ) - }, - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), - ), - ) - } - - it.live("approves an explicit external workdir before bash execution", () => - Effect.acquireUseRelease( - Effect.promise(() => Promise.all([tmpdir(), tmpdir()])), - ([active, outside]) => { - reset() - return withTool(active.path, (registry) => - executeTool(registry, call({ command: "pwd", workdir: outside.path })), - ).pipe( - Effect.andThen( - Effect.sync(() => { - expect(assertions.map((item) => item.action)).toEqual(["external_directory", "bash"]) - expect(assertions[0]).toMatchObject({ - resources: [path.join(realpathSync(outside.path), "*").replaceAll("\\", "/")], - }) - expect(runs).toHaveLength(1) - }), - ), - ) - }, - ([active, outside]) => - Effect.promise(() => - Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined), - ), - ), - ) - - it.live("does not execute after external-directory or bash denial", () => - Effect.acquireUseRelease( - Effect.promise(() => Promise.all([tmpdir(), tmpdir()])), - ([active, outside]) => - Effect.gen(function* () { - reset() - denyAction = "external_directory" - yield* withTool(active.path, (registry) => - executeTool(registry, call({ command: "pwd", workdir: outside.path })), - ) - expect(assertions.map((item) => item.action)).toEqual(["external_directory"]) - expect(runs).toEqual([]) - - reset() - denyAction = "bash" - yield* withTool(active.path, (registry) => executeTool(registry, call({ command: "pwd" }))) - expect(assertions.map((item) => item.action)).toEqual(["bash"]) - expect(runs).toEqual([]) - }), - ([active, outside]) => - Effect.promise(() => - Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined), - ), - ), - ) - - it.live("reports external command arguments as advisory warnings without enforcing approval", () => - Effect.acquireUseRelease( - Effect.promise(() => Promise.all([tmpdir(), tmpdir()])), - ([active, outside]) => { - reset() - denyAction = "external_directory" - const target = path.join(outside.path, "secret.txt") - return withTool(active.path, (registry) => settleTool(registry, call({ command: `cat ${target}` }))).pipe( - Effect.andThen((settled) => - Effect.sync(() => { - expect(assertions.map((item) => item.action)).toEqual(["bash"]) - expect(runs).toHaveLength(1) - expect(settled.output?.structured).toMatchObject({ - truncated: false, - }) - expect(settled.output?.structured).not.toHaveProperty("warnings") - expect(settled.output?.content[1]).toMatchObject({ - type: "text", - text: expect.stringContaining("Warnings:"), - }) - }), - ), - ) - }, - ([active, outside]) => - Effect.promise(() => - Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined), - ), - ), - ) - - it.live("keeps non-zero exits useful", () => - Effect.acquireUseRelease( - Effect.promise(() => tmpdir()), - (tmp) => { - reset() - result = { ...result, exitCode: 7, output: Buffer.from("HEAD full output TAIL") } - return withTool(tmp.path, (registry) => settleTool(registry, call({ command: "false" }, "call-overflow"))).pipe( - Effect.andThen((settled) => - Effect.sync(() => { - expect(settled.output?.content[1]).toMatchObject({ - type: "text", - text: expect.stringContaining("Command exited with code 7"), - }) - expect(settled.output?.structured).toMatchObject({ - exit: 7, - truncated: false, - }) - expect(settled.output?.content[0]).toEqual({ type: "text", text: "HEAD full output TAIL" }) - }), - ), - ) - }, - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), - ), - ) - - it.live("surfaces bounded process-capture truncation", () => - Effect.acquireUseRelease( - Effect.promise(() => tmpdir()), - (tmp) => { - reset() - result = { ...result, outputTruncated: true } - return withTool(tmp.path, (registry) => settleTool(registry, call({ command: "verbose" }))).pipe( - Effect.andThen((settled) => - Effect.sync(() => { - expect(settled.output?.structured).toMatchObject({ truncated: true }) - expect(settled.output?.content[0]).toMatchObject({ - type: "text", - text: expect.stringContaining("output capture truncated"), - }) - expect(settled.output?.structured).not.toHaveProperty("resource") - }), - ), - ) - }, - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), - ), - ) - - it.live("returns a useful timeout settlement", () => - Effect.acquireUseRelease( - Effect.promise(() => tmpdir()), - (tmp) => { - reset() - runFailure = new AppProcess.AppProcessError({ command: "sleep", cause: new Error("Timed out") }) - return withTool(tmp.path, (registry) => settleTool(registry, call({ command: "sleep 60", timeout: 10 }))).pipe( - Effect.andThen((settled) => - Effect.sync(() => { - expect(settled.output?.content[1]).toMatchObject({ - type: "text", - text: expect.stringContaining("Command timed out"), - }) - expect(settled.output?.structured).toMatchObject({ - timeout: true, - truncated: false, - }) - }), - ), - ) - }, - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), - ), - ) -}) - -test("keeps locked deferred parity TODOs visible", async () => { - const source = await fs.readFile(new URL("../src/tool/bash.ts", import.meta.url), "utf8") - for (const todo of [ - "Port tree-sitter bash / PowerShell parser-based approval reduction.", - "Port BashArity reusable command-prefix approvals.", - "Replace token-based command-argument external-directory advisories with parser-based detection.", - "Restore PowerShell and cmd-specific invocation/path handling on Windows.", - "Add plugin shell.env environment augmentation once V2 plugin hooks exist.", - "Add durable/live progress metadata streaming for long-running commands once V2 tool invocation progress context is wired.", - "Persist background job status and define restart recovery before exposing remote observation.", - "Revisit process-group cleanup and platform coverage with shell-specific tests if current AppProcess semantics do not fully cover it.", - "Revisit binary output handling if stdout/stderr decoding is text-only.", - "Stream full shell output into managed storage while retaining only a bounded in-memory preview.", - ]) { - expect(source).toContain(`TODO: ${todo}`) - } -}) diff --git a/packages/core/test/tool-shell.test.ts b/packages/core/test/tool-shell.test.ts new file mode 100644 index 00000000000..a97d31dd111 --- /dev/null +++ b/packages/core/test/tool-shell.test.ts @@ -0,0 +1,366 @@ +import fs from "fs/promises" +import { realpathSync } from "node:fs" +import path from "path" +import { describe, expect, test } from "bun:test" +import { Effect, Layer } from "effect" +import { Config } from "@opencode-ai/core/config" +import { EventV2 } from "@opencode-ai/core/event" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { Global } from "@opencode-ai/core/global" +import { Location } from "@opencode-ai/core/location" +import { LocationMutation } from "@opencode-ai/core/location-mutation" +import { PermissionV2 } from "@opencode-ai/core/permission" +import { AppProcess } from "@opencode-ai/core/process" +import { Project } from "@opencode-ai/core/project" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { SessionV2 } from "@opencode-ai/core/session" +import { Shell } from "@opencode-ai/core/shell" +import { ShellTool } from "@opencode-ai/core/tool/shell" +import { ToolRegistry } from "@opencode-ai/core/tool/registry" +import { tmpdir } from "./fixture/tmpdir" +import { testEffect } from "./lib/effect" +import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool" + +const sessionID = SessionV2.ID.make("ses_shell_tool_test") +const assertions: PermissionV2.AssertInput[] = [] +let denyAction: string | undefined +let afterPermission = (_input: PermissionV2.AssertInput): Effect.Effect => Effect.void + +const permission = Layer.succeed( + PermissionV2.Service, + PermissionV2.Service.of({ + assert: (input) => + Effect.sync(() => assertions.push(input)).pipe( + Effect.andThen(Effect.suspend(() => afterPermission(input))), + Effect.andThen( + input.action === denyAction ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void, + ), + ), + ask: () => Effect.die("unused"), + reply: () => Effect.die("unused"), + get: () => Effect.die("unused"), + forSession: () => Effect.die("unused"), + list: () => Effect.die("unused"), + }), +) + +const reset = () => { + assertions.length = 0 + denyAction = undefined + afterPermission = () => Effect.void +} + +const withTool = ( + data: string, + directory: string, + body: (registry: ToolRegistry.Interface) => Effect.Effect, +) => { + const filesystem = FSUtil.defaultLayer + const location = Location.layer(Location.Ref.make({ directory: AbsolutePath.make(directory) })).pipe( + Layer.provide(Project.defaultLayer), + ) + const global = Global.layerWith({ data, config: path.join(data, "config") }) + const mutation = LocationMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(location)) + const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission)) + const shellService = Shell.layer.pipe( + Layer.provide(EventV2.defaultLayer), + Layer.provide(location), + Layer.provide(Config.locationLayer.pipe(Layer.provide(location), Layer.provide(filesystem), Layer.provide(global))), + Layer.provide(global), + Layer.provide(filesystem), + Layer.provide(AppProcess.defaultLayer), + ) + const shell = ShellTool.layer.pipe( + Layer.provide(registry), + Layer.provide(permission), + Layer.provide(mutation), + Layer.provide(filesystem), + Layer.provide(shellService), + ) + return Effect.gen(function* () { + return yield* body(yield* ToolRegistry.Service) + }).pipe(Effect.provide(Layer.mergeAll(registry, shell, filesystem))) +} + +const call = (input: typeof ShellTool.Input.Type, id = "call-shell") => ({ + sessionID, + ...toolIdentity, + call: { type: "tool-call" as const, id, name: "shell", input }, +}) + +const it = testEffect(Layer.empty) + +describe("ShellTool", () => { + it.live("registers and returns real successful output from the active Location", () => + Effect.acquireUseRelease( + Effect.promise(() => Promise.all([tmpdir(), tmpdir()])), + ([data, tmp]) => { + reset() + return withTool(data.path, tmp.path, (registry) => + Effect.gen(function* () { + const definitions = yield* toolDefinitions(registry) + expect(definitions.map((tool) => tool.name)).toEqual(["shell"]) + expect(definitions[0]?.outputSchema).not.toHaveProperty("properties.output") + expect(yield* toolDefinitions(registry, [{ action: "shell", resource: "*", effect: "deny" }])).toEqual([]) + + const settled = yield* settleTool(registry, call({ command: "printf hello" })) + expect(settled.output?.structured).toMatchObject({ exit: 0, truncated: false }) + expect(settled.output?.content[0]).toEqual({ type: "text", text: "hello" }) + expect(settled.output?.content[1]).toMatchObject({ + type: "text", + text: expect.stringContaining("Command exited with code 0."), + }) + expect(assertions).toMatchObject([{ sessionID, action: "shell", resources: ["printf hello"] }]) + }), + ) + }, + ([data, tmp]) => + Effect.promise(() => + Promise.all([data[Symbol.asyncDispose](), tmp[Symbol.asyncDispose]()]).then(() => undefined), + ), + ), + ) + + it.live("resolves a relative workdir from the active Location", () => + Effect.acquireUseRelease( + Effect.promise(() => Promise.all([tmpdir(), tmpdir()])), + ([data, tmp]) => { + reset() + return Effect.promise(() => fs.mkdir(path.join(tmp.path, "src"))).pipe( + Effect.andThen( + withTool(data.path, tmp.path, (registry) => settleTool(registry, call({ command: "pwd", workdir: "src" }))), + ), + Effect.andThen((settled) => + Effect.sync(() => + expect(settled.output?.content[0]).toMatchObject({ + type: "text", + text: expect.stringContaining(realpathSync(path.join(tmp.path, "src"))), + }), + ), + ), + ) + }, + ([data, tmp]) => + Effect.promise(() => + Promise.all([data[Symbol.asyncDispose](), tmp[Symbol.asyncDispose]()]).then(() => undefined), + ), + ), + ) + + it.live("rejects a workdir that stops being a directory during approval", () => + Effect.acquireUseRelease( + Effect.promise(() => Promise.all([tmpdir(), tmpdir()])), + ([data, 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( + withTool(data.path, tmp.path, (registry) => + executeTool(registry, call({ command: "pwd", workdir: "src" })), + ), + ), + Effect.andThen(Effect.sync(() => expect(assertions.map((input) => input.action)).toEqual(["shell"]))), + ) + }, + ([data, tmp]) => + Effect.promise(() => + Promise.all([data[Symbol.asyncDispose](), tmp[Symbol.asyncDispose]()]).then(() => undefined), + ), + ), + ) + + it.live("approves an explicit external workdir before shell execution", () => + Effect.acquireUseRelease( + Effect.promise(() => Promise.all([tmpdir(), tmpdir(), tmpdir()])), + ([data, active, outside]) => { + reset() + return withTool(data.path, active.path, (registry) => + executeTool(registry, call({ command: "pwd", 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("\\", "/")], + }) + }), + ), + ) + }, + ([data, active, outside]) => + Effect.promise(() => + Promise.all([ + data[Symbol.asyncDispose](), + active[Symbol.asyncDispose](), + outside[Symbol.asyncDispose](), + ]).then(() => undefined), + ), + ), + ) + + it.live("does not execute after external-directory or shell denial", () => + Effect.acquireUseRelease( + Effect.promise(() => Promise.all([tmpdir(), tmpdir(), tmpdir()])), + ([data, active, outside]) => + Effect.gen(function* () { + reset() + denyAction = "external_directory" + yield* withTool(data.path, active.path, (registry) => + executeTool(registry, call({ command: "pwd", workdir: outside.path })), + ) + expect(assertions.map((item) => item.action)).toEqual(["external_directory"]) + + reset() + denyAction = "shell" + yield* withTool(data.path, active.path, (registry) => executeTool(registry, call({ command: "pwd" }))) + expect(assertions.map((item) => item.action)).toEqual(["shell"]) + }), + ([data, active, outside]) => + Effect.promise(() => + Promise.all([ + data[Symbol.asyncDispose](), + active[Symbol.asyncDispose](), + outside[Symbol.asyncDispose](), + ]).then(() => undefined), + ), + ), + ) + + it.live("reports external command arguments as advisory warnings without enforcing approval", () => + Effect.acquireUseRelease( + Effect.promise(() => Promise.all([tmpdir(), tmpdir(), tmpdir()])), + ([data, active, outside]) => { + reset() + denyAction = "external_directory" + const target = path.join(outside.path, "secret.txt") + return withTool(data.path, active.path, (registry) => + settleTool(registry, call({ command: `cat ${target}` })), + ).pipe( + Effect.andThen((settled) => + Effect.sync(() => { + expect(assertions.map((item) => item.action)).toEqual(["shell"]) + expect(settled.output?.structured).not.toHaveProperty("warnings") + expect(settled.output?.content[1]).toMatchObject({ + type: "text", + text: expect.stringContaining("Warnings:"), + }) + }), + ), + ) + }, + ([data, active, outside]) => + Effect.promise(() => + Promise.all([ + data[Symbol.asyncDispose](), + active[Symbol.asyncDispose](), + outside[Symbol.asyncDispose](), + ]).then(() => undefined), + ), + ), + ) + + it.live("keeps non-zero exits useful", () => + Effect.acquireUseRelease( + Effect.promise(() => Promise.all([tmpdir(), tmpdir()])), + ([data, tmp]) => { + reset() + return withTool(data.path, tmp.path, (registry) => + settleTool(registry, call({ command: "printf body && exit 7" }, "call-nonzero")), + ).pipe( + Effect.andThen((settled) => + Effect.sync(() => { + expect(settled.output?.structured).toMatchObject({ exit: 7, truncated: false }) + expect(settled.output?.content[0]).toEqual({ type: "text", text: "body" }) + expect(settled.output?.content[1]).toMatchObject({ + type: "text", + text: expect.stringContaining("Command exited with code 7"), + }) + }), + ), + ) + }, + ([data, tmp]) => + 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", () => + Effect.acquireUseRelease( + Effect.promise(() => Promise.all([tmpdir(), tmpdir()])), + ([data, tmp]) => { + reset() + const bytes = ShellTool.MAX_CAPTURE_BYTES + 1024 + return withTool(data.path, tmp.path, (registry) => + settleTool(registry, call({ command: `head -c ${bytes} /dev/zero | tr '\\0' 'x'` }, "call-overflow")), + ).pipe( + Effect.andThen((settled) => + Effect.sync(() => { + expect(settled.output?.structured).toMatchObject({ exit: 0, truncated: true }) + expect(settled.output?.content[0]).toMatchObject({ + type: "text", + text: expect.stringContaining("output truncated; full output saved to:"), + }) + }), + ), + ) + }, + ([data, tmp]) => + Effect.promise(() => + Promise.all([data[Symbol.asyncDispose](), tmp[Symbol.asyncDispose]()]).then(() => undefined), + ), + ), + ) + + it.live("returns a useful timeout settlement", () => + Effect.acquireUseRelease( + Effect.promise(() => Promise.all([tmpdir(), tmpdir()])), + ([data, tmp]) => { + reset() + return withTool(data.path, tmp.path, (registry) => + settleTool(registry, call({ command: "sleep 60", timeout: 50 })), + ).pipe( + Effect.andThen((settled) => + Effect.sync(() => { + expect(settled.output?.structured).toMatchObject({ timeout: true, truncated: false }) + expect(settled.output?.content[1]).toMatchObject({ + type: "text", + text: expect.stringContaining("Command timed out"), + }) + }), + ), + ) + }, + ([data, tmp]) => + Effect.promise(() => + Promise.all([data[Symbol.asyncDispose](), tmp[Symbol.asyncDispose]()]).then(() => undefined), + ), + ), + ) +}) + +test("keeps locked deferred parity TODOs visible", async () => { + const source = await fs.readFile(new URL("../src/tool/shell.ts", import.meta.url), "utf8") + for (const todo of [ + "Port tree-sitter bash / PowerShell parser-based approval reduction.", + "Port BashArity reusable command-prefix approvals.", + "Replace token-based command-argument external-directory advisories with parser-based detection.", + "Restore PowerShell and cmd-specific invocation/path handling on Windows.", + "Add plugin shell.env environment augmentation once V2 plugin hooks exist.", + "Add durable/live progress metadata streaming for long-running commands once V2 tool invocation progress context is wired.", + "Persist background job status and define restart recovery before exposing remote observation.", + "Revisit process-group cleanup and platform coverage with shell-specific tests if current AppProcess semantics do not fully cover it.", + "Revisit binary output handling if stdout/stderr decoding is text-only.", + "Stream full shell output into managed storage while retaining only a bounded in-memory preview.", + ]) { + expect(source).toContain(`TODO: ${todo}`) + } +}) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts index 6446c9d28bc..390f3dd9b70 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts @@ -9,7 +9,7 @@ import { PtyTicket } from "@opencode-ai/core/pty/ticket" import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services" import { Location } from "@opencode-ai/core/location" import { AbsolutePath } from "@opencode-ai/core/schema" -import { Shell } from "@opencode-ai/core/shell" +import { ShellSelect } from "@opencode-ai/core/shell/select" import { CorsConfig, isAllowedRequestOrigin, type CorsOptions } from "@opencode-ai/server/cors" import { PTY_CONNECT_TICKET_QUERY, @@ -58,7 +58,7 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler }) const shells = Effect.fn("PtyHttpApi.shells")(function* () { - return yield* Effect.promise(() => Shell.list()) + return yield* Effect.promise(() => ShellSelect.list()) }) const list = Effect.fn("PtyHttpApi.list")(function* () { diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 60937588120..8a0c713a493 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -35,7 +35,7 @@ import { Tool } from "@/tool/tool" import { Permission } from "@/permission" import { SessionStatus } from "./status" import { LLM } from "./llm" -import { Shell } from "@opencode-ai/core/shell" +import { ShellSelect } from "@opencode-ai/core/shell/select" import { ShellID } from "@/tool/shell/id" import { FSUtil } from "@opencode-ai/core/fs-util" import { Truncate } from "@/tool/truncate" @@ -520,8 +520,8 @@ export const layer = Layer.effect( }).pipe(Effect.ensuring(markReady)) const cfg = yield* config.get() - const sh = Shell.preferred(cfg.shell) - const args = Shell.args(sh, input.command, cwd) + const sh = ShellSelect.preferred(cfg.shell) + const args = ShellSelect.args(sh, input.command, cwd) let output = "" let aborted = false @@ -1396,7 +1396,7 @@ export const layer = Layer.effect( const shellMatches = ConfigMarkdown.shell(template) if (shellMatches.length > 0) { const cfg = yield* config.get() - const sh = Shell.preferred(cfg.shell) + const sh = ShellSelect.preferred(cfg.shell) const results = yield* Effect.promise(() => Promise.all( shellMatches.map(async ([, cmd]) => (await Process.text([cmd], { shell: sh, nothrow: true })).text), diff --git a/packages/opencode/src/tool/shell.ts b/packages/opencode/src/tool/shell.ts index 1e4423e0177..aa500b8eeef 100644 --- a/packages/opencode/src/tool/shell.ts +++ b/packages/opencode/src/tool/shell.ts @@ -12,7 +12,7 @@ import { FSUtil } from "@opencode-ai/core/fs-util" import { fileURLToPath } from "url" import { Config } from "@/config/config" import { RuntimeFlags } from "@/effect/runtime-flags" -import { Shell } from "@opencode-ai/core/shell" +import { ShellSelect } from "@opencode-ai/core/shell/select" import { ShellID } from "./shell/id" import * as Truncate from "./truncate" @@ -291,7 +291,7 @@ const ask = Effect.fn("ShellTool.ask")(function* (ctx: Tool.Context, scan: Scan, }) function cmd(shell: string, command: string, cwd: string, env: NodeJS.ProcessEnv) { - if (process.platform === "win32" && Shell.ps(shell)) { + if (process.platform === "win32" && ShellSelect.ps(shell)) { return ChildProcess.make(shell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command], { cwd, env, @@ -357,7 +357,7 @@ export const ShellTool = Tool.define( const resolvePath = Effect.fn("ShellTool.resolvePath")(function* (text: string, root: string, shell: string) { if (process.platform === "win32") { - if (Shell.posix(shell) && text.startsWith("/") && FSUtil.windowsPath(text) === text) { + if (ShellSelect.posix(shell) && text.startsWith("/") && FSUtil.windowsPath(text) === text) { const file = yield* cygpath(shell, text) if (file) return file } @@ -387,7 +387,7 @@ export const ShellTool = Tool.define( patterns: new Set(), always: new Set(), } - const shellKind = ShellID.toKind(Shell.name(shell)) + const shellKind = ShellID.toKind(ShellSelect.name(shell)) for (const node of commands(root)) { const command = parts(node) @@ -597,8 +597,8 @@ export const ShellTool = Tool.define( return () => Effect.gen(function* () { const cfg = yield* config.get() - const shell = Shell.acceptable(cfg.shell) - const name = Shell.name(shell) + const shell = ShellSelect.acceptable(cfg.shell) + const name = ShellSelect.name(shell) const limits = yield* trunc.limits() const prompt = ShellPrompt.render(name, process.platform, limits, defaultTimeoutMs) yield* Effect.logInfo("shell tool using shell", { shell }) @@ -616,7 +616,7 @@ export const ShellTool = Tool.define( throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`) } const timeout = params.timeout ?? defaultTimeoutMs - const ps = Shell.ps(shell) + const ps = ShellSelect.ps(shell) yield* Effect.scoped( Effect.gen(function* () { const tree = yield* Effect.acquireRelease(parse(params.command, ps), (tree) => diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 2e95bd8748c..dbaa724f76e 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -43,7 +43,7 @@ import { SessionV2 } from "@opencode-ai/core/session" import { SessionExecution } from "@opencode-ai/core/session/execution" import { Skill } from "../../src/skill" import { SystemPrompt } from "../../src/session/system" -import { Shell } from "@opencode-ai/core/shell" +import { ShellSelect } from "@opencode-ai/core/shell/select" import { Snapshot } from "../../src/snapshot" import { ToolRegistry } from "@/tool/registry" import { Truncate } from "@/tool/truncate" @@ -77,7 +77,7 @@ function withSh(fx: () => Effect.Effect) { Effect.sync(() => { const prev = process.env.SHELL process.env.SHELL = "/bin/sh" - Shell.preferred.reset() + ShellSelect.preferred.reset() return prev }), () => fx(), @@ -85,7 +85,7 @@ function withSh(fx: () => Effect.Effect) { Effect.sync(() => { if (prev === undefined) delete process.env.SHELL else process.env.SHELL = prev - Shell.preferred.reset() + ShellSelect.preferred.reset() }), ) } diff --git a/packages/opencode/test/tool/shell.test.ts b/packages/opencode/test/tool/shell.test.ts index 7b98e721d36..05e1824bfbc 100644 --- a/packages/opencode/test/tool/shell.test.ts +++ b/packages/opencode/test/tool/shell.test.ts @@ -5,7 +5,7 @@ import type * as Scope from "effect/Scope" import os from "os" import path from "path" import { Config } from "@/config/config" -import { Shell } from "@opencode-ai/core/shell" +import { ShellSelect } from "@opencode-ai/core/shell/select" import { ShellTool } from "../../src/tool/shell" import { Filesystem } from "@/util/filesystem" import { provideInstance, testInstanceStoreLayer, tmpdirScoped } from "../fixture/fixture" @@ -77,25 +77,25 @@ const ctx = { ask: () => Effect.void, } -Shell.acceptable.reset() +ShellSelect.acceptable.reset() const quote = (text: string) => `"${text}"` const squote = (text: string) => `'${text}'` const projectRoot = path.join(__dirname, "../..") const bin = quote(process.execPath.replaceAll("\\", "/")) const bash = (() => { - const shell = Shell.acceptable() - if (Shell.name(shell) === "bash") return shell - return Shell.gitbash() + const shell = ShellSelect.acceptable() + if (ShellSelect.name(shell) === "bash") return shell + return ShellSelect.gitbash() })() const shells = (() => { if (process.platform !== "win32") { - const shell = Shell.acceptable() - return [{ label: Shell.name(shell), shell }] + const shell = ShellSelect.acceptable() + return [{ label: ShellSelect.name(shell), shell }] } const list = [bash, Bun.which("pwsh"), Bun.which("powershell"), process.env.COMSPEC || Bun.which("cmd.exe")] .filter((shell): shell is string => Boolean(shell)) - .map((shell) => ({ label: Shell.name(shell), shell })) + .map((shell) => ({ label: ShellSelect.name(shell), shell })) return list.filter( (item, i) => list.findIndex((other) => other.shell.toLowerCase() === item.shell.toLowerCase()) === i, @@ -105,7 +105,7 @@ const PS = new Set(["pwsh", "powershell"]) const ps = shells.filter((item) => PS.has(item.label)) const cmdShell = shells.find((item) => item.label === "cmd") -const sh = () => Shell.name(Shell.acceptable()) +const sh = () => ShellSelect.name(ShellSelect.acceptable()) const evalarg = (text: string) => (sh() === "cmd" ? quote(text) : squote(text)) const fill = (mode: "lines" | "bytes", n: number) => { @@ -133,8 +133,8 @@ const withShell = (item: { label: string; shell: string }, self: Effect Effect.sync(() => { const prev = process.env.SHELL process.env.SHELL = item.shell - Shell.acceptable.reset() - Shell.preferred.reset() + ShellSelect.acceptable.reset() + ShellSelect.preferred.reset() return prev }), () => self, @@ -142,8 +142,8 @@ const withShell = (item: { label: string; shell: string }, self: Effect Effect.sync(() => { if (prev === undefined) delete process.env.SHELL else process.env.SHELL = prev - Shell.acceptable.reset() - Shell.preferred.reset() + ShellSelect.acceptable.reset() + ShellSelect.preferred.reset() }), ) @@ -196,7 +196,7 @@ describe("tool.shell", () => { tmp, Effect.gen(function* () { const bash = yield* initBash() - const fallback = Shell.name(Shell.acceptable("fish")) + const fallback = ShellSelect.name(ShellSelect.acceptable("fish")) expect(fallback).not.toBe("fish") expect(bash.description).toContain(fallback) diff --git a/packages/protocol/src/api.ts b/packages/protocol/src/api.ts index 9458873404b..e9e8e8e18dc 100644 --- a/packages/protocol/src/api.ts +++ b/packages/protocol/src/api.ts @@ -15,6 +15,7 @@ import type { Definition } from "@opencode-ai/schema/event" import { AgentGroup } from "./groups/agent" import { HealthGroup } from "./groups/health" import { PtyGroup } from "./groups/pty" +import { ShellGroup } from "./groups/shell" import { makeQuestionGroup } from "./groups/question" import { ReferenceGroup } from "./groups/reference" import { Authorization } from "./middleware/authorization" @@ -52,6 +53,7 @@ const makeApiFromGroup = < .add(SkillGroup.middleware(locationMiddleware)) .add(eventGroup) .add(PtyGroup.middleware(locationMiddleware)) + .add(ShellGroup.middleware(locationMiddleware)) .add(makeQuestionGroup(locationMiddleware, sessionLocationMiddleware)) .add(ReferenceGroup.middleware(locationMiddleware)) .add(ProjectCopyGroup.middleware(locationMiddleware)) diff --git a/packages/protocol/src/errors.ts b/packages/protocol/src/errors.ts index b374546e3ae..27478af786e 100644 --- a/packages/protocol/src/errors.ts +++ b/packages/protocol/src/errors.ts @@ -118,3 +118,12 @@ export class PtyNotFoundError extends Schema.TaggedErrorClass( }, { httpApiStatus: 404 }, ) {} + +export class ShellNotFoundError extends Schema.TaggedErrorClass()( + "ShellNotFoundError", + { + id: Schema.String, + message: Schema.String, + }, + { httpApiStatus: 404 }, +) {} diff --git a/packages/protocol/src/groups/shell.ts b/packages/protocol/src/groups/shell.ts new file mode 100644 index 00000000000..0ed1b911440 --- /dev/null +++ b/packages/protocol/src/groups/shell.ts @@ -0,0 +1,89 @@ +import { Shell } from "@opencode-ai/schema/shell" +import { Location } from "@opencode-ai/schema/location" +import { Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" +import { ShellNotFoundError } from "../errors" +import { LocationQuery, locationQueryOpenApi } from "./location" + +export const ShellGroup = HttpApiGroup.make("server.shell") + .add( + HttpApiEndpoint.get("shell.list", "/api/shell", { + query: LocationQuery, + success: Location.response(Schema.Array(Shell.Info)), + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.shell.list", + summary: "List running shell commands", + description: "List currently running shell commands for a location. Exited commands are not included.", + }), + ), + ) + .add( + HttpApiEndpoint.post("shell.create", "/api/shell", { + query: LocationQuery, + payload: Shell.CreateInput, + success: Location.response(Shell.Info), + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.shell.create", + summary: "Run shell command", + description: + "Spawn one non-interactive shell command for a location. Combined stdout/stderr is captured to a file pageable via output.", + }), + ), + ) + .add( + HttpApiEndpoint.get("shell.get", "/api/shell/:id", { + params: { id: Shell.ID }, + query: LocationQuery, + success: Location.response(Shell.Info), + error: ShellNotFoundError, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.shell.get", + summary: "Get shell command", + description: "Get one shell command, including its status and exit code once exited.", + }), + ), + ) + .add( + HttpApiEndpoint.get("shell.output", "/api/shell/:id/output", { + params: { id: Shell.ID }, + query: Schema.Struct({ ...LocationQuery.fields, ...Shell.OutputInput.fields }), + success: Location.response(Shell.Output), + error: ShellNotFoundError, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.shell.output", + summary: "Read shell output", + description: "Page through captured combined output by absolute byte cursor.", + }), + ), + ) + .add( + HttpApiEndpoint.delete("shell.remove", "/api/shell/:id", { + params: { id: Shell.ID }, + query: LocationQuery, + success: HttpApiSchema.NoContent, + error: ShellNotFoundError, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.shell.remove", + summary: "Remove shell command", + description: "Terminate and remove one shell command and its retained output.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ title: "shell", description: "Experimental location-scoped shell command routes." }), + ) diff --git a/packages/schema/src/event-manifest.ts b/packages/schema/src/event-manifest.ts index b681362e83b..e47bc6ed90f 100644 --- a/packages/schema/src/event-manifest.ts +++ b/packages/schema/src/event-manifest.ts @@ -21,6 +21,7 @@ import { Question } from "./question" import { QuestionV1 } from "./question-v1" import { Reference } from "./reference" import { ServerEvent } from "./server-event" +import { Shell } from "./shell" import { SessionCompactionEvent } from "./session-compaction-event" import { SessionEvent } from "./session-event" import { SessionStatusEvent } from "./session-status-event" @@ -51,6 +52,7 @@ const featureDefinitions = Event.inventory( ...ProjectDirectories.Event.Definitions, ...FileSystemWatcher.Event.Definitions, ...Pty.Event.Definitions, + ...Shell.Event.Definitions, ...Question.Event.Definitions, ) diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index b7c8e5110f7..22b6431e70b 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -18,6 +18,7 @@ export { Revert } from "./revert" export { Session } from "./session" export { SessionInput } from "./session-input" export { SessionMessage } from "./session-message" +export { Shell } from "./shell" export { Skill } from "./skill" export { Pty } from "./pty" export { PtyTicket } from "./pty-ticket" diff --git a/packages/schema/src/shell.ts b/packages/schema/src/shell.ts new file mode 100644 index 00000000000..ac1fc86173a --- /dev/null +++ b/packages/schema/src/shell.ts @@ -0,0 +1,79 @@ +export * as Shell from "./shell" + +import { Schema } from "effect" +import { optional } from "./schema" +import { define, inventory } from "./event" +import { ascending } from "./identifier" +import { NonNegativeInt, statics } from "./schema" + +const IDSchema = Schema.String.check(Schema.isStartsWith("sh_")).pipe(Schema.brand("ShellID")) + +export const ID = IDSchema.pipe( + statics((schema: typeof IDSchema) => { + const create = () => schema.make("sh_" + ascending()) + return { + create, + ascending: (id?: string) => (id === undefined ? create() : schema.make(id)), + } + }), +) +export type ID = typeof ID.Type + +export const Status = Schema.Literals(["running", "exited", "timeout", "killed"]) +export type Status = typeof Status.Type + +export const Time = Schema.Struct({ + started: Schema.Number, + completed: optional(Schema.Number), +}) +export interface Time extends Schema.Schema.Type {} + +// Opaque caller-supplied tags echoed back on Info and events. The Shell service never interprets +// these; callers (e.g. ShellTool stores the originating session ID) use them to filter or correlate. +export const Metadata = Schema.Record(Schema.String, Schema.Unknown) +export type Metadata = typeof Metadata.Type + +export const Info = Schema.Struct({ + id: ID, + status: Status, + command: Schema.String, + cwd: Schema.String, + shell: Schema.String, + // Absolute path of the file capturing combined stdout/stderr. Page through it via `output`. + file: Schema.String, + pid: optional(NonNegativeInt), + exit: optional(Schema.Number), + // Always present; defaults to an empty object when the creator supplies no metadata. + metadata: Metadata, + time: Time, +}).annotate({ identifier: "Shell" }) +export interface Info extends Schema.Schema.Type {} + +const Created = define({ type: "shell.created", schema: { info: Info } }) +const Exited = define({ type: "shell.exited", schema: { id: ID, exit: optional(Schema.Number), status: Status } }) +const Deleted = define({ type: "shell.deleted", schema: { id: ID } }) +export const Event = { Created, Exited, Deleted, Definitions: inventory(Created, Exited, Deleted) } + +export const CreateInput = Schema.Struct({ + command: Schema.String, + cwd: optional(Schema.String), + timeout: optional(NonNegativeInt), + metadata: optional(Metadata), +}) +export interface CreateInput extends Schema.Schema.Type {} + +export const OutputInput = Schema.Struct({ + cursor: optional(NonNegativeInt), + limit: optional(NonNegativeInt), +}) +export interface OutputInput extends Schema.Schema.Type {} + +export const Output = Schema.Struct({ + output: Schema.String, + // Absolute cursor after this page. Equals `size` once fully caught up. + cursor: NonNegativeInt, + // Total bytes captured so far. A consumer has more to page while `cursor < size`. + size: NonNegativeInt, + truncated: Schema.Boolean, +}) +export interface Output extends Schema.Schema.Type {} diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 87e37ffa74e..80aec1244c6 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -385,6 +385,16 @@ import type { V2SessionSwitchModelResponses, V2SessionWaitErrors, V2SessionWaitResponses, + V2ShellCreateErrors, + V2ShellCreateResponses, + V2ShellGetErrors, + V2ShellGetResponses, + V2ShellListErrors, + V2ShellListResponses, + V2ShellOutputErrors, + V2ShellOutputResponses, + V2ShellRemoveErrors, + V2ShellRemoveResponses, V2SkillListErrors, V2SkillListResponses, VcsApplyErrors, @@ -6849,6 +6859,179 @@ export class Pty2 extends HeyApiClient { } } +export class Shell extends HeyApiClient { + /** + * List running shell commands + * + * List currently running shell commands for a location. Exited commands are not included. + */ + public list( + parameters?: { + location?: { + directory?: string + workspace?: string + } + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }]) + return (options?.client ?? this.client).get({ + url: "/api/shell", + ...options, + ...params, + }) + } + + /** + * Run shell command + * + * Spawn one non-interactive shell command for a location. Combined stdout/stderr is captured to a file pageable via output. + */ + public create( + parameters?: { + location?: { + directory?: string + workspace?: string + } + command?: string + cwd?: string + timeout?: number + metadata?: { + [key: string]: unknown + } + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "location" }, + { in: "body", key: "command" }, + { in: "body", key: "cwd" }, + { in: "body", key: "timeout" }, + { in: "body", key: "metadata" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/api/shell", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Remove shell command + * + * Terminate and remove one shell command and its retained output. + */ + public remove( + parameters: { + id: string + location?: { + directory?: string + workspace?: string + } + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "id" }, + { in: "query", key: "location" }, + ], + }, + ], + ) + return (options?.client ?? this.client).delete({ + url: "/api/shell/{id}", + ...options, + ...params, + }) + } + + /** + * Get shell command + * + * Get one shell command, including its status and exit code once exited. + */ + public get( + parameters: { + id: string + location?: { + directory?: string + workspace?: string + } + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "id" }, + { in: "query", key: "location" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/api/shell/{id}", + ...options, + ...params, + }) + } + + /** + * Read shell output + * + * Page through captured combined output by absolute byte cursor. + */ + public output( + parameters: { + id: string + location?: { + directory?: string + workspace?: string + } + cursor?: string + limit?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "id" }, + { in: "query", key: "location" }, + { in: "query", key: "cursor" }, + { in: "query", key: "limit" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/api/shell/{id}/output", + ...options, + ...params, + }) + } +} + export class Request2 extends HeyApiClient { /** * List pending question requests @@ -7095,6 +7278,11 @@ export class V2 extends HeyApiClient { return (this._pty ??= new Pty2({ client: this.client })) } + private _shell?: Shell + get shell(): Shell { + return (this._shell ??= new Shell({ client: this.client })) + } + private _question?: Question3 get question(): Question3 { return (this._question ??= new Question3({ client: this.client })) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 16d6ba7f1ee..9259dad9411 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -65,6 +65,9 @@ export type Event = | EventPtyUpdated | EventPtyExited | EventPtyDeleted + | EventShellCreated + | EventShellExited + | EventShellDeleted | EventQuestionV2Asked | EventQuestionV2Replied | EventQuestionV2Rejected @@ -643,6 +646,7 @@ export type Prompt = { text: string files?: Array agents?: Array + system?: string } export type Pty = { @@ -656,6 +660,24 @@ export type Pty = { exitCode?: number } +export type Shell = { + id: string + status: "running" | "exited" | "timeout" | "killed" + command: string + cwd: string + shell: string + file: string + pid?: number + exit?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + metadata: { + [key: string]: unknown + } + time: { + started: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + completed?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + } +} + export type Todo = { /** * Brief description of the task @@ -1338,6 +1360,29 @@ export type GlobalEvent = { id: string } } + | { + id: string + type: "shell.created" + properties: { + info: Shell + } + } + | { + id: string + type: "shell.exited" + properties: { + id: string + exit?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + status: "running" | "exited" | "timeout" | "killed" + } + } + | { + id: string + type: "shell.deleted" + properties: { + id: string + } + } | { id: string type: "question.v2.asked" @@ -2715,6 +2760,7 @@ export type PromptInput = { text: string files?: Array agents?: Array + system?: string } export type ConflictError = { @@ -2804,6 +2850,24 @@ export type OutputFormat1 = retryCount?: number } +export type Shell1 = { + id: string + status: "running" | "exited" | "timeout" | "killed" + command: string + cwd: string + shell: string + file: string + pid?: number + exit?: number | "NaN" | "Infinity" | "-Infinity" + metadata: { + [key: string]: unknown + } + time: { + started: number | "NaN" | "Infinity" | "-Infinity" + completed?: number | "NaN" | "Infinity" | "-Infinity" + } +} + export type SessionStatus2 = { id: string metadata?: { @@ -2920,6 +2984,9 @@ export type V2Event = | PtyUpdated | PtyExited | PtyDeleted + | ShellCreated + | ShellExited + | ShellDeleted | QuestionV2Asked | QuestionV2Replied | QuestionV2Rejected @@ -2957,6 +3024,12 @@ export type ForbiddenError = { message: string } +export type ShellNotFoundError = { + _tag: "ShellNotFoundError" + id: string + message: string +} + export type ProjectCopyError = { name: "ProjectCopyError" data: { @@ -2969,6 +3042,24 @@ export type EffectHttpApiErrorForbidden = { _tag: "Forbidden" } +export type Shell2 = { + id: string + status: "running" | "exited" | "timeout" | "killed" + command: string + cwd: string + shell: string + file: string + pid?: number + exit?: number | "NaN" | "Infinity" | "-Infinity" + metadata: { + [key: string]: unknown + } + time: { + started: number | "NaN" | "Infinity" | "-Infinity" + completed?: number | "NaN" | "Infinity" | "-Infinity" + } +} + export type EventTuiPromptAppend2 = { id: string type: "tui.prompt.append" @@ -4005,6 +4096,7 @@ export type SessionMessageUser = { text: string files?: Array agents?: Array + system?: string type: "user" } @@ -5641,6 +5733,59 @@ export type PtyDeleted = { } } +export type ShellCreated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "shell.created" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + info: Shell1 + } +} + +export type ShellExited = { + id: string + metadata?: { + [key: string]: unknown + } + type: "shell.exited" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + id: string + exit?: number | "NaN" | "Infinity" | "-Infinity" + status: "running" | "exited" | "timeout" | "killed" + } +} + +export type ShellDeleted = { + id: string + metadata?: { + [key: string]: unknown + } + type: "shell.deleted" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + id: string + } +} + export type QuestionV2Asked = { id: string metadata?: { @@ -6859,6 +7004,32 @@ export type EventPtyDeleted = { } } +export type EventShellCreated = { + id: string + type: "shell.created" + properties: { + info: Shell2 + } +} + +export type EventShellExited = { + id: string + type: "shell.exited" + properties: { + id: string + exit?: number | "NaN" | "Infinity" | "-Infinity" + status: "running" | "exited" | "timeout" | "killed" + } +} + +export type EventShellDeleted = { + id: string + type: "shell.deleted" + properties: { + id: string + } +} + export type EventQuestionV2Asked = { id: string type: "question.v2.asked" @@ -13409,6 +13580,220 @@ export type V2PtyConnectResponses = { export type V2PtyConnectResponse = V2PtyConnectResponses[keyof V2PtyConnectResponses] +export type V2ShellListData = { + body?: never + path?: never + query?: { + location?: { + directory?: string + workspace?: string + } + } + url: "/api/shell" +} + +export type V2ShellListErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2ShellListError = V2ShellListErrors[keyof V2ShellListErrors] + +export type V2ShellListResponses = { + /** + * Success + */ + 200: { + location: LocationInfo + data: Array + } +} + +export type V2ShellListResponse = V2ShellListResponses[keyof V2ShellListResponses] + +export type V2ShellCreateData = { + body: { + command: string + cwd?: string + timeout?: number + metadata?: { + [key: string]: unknown + } + } + path?: never + query?: { + location?: { + directory?: string + workspace?: string + } + } + url: "/api/shell" +} + +export type V2ShellCreateErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2ShellCreateError = V2ShellCreateErrors[keyof V2ShellCreateErrors] + +export type V2ShellCreateResponses = { + /** + * Success + */ + 200: { + location: LocationInfo + data: Shell + } +} + +export type V2ShellCreateResponse = V2ShellCreateResponses[keyof V2ShellCreateResponses] + +export type V2ShellRemoveData = { + body?: never + path: { + id: string + } + query?: { + location?: { + directory?: string + workspace?: string + } + } + url: "/api/shell/{id}" +} + +export type V2ShellRemoveErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * ShellNotFoundError + */ + 404: ShellNotFoundError +} + +export type V2ShellRemoveError = V2ShellRemoveErrors[keyof V2ShellRemoveErrors] + +export type V2ShellRemoveResponses = { + /** + * + */ + 204: void +} + +export type V2ShellRemoveResponse = V2ShellRemoveResponses[keyof V2ShellRemoveResponses] + +export type V2ShellGetData = { + body?: never + path: { + id: string + } + query?: { + location?: { + directory?: string + workspace?: string + } + } + url: "/api/shell/{id}" +} + +export type V2ShellGetErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * ShellNotFoundError + */ + 404: ShellNotFoundError +} + +export type V2ShellGetError = V2ShellGetErrors[keyof V2ShellGetErrors] + +export type V2ShellGetResponses = { + /** + * Success + */ + 200: { + location: LocationInfo + data: Shell + } +} + +export type V2ShellGetResponse = V2ShellGetResponses[keyof V2ShellGetResponses] + +export type V2ShellOutputData = { + body?: never + path: { + id: string + } + query?: { + location?: { + directory?: string + workspace?: string + } + cursor?: string + limit?: string + } + url: "/api/shell/{id}/output" +} + +export type V2ShellOutputErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * ShellNotFoundError + */ + 404: ShellNotFoundError +} + +export type V2ShellOutputError = V2ShellOutputErrors[keyof V2ShellOutputErrors] + +export type V2ShellOutputResponses = { + /** + * Success + */ + 200: { + location: LocationInfo + data: { + output: string + cursor: number + size: number + truncated: boolean + } + } +} + +export type V2ShellOutputResponse = V2ShellOutputResponses[keyof V2ShellOutputResponses] + export type V2QuestionRequestListData = { body?: never path?: never diff --git a/packages/server/src/handlers.ts b/packages/server/src/handlers.ts index d208b5c9da2..fbf11b5d792 100644 --- a/packages/server/src/handlers.ts +++ b/packages/server/src/handlers.ts @@ -12,6 +12,7 @@ import { EventHandler } from "./handlers/event" import { AgentHandler } from "./handlers/agent" import { HealthHandler } from "./handlers/health" import { PtyHandler } from "./handlers/pty" +import { ShellHandler } from "./handlers/shell" import { QuestionHandler } from "./handlers/question" import { ReferenceHandler } from "./handlers/reference" import { LocationHandler } from "./handlers/location" @@ -36,6 +37,7 @@ export const handlers = Layer.mergeAll( SkillHandler, EventHandler, PtyHandler, + ShellHandler, QuestionHandler, ReferenceHandler, ProjectCopyHandler, diff --git a/packages/server/src/handlers/shell.ts b/packages/server/src/handlers/shell.ts new file mode 100644 index 00000000000..6f4c57d5383 --- /dev/null +++ b/packages/server/src/handlers/shell.ts @@ -0,0 +1,69 @@ +import { Shell } from "@opencode-ai/core/shell" +import { Location } from "@opencode-ai/core/location" +import { Effect } from "effect" +import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" +import { ShellNotFoundError } from "@opencode-ai/protocol/errors" +import { Api } from "../api" +import { response } from "../location" + +export const ShellHandler = HttpApiBuilder.group(Api, "server.shell", (handlers) => + Effect.gen(function* () { + return handlers + .handle( + "shell.list", + Effect.fn(function* () { + const shell = yield* Shell.Service + return yield* response(shell.list()) + }), + ) + .handle( + "shell.create", + Effect.fn(function* (ctx) { + const shell = yield* Shell.Service + const location = yield* Location.Service + return yield* response(shell.create({ ...ctx.payload, cwd: ctx.payload.cwd || location.directory })) + }), + ) + .handle( + "shell.get", + Effect.fn(function* (ctx) { + const shell = yield* Shell.Service + return yield* response( + shell.get(ctx.params.id).pipe( + Effect.catchTag( + "Shell.NotFoundError", + () => new ShellNotFoundError({ id: ctx.params.id, message: `Shell command not found: ${ctx.params.id}` }), + ), + ), + ) + }), + ) + .handle( + "shell.output", + Effect.fn(function* (ctx) { + const shell = yield* Shell.Service + return yield* response( + shell.output(ctx.params.id, { cursor: ctx.query.cursor, limit: ctx.query.limit }).pipe( + Effect.catchTag( + "Shell.NotFoundError", + () => new ShellNotFoundError({ id: ctx.params.id, message: `Shell command not found: ${ctx.params.id}` }), + ), + ), + ) + }), + ) + .handle( + "shell.remove", + Effect.fn(function* (ctx) { + const shell = yield* Shell.Service + yield* shell.remove(ctx.params.id).pipe( + Effect.catchTag( + "Shell.NotFoundError", + () => new ShellNotFoundError({ id: ctx.params.id, message: `Shell command not found: ${ctx.params.id}` }), + ), + ) + return HttpApiSchema.NoContent.make() + }), + ) + }), +) diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index a48074cb4e1..f1082fef5e4 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -164,6 +164,9 @@ export function Prompt(props: PromptProps) { .filter((session) => session.parentID === props.sessionID && data.session.status(session.id) === "running") .length, ) + const runningShells = createMemo( + () => data.shell.list().filter((shell) => shell.metadata.sessionID === props.sessionID).length, + ) const history = usePromptHistory() const stash = usePromptStash() const keymap = useOpencodeKeymap() @@ -284,6 +287,20 @@ export function Prompt(props: PromptProps) { } }) + // Far-right footer cluster: live work counts lead, then context/cost usage, all dot-joined. + // When empty, the cluster falls back to the hotkey hints. + const statusItems = createMemo(() => { + const agents = activeSubagents() + const shells = runningShells() + const stats = usage() + return [ + agents ? `${agents} subagent${agents === 1 ? "" : "s"}` : undefined, + shells ? `${shells} shell${shells === 1 ? "" : "s"}` : undefined, + stats?.context, + stats?.cost, + ].filter(Boolean) + }) + const [store, setStore] = createStore<{ prompt: PromptInfo mode: "normal" | "shell" @@ -1548,13 +1565,6 @@ export function Prompt(props: PromptProps) { - - {(count) => ( - - {count()} active subagent{count() === 1 ? "" : "s"} - - )} - 0 ? theme.primary : theme.text}> esc{" "} 0 ? theme.primary : theme.textMuted }}> @@ -1624,22 +1634,20 @@ export function Prompt(props: PromptProps) { - - {(item) => ( - - {[item().context, item().cost].filter(Boolean).join(" · ")} - - )} + 0}> + + {statusItems().join(" · ")} + {agentShortcut()} agents + + {paletteShortcut()} commands + - - {paletteShortcut()} commands - diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 7b1c6a974e1..df3220f5012 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -15,6 +15,7 @@ import type { SessionMessageAssistantText, SessionMessageAssistantTool, SessionV2Info, + Shell, SkillV2Info, V2Event, } from "@opencode-ai/sdk/v2" @@ -47,6 +48,9 @@ type Data = { permission: Record } location: Record + // Currently running shell commands, keyed by shell id. Entries are removed once the command + // exits or is deleted, so this only ever holds in-flight shells. + shell: Record } function locationKey(location: LocationRef) { @@ -72,6 +76,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ permission: {}, }, location: {}, + shell: {}, }) const sdk = useSDK() @@ -467,6 +472,18 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ ), ) break + case "shell.created": + setStore("shell", event.data.info.id, event.data.info) + break + case "shell.exited": + case "shell.deleted": + setStore( + "shell", + produce((draft) => { + delete draft[event.data.id] + }), + ) + break case "reference.updated": void result.location.reference.refresh() break @@ -567,6 +584,23 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }, }, }, + shell: { + list() { + return Object.values(store.shell) + }, + get(id: string) { + return store.shell[id] + }, + async refresh(ref?: LocationRef) { + const result = await sdk.client.v2.shell.list({ location: locationQuery(ref) }, { throwOnError: true }) + setStore( + "shell", + produce((draft) => { + for (const info of result.data.data) draft[info.id] = info + }), + ) + }, + }, location: { default() { return defaultLocation() @@ -682,6 +716,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ result.location.reference.refresh(), result.location.command.refresh(), result.location.skill.refresh(), + result.shell.refresh(), ]) for (const failure of settled.filter((item) => item.status === "rejected")) console.error("Failed to refresh default location data", failure.reason) diff --git a/packages/tui/src/feature-plugins/home/tips-view.tsx b/packages/tui/src/feature-plugins/home/tips-view.tsx index 16354a59ff6..782e23ecaa1 100644 --- a/packages/tui/src/feature-plugins/home/tips-view.tsx +++ b/packages/tui/src/feature-plugins/home/tips-view.tsx @@ -221,8 +221,8 @@ const TIPS: Tip[] = [ "Use {highlight}$ARGUMENTS{/highlight}, {highlight}$1{/highlight}, {highlight}$2{/highlight} in custom commands for dynamic input", "Use backticks in commands to inject shell output (e.g., {highlight}`git status`{/highlight})", "Add {highlight}.md{/highlight} files to {highlight}.opencode/agents/{/highlight} for specialized AI personas", - "Configure per-agent permissions for {highlight}edit{/highlight}, {highlight}bash{/highlight}, and {highlight}webfetch{/highlight} tools", - 'Use patterns like {highlight}"git *": "allow"{/highlight} for granular bash permissions', + "Configure per-agent permissions for {highlight}edit{/highlight}, {highlight}shell{/highlight}, and {highlight}webfetch{/highlight} tools", + 'Use patterns like {highlight}"git *": "allow"{/highlight} for granular shell permissions', 'Set {highlight}"rm -rf *": "deny"{/highlight} to block destructive commands', 'Configure {highlight}"git push": "ask"{/highlight} to require approval before pushing', 'Set {highlight}"formatter": true{/highlight} in config to enable built-in formatters like prettier, gofmt, and ruff', @@ -256,7 +256,7 @@ const TIPS: Tip[] = [ "Use {highlight}instructions{/highlight} in config to load additional rules files", "Set agent {highlight}temperature{/highlight} from 0.0 (focused) to 1.0 (creative)", "Configure {highlight}steps{/highlight} to limit agentic iterations per request", - 'Set {highlight}"tools": {"bash": false}{/highlight} to disable specific tools', + 'Set {highlight}"tools": {"shell": false}{/highlight} to disable specific tools', 'Set {highlight}"mcp_*": false{/highlight} to disable all tools from an MCP server', "Override global tool settings per agent configuration", 'Set {highlight}"share": "auto"{/highlight} to automatically share all sessions', diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 891d644e6d6..8a3c8bffd88 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -1622,7 +1622,7 @@ function ToolPart(props: { part: SessionMessageAssistantTool }) { return ( - + @@ -1646,7 +1646,7 @@ function ToolPart(props: { part: SessionMessageAssistantTool }) { - + @@ -2111,8 +2111,8 @@ function Task(props: ToolProps) { }} > {formatSubagentTitle( - Locale.titlecase(stringValue(props.input.subagent_type) ?? "General"), - description() ?? "Task", + Locale.titlecase(stringValue(props.input.agent) ?? stringValue(props.input.subagent_type) ?? "General"), + description() ?? "Subagent", props.metadata.background === true, )} @@ -2124,7 +2124,7 @@ export function formatSubagentToolcalls(count: number) { } export function formatSubagentTitle(agent: string, description: string, background: boolean) { - return `${agent} Task${background ? " (background)" : ""} — ${description}` + return `${agent} Subagent${background ? " (background)" : ""} — ${description}` } export function formatSubagentRetry(attempt: number, message: string) { @@ -2402,7 +2402,7 @@ function numberValue(value: unknown) { } const toolDisplays = new Set([ - "bash", + "shell", "glob", "read", "grep", @@ -2410,7 +2410,7 @@ const toolDisplays = new Set([ "websearch", "write", "edit", - "task", + "subagent", "apply_patch", "todowrite", "question", @@ -2418,7 +2418,10 @@ const toolDisplays = new Set([ ]) export function toolDisplay(tool: string) { - return toolDisplays.has(tool) ? tool : "generic" + // Legacy transcripts recorded the shell tool as "bash" and the subagent tool as "task"; render + // them with the renamed views. + const normalized = tool === "bash" ? "shell" : tool === "task" ? "subagent" : tool + return toolDisplays.has(normalized) ? normalized : "generic" } function recordValue(value: unknown): Record | undefined { diff --git a/packages/tui/src/routes/session/permission.tsx b/packages/tui/src/routes/session/permission.tsx index 159691a6d1c..91e2189a188 100644 --- a/packages/tui/src/routes/session/permission.tsx +++ b/packages/tui/src/routes/session/permission.tsx @@ -287,7 +287,7 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director } } - if (permission === "bash") { + if (permission === "shell") { const command = typeof data.command === "string" ? data.command : "" return { body: ( @@ -300,12 +300,17 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director } } - if (permission === "task") { - const type = typeof data.subagent_type === "string" ? data.subagent_type : "Unknown" + if (permission === "subagent" || permission === "task") { + const agent = + typeof data.agent === "string" + ? data.agent + : typeof data.subagent_type === "string" + ? data.subagent_type + : "Unknown" const desc = typeof data.description === "string" ? data.description : "" return { icon: "#", - title: `${Locale.titlecase(type)} Task`, + title: `${Locale.titlecase(agent)} Subagent`, body: ( diff --git a/packages/tui/test/cli/tui/__snapshots__/inline-tool-wrap-snapshot.test.tsx.snap b/packages/tui/test/cli/tui/__snapshots__/inline-tool-wrap-snapshot.test.tsx.snap index ab31e7cd03f..6cb0df1e2ef 100644 --- a/packages/tui/test/cli/tui/__snapshots__/inline-tool-wrap-snapshot.test.tsx.snap +++ b/packages/tui/test/cli/tui/__snapshots__/inline-tool-wrap-snapshot.test.tsx.snap @@ -20,68 +20,3 @@ exports[`TUI inline tool wrapping snapshots expanded tool errors under the tool ✱ Grep "export const OPENCODE_DB|OPENCODE_DB|OPENCODE_DEV|Global\\. Path\\.data|data =" in packages/opencode/src (115 matches)" `; - -exports[`TUI inline tool wrapping keeps separation after a shell output block 1`] = ` -" - - $ ls - - file.ts - - ✱ Grep "OPENCODE.*DB|database|sqlite|drizzle|dev.*db|data. - *dir|xdg|APPDATA" in packages/opencode/src (151 matches) - ✱ Glob "**/*db*" in packages/opencode (6 matches) - → Read packages/opencode/src/storage/db.ts [offset=1, limit=130] - → Read packages/opencode/src/index.ts [offset=1, limit=100] - ✱ Grep "export const OPENCODE_DB|OPENCODE_DB|OPENCODE_DEV|Global\\. - Path\\.data|data =" in packages/opencode/src (115 matches)" -`; - -exports[`TUI inline tool wrapping keeps separation after a padded user message 1`] = ` -" - Check whether the next tool remains separated. - - - ✱ Grep "OPENCODE.*DB|database|sqlite|drizzle|dev.*db|data. - *dir|xdg|APPDATA" in packages/opencode/src (151 matches) - ✱ Glob "**/*db*" in packages/opencode (6 matches) - → Read packages/opencode/src/storage/db.ts [offset=1, limit=130] - → Read packages/opencode/src/index.ts [offset=1, limit=100] - ✱ Grep "export const OPENCODE_DB|OPENCODE_DB|OPENCODE_DEV|Global\\. - Path\\.data|data =" in packages/opencode/src (115 matches)" -`; - -exports[`TUI inline tool wrapping separates after a multi-line task row 1`] = ` -" ✱ Grep "Task" (2 matches) - - ⠙ Explore Task — Inspect active task spacing - - ✓ General Task — Confirm completed task spacing - ↳ 1 toolcall · 501ms - - → Read src/cli/cmd/tui/routes/session/index.tsx" -`; - -exports[`TUI inline tool wrapping separates a task row from a preceding inline detail 1`] = ` -" → Read src/cli/cmd/tui/routes/session/index.tsx - ↳ Loaded src/cli/cmd/tui/routes/session/tools.tsx - - ✓ Explore Task — Inspect active task spacing - ↳ 1 toolcall · 501ms" -`; - -exports[`TUI inline tool wrapping separates an inline row from the previous assistant summary 1`] = ` -" Build · Little Frank · 53.1s - - ✓ Build Task — Review changes - ↳ 48 toolcalls · 1m 40s" -`; - -exports[`TUI inline tool wrapping separates an inline row from the previous assistant error 1`] = ` -"│ -│ Managed inference requires an active Member plan -│ - - ✓ Build Task — Review changes - ↳ 48 toolcalls · 1m 40s" -`; diff --git a/packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx b/packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx index dee4c406ff6..31b05ebcf15 100644 --- a/packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx +++ b/packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx @@ -104,7 +104,11 @@ async function renderFrame(component: () => JSX.Element, options: { width: numbe describe("TUI inline tool wrapping", () => { test("falls back for unknown tool names", () => { - expect(toolDisplay("bash")).toBe("bash") + expect(toolDisplay("shell")).toBe("shell") + expect(toolDisplay("subagent")).toBe("subagent") + // Legacy tool names normalize to their renamed views. + expect(toolDisplay("bash")).toBe("shell") + expect(toolDisplay("task")).toBe("subagent") expect(toolDisplay("plugin_tool")).toBe("generic") }) @@ -169,9 +173,9 @@ describe("TUI inline tool wrapping", () => { }) test("keeps background state attached to the subagent identity", () => { - expect(formatSubagentTitle("Explore", "Inspect renderer", false)).toBe("Explore Task — Inspect renderer") + expect(formatSubagentTitle("Explore", "Inspect renderer", false)).toBe("Explore Subagent — Inspect renderer") expect(formatSubagentTitle("Explore", "Inspect renderer", true)).toBe( - "Explore Task (background) — Inspect renderer", + "Explore Subagent (background) — Inspect renderer", ) })