From 395eb8a9c87bf46b7576ccf15b6592e294e6b7ac Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 5 Aug 2026 21:06:48 -0400 Subject: [PATCH] feat(core): run hosted shell commands through workspace environment --- packages/core/src/location-services.ts | 1 + packages/core/src/shell.ts | 70 ++++++++++++++++++++------ 2 files changed, 57 insertions(+), 14 deletions(-) diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts index 136f7eb7bc..2cc72d736d 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -132,6 +132,7 @@ export function buildLocationServiceMap( [FileSystem.node, FileSystem.hostedNode], [LocationMutation.node, LocationMutation.hostedNode], [FileMutation.node, FileMutation.hostedNode], + [Shell.node, Shell.hostedNode], ] : [[Location.node, Location.boundNode(ref)]], ) diff --git a/packages/core/src/shell.ts b/packages/core/src/shell.ts index 16318b55a7..7bfd29da8f 100644 --- a/packages/core/src/shell.ts +++ b/packages/core/src/shell.ts @@ -12,6 +12,7 @@ import { Bus } from "./bus" import { Location } from "./location" import { Global } from "@opencode-ai/util/global" import { ShellSelect } from "./shell/select" +import { WorkspaceEnvironment } from "./workspace/environment" import type { ShellCreateBefore } from "@opencode-ai/plugin/effect/shell" import { PluginHooks } from "./plugin/hooks" @@ -65,14 +66,24 @@ export interface Interface { export class Service extends Context.Service()("@opencode/Shell") {} -export const layer = (options?: ShellSelect.Options) => Layer.effect( +/** The provider-facing seams; everything else in this service is host-owned bookkeeping. */ +interface Backend { + readonly spawn: (command: ChildProcess.Command) => ReturnType + /** Shell executable; re-evaluated per command so config changes apply. */ + readonly shell: Effect.Effect + readonly args: (shell: string, command: string) => readonly string[] + /** Base environment for spawned commands. Hosted backends must not leak host process.env. */ + readonly env: Readonly> + readonly detached: boolean +} + +const layerWith = (backend: Effect.Effect) => Layer.effect( Service, Effect.gen(function* () { const bus = yield* Bus.Service const location = yield* Location.Service - const config = yield* Config.Service const global = yield* Global.Service - const appProcess = yield* AppProcess.Service + const spawner = yield* backend const hooks = yield* PluginHooks.Service const context = yield* Effect.context() const runFork = Effect.runForkWith(context) @@ -141,12 +152,7 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect( return session.info }) - const resolve = () => - config - .entries() - .pipe(Effect.map((entries) => ShellSelect.preferred(Config.latest(entries, "shell"), options))) - - const name = () => resolve().pipe(Effect.map(ShellSelect.name)) + const name = () => spawner.shell.pipe(Effect.map(ShellSelect.name)) const output = Effect.fn("Shell.output")(function* (id: Shell.ID, input?: Shell.OutputInput) { const session = yield* require(id) @@ -186,9 +192,9 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect( command: input.command, cwd: input.cwd ?? location.directory, timeout: input.timeout, - shell: yield* resolve(), + shell: yield* spawner.shell, env: { - ...process.env, + ...spawner.env, TERM: "xterm-256color", OPENCODE_TERMINAL: "1", }, @@ -197,7 +203,7 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect( if (before) yield* before(invocation) const id = Shell.ID.ascending() - const args = ShellSelect.args(invocation.shell, invocation.command) + const args = spawner.args(invocation.shell, invocation.command) const file = path.join(outputDir, `${id}.out`) const info: Info = { @@ -218,12 +224,12 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect( runFork( Effect.scoped( Effect.gen(function* () { - const handle = yield* appProcess.spawn( + const handle = yield* spawner.spawn( ChildProcess.make(invocation.shell, args, { cwd: invocation.cwd, env: invocation.env, stdin: "ignore", - detached: process.platform !== "win32", + detached: spawner.detached, forceKillAfter: Duration.seconds(3), }), ) @@ -339,6 +345,23 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect( }), ) +export const layer = (options?: ShellSelect.Options) => + layerWith( + Effect.gen(function* () { + const config = yield* Config.Service + const appProcess = yield* AppProcess.Service + return { + spawn: (command) => appProcess.spawn(command), + shell: config + .entries() + .pipe(Effect.map((entries) => ShellSelect.preferred(Config.latest(entries, "shell"), options))), + args: ShellSelect.args, + env: process.env, + detached: process.platform !== "win32", + } satisfies Backend + }), + ) + export function configured(options?: ShellSelect.Options) { return makeLocationNode({ service: Service, @@ -348,3 +371,22 @@ export function configured(options?: ShellSelect.Options) { } export const node = configured() + +// Commands run inside the workspace: provider spawner, image shell lowering, +// and no host process.env leakage. Output capture files remain host-owned. +export const hostedNode = makeLocationNode({ + service: Service, + layer: layerWith( + Effect.gen(function* () { + const env = yield* WorkspaceEnvironment.Service + return { + spawn: (command) => env.process.spawn(command), + shell: Effect.succeed(env.shell.executable), + args: (_shell, command) => env.shell.args(command), + env: env.shell.environmentOverrides, + detached: env.shell.detached, + } satisfies Backend + }), + ), + deps: [Bus.node, Location.node, Global.node, WorkspaceEnvironment.node, PluginHooks.node], +})