From a4eeaefd5b127453a463e397edf48d77663bb4d5 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 5 Aug 2026 22:27:55 -0400 Subject: [PATCH] fix(core): stat shell workdir through the workspace environment --- packages/core/src/location-services.ts | 1 + packages/core/src/plugin/internal.ts | 10 ++- packages/core/src/plugin/supervisor.ts | 86 +++++++++++++++----------- packages/core/src/tool/plugin/shell.ts | 24 +++++-- 4 files changed, 77 insertions(+), 44 deletions(-) diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts index 3cf8059b3b7..542a941b55d 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -134,6 +134,7 @@ export function buildLocationServiceMap( [LocationMutation.node, LocationMutation.hostedNode], [FileMutation.node, FileMutation.hostedNode], [Shell.node, Shell.hostedNode], + [PluginSupervisor.node, PluginSupervisor.hostedNode], ] : [[Location.node, Location.boundNode(ref)]], ) diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index fc4dcded3b2..5999907c0ae 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -1,7 +1,8 @@ export * as PluginInternal from "./internal" import type { Plugin } from "@opencode-ai/plugin/effect/plugin" -import { Context, Effect, Scope } from "effect" +import { Context, Effect, Option, Scope } from "effect" +import { WorkspaceEnvironment } from "../workspace/environment" import { HttpClient } from "effect/unstable/http" import { Agent } from "../agent" import { Catalog } from "../catalog" @@ -95,7 +96,14 @@ const services = Effect.fn("PluginInternal.services")(function* () { const skill = yield* Skill.Service const tools = yield* Tool.Service const wellknown = yield* WellKnown.Service + // Bound only in hosted Location graphs; plugins read it with serviceOption + // to route filesystem checks at the provider instead of the host. + const environment = yield* Effect.serviceOption(WorkspaceEnvironment.Service) return Context.mergeAll( + Option.match(environment, { + onSome: (value) => Context.make(WorkspaceEnvironment.Service, value), + onNone: () => Context.empty(), + }), Context.make(Agent.Service, agent), Context.make(Catalog.Service, catalog), Context.make(Command.Service, command), diff --git a/packages/core/src/plugin/supervisor.ts b/packages/core/src/plugin/supervisor.ts index 160528b8b8d..79ec0c0d9b4 100644 --- a/packages/core/src/plugin/supervisor.ts +++ b/packages/core/src/plugin/supervisor.ts @@ -40,6 +40,7 @@ import { ReadToolFileSystem } from "../tool/read-filesystem" import { Tool } from "../tool" import { WebSearch } from "../websearch" import { WellKnown } from "../wellknown" +import { WorkspaceEnvironment } from "../workspace/environment" import { PluginInternal } from "./internal" import { PluginRuntime } from "./runtime" import { SdkPlugins } from "./sdk" @@ -282,7 +283,9 @@ const layer = Layer.effect( }) const updates = Stream.merge( config.changes().pipe( - Stream.filterEffect((update) => Effect.map(config.entries(), (entries) => isPluginSource(entries, update.path))), + Stream.filterEffect((update) => + Effect.map(config.entries(), (entries) => isPluginSource(entries, update.path)), + ), Stream.merge(Stream.fromPubSub(configuredChanges)), ), bus.subscribe([Event.Updated, SdkPlugins.Updated]), @@ -308,45 +311,54 @@ const layer = Layer.effect( const nodeLayer = layer as Layer.Layer +const nodeDeps = [ + Plugin.node, + SdkPlugins.node, + Agent.node, + Catalog.node, + Command.node, + Config.node, + Credential.node, + Bus.node, + FileMutation.node, + Formatter.node, + FileSystem.node, + FSUtil.node, + Global.node, + httpClient, + Image.node, + Integration.node, + KV.node, + Location.node, + LocationMutation.node, + ModelsDev.node, + Npm.node, + Permission.node, + PluginRuntime.node, + Form.node, + ReadToolFileSystem.node, + Reference.node, + Ripgrep.node, + SessionInstructions.node, + Shell.node, + Skill.node, + Tool.node, + Watcher.node, + WebSearch.node, + WellKnown.node, +] as const + export const node = makeLocationNode({ service: Service, layer: nodeLayer, - deps: [ - Plugin.node, - SdkPlugins.node, - Agent.node, - Catalog.node, - Command.node, - Config.node, - Credential.node, - Bus.node, - FileMutation.node, - Formatter.node, - FileSystem.node, - FSUtil.node, - Global.node, - httpClient, - Image.node, - Integration.node, - KV.node, - Location.node, - LocationMutation.node, - ModelsDev.node, - Npm.node, - Permission.node, - PluginRuntime.node, - Form.node, - ReadToolFileSystem.node, - Reference.node, - Ripgrep.node, - SessionInstructions.node, - Shell.node, - Skill.node, - Tool.node, - Watcher.node, - WebSearch.node, - WellKnown.node, - ], + deps: nodeDeps, +}) + +/** Hosted graphs bind the workspace environment so internal plugins can reach it. */ +export const hostedNode = makeLocationNode({ + service: Service, + layer: nodeLayer, + deps: [...nodeDeps, WorkspaceEnvironment.node], }) export { layer } diff --git a/packages/core/src/tool/plugin/shell.ts b/packages/core/src/tool/plugin/shell.ts index 16fe6a5f774..ab9efc41290 100644 --- a/packages/core/src/tool/plugin/shell.ts +++ b/packages/core/src/tool/plugin/shell.ts @@ -4,8 +4,9 @@ import path from "path" import { ToolFailure } from "@opencode-ai/ai" import type { Content } from "@opencode-ai/schema/tool" import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin" -import { Deferred, Effect, Schema, Scope } from "effect" +import { Deferred, Effect, Option, Schema, Scope } from "effect" import { FSUtil } from "@opencode-ai/util/fs-util" +import { WorkspaceEnvironment } from "../../workspace/environment" import { LocationMutation } from "../../location-mutation" import { Permission } from "../../permission" import { PluginRuntime } from "../../plugin/runtime" @@ -83,6 +84,21 @@ export const Plugin = { const runtime = yield* PluginRuntime.Service const scope = yield* Scope.Scope const fsUtil = yield* FSUtil.Service + // Hosted Locations bind the workspace environment; the workdir check must + // stat the provider filesystem there, never the host's. + const environment = Option.getOrUndefined(yield* Effect.serviceOption(WorkspaceEnvironment.Service)) + const statWorkdir = (canonical: string) => + environment + ? environment.files.stat(canonical).pipe( + Effect.catchTag("WorkspaceEnvironment.NotFoundError", () => + Effect.fail(new Error(`Working directory does not exist: ${canonical}`)), + ), + ) + : fsUtil.stat(canonical).pipe( + Effect.catchReason("PlatformError", "NotFound", () => + Effect.fail(new Error(`Working directory does not exist: ${canonical}`)), + ), + ) const mutation = yield* LocationMutation.Service const shell = yield* Shell.Service const permission = yield* Permission.Service @@ -176,11 +192,7 @@ export const Plugin = { agent: context.agent, source, }) - const workdir = yield* fsUtil.stat(target.canonical).pipe( - Effect.catchReason("PlatformError", "NotFound", () => - Effect.fail(new Error(`Working directory does not exist: ${target.canonical}`)), - ), - ) + const workdir = yield* statWorkdir(target.canonical) if (workdir.type !== "Directory") return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.canonical}`)) }),