diff --git a/packages/cli/script/build.ts b/packages/cli/script/build.ts index ce7acca0e1d..dbb38f05f75 100755 --- a/packages/cli/script/build.ts +++ b/packages/cli/script/build.ts @@ -111,7 +111,7 @@ export default { path: file, version: ${JSON.stringify(opencodePty.version)}, sh const parcelWatcherPlugin: BunPlugin = { name: "parcel-watcher-binding", setup(build) { - build.onLoad({ filter: /filesystem\/watcher-binding\.ts$/ }, () => ({ + build.onLoad({ filter: /filesystem[/\\]watcher-binding\.ts$/ }, () => ({ contents: `export default () => require(${JSON.stringify(parcelWatcherPackage)})`, loader: "js", })) diff --git a/packages/core/src/config/plugin/agent.ts b/packages/core/src/config/plugin/agent.ts index b3c37a2f3da..ba402bdc12f 100644 --- a/packages/core/src/config/plugin/agent.ts +++ b/packages/core/src/config/plugin/agent.ts @@ -13,7 +13,7 @@ import { ConfigAgentV1 } from "../../v1/config/agent.js" import { ConfigMigrateV1 } from "../../v1/config/migrate.js" import { Global } from "@opencode-ai/util/global" import { Permission } from "../../permission.js" -import type { LocationMutation } from "../../location-mutation.js" +import type { FileAccess } from "../../file-access.js" import type { ReadTool } from "../../tool/plugin/read.js" import type { EditTool } from "../../tool/plugin/edit.js" import { AbsolutePath } from "../../schema.js" @@ -27,10 +27,7 @@ const sourceDirectories = ["agent", "agents", "mode", "modes"] as const const decodeAgent = Schema.decodeUnknownOption(ConfigAgent.Info) const decodeLegacyAgent = Schema.decodeUnknownOption(ConfigAgentV1.Info) const decodeConfig = Schema.decodeUnknownOption(Info) -type PathAction = - | LocationMutation.ExternalDirectoryAuthorization["action"] - | typeof ReadTool.name - | typeof EditTool.name +type PathAction = FileAccess.ExternalDirectoryAuthorization["action"] | typeof ReadTool.name | typeof EditTool.name const pathActions = ["external_directory", "read", "edit"] as const satisfies readonly PathAction[] const agentKeys = new Set(["variant", ...Object.keys(ConfigAgent.Info.fields)]) diff --git a/packages/core/src/file-access.ts b/packages/core/src/file-access.ts new file mode 100644 index 00000000000..e30139c3932 --- /dev/null +++ b/packages/core/src/file-access.ts @@ -0,0 +1,173 @@ +export * as FileAccess from "./file-access.js" + +import { makeLocationNode } from "@opencode-ai/util/effect/app-node" +import { FSUtil } from "@opencode-ai/util/fs-util" +import { Global } from "@opencode-ai/util/global" +import { Array, Context, Effect, Layer, Schema } from "effect" +import path from "path" +import { Location } from "./location.js" +import { Permission } from "./permission.js" +import { Project } from "./project.js" +import { AbsolutePath } from "./schema.js" +import type { SessionErrors } from "./session/error.js" +import type { Tool } from "./tool.js" + +export const Kind = Schema.Literals(["file", "directory"]) +export type Kind = typeof Kind.Type + +export const ResolveInput = Schema.Struct({ + path: Schema.String, + /** Selects the external approval boundary; it does not validate the target type. */ + kind: Kind.pipe(Schema.optional), +}) +export type ResolveInput = typeof ResolveInput.Type + +export interface ExternalDirectoryAuthorization { + readonly action: "external_directory" + /** Lexical directory used as the external approval boundary. */ + readonly directory: AbsolutePath + readonly resource: string + readonly save: string +} + +export const externalDirectoryPermission = (input: ExternalDirectoryAuthorization) => ({ + action: input.action, + resources: [input.resource], + save: [input.save], +}) + +export interface Target { + readonly absolute: AbsolutePath + /** Location-relative for internal paths, absolute for external paths. */ + readonly resource: string + readonly externalDirectory?: ExternalDirectoryAuthorization +} + +export type Invocation = Pick + +export interface ReadOptions { + /** A target already authorized by this invocation, used for filename recovery. */ + readonly siblingOf: Target +} + +export interface Interface { + /** Resolve a lexical path and its permission resources, without requesting approval. */ + readonly resolve: (input: ResolveInput) => Effect.Effect + /** Approve external directories in one batch, preserving first-seen resource order. */ + readonly authorizeExternal: ( + targets: readonly Target[], + context: Invocation, + metadata?: Permission.AssertInput["metadata"], + ) => Effect.Effect + /** Resolve a read target and obtain external-directory approval before read approval. */ + readonly authorizeRead: ( + file: string, + context: Invocation, + options?: ReadOptions, + ) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/FileAccess") {} + +/** Expand a leading ~ and normalize Windows shell paths before lexical resolution. */ +export const resolvePath = (directory: string, input: string, home = Global.Path.home) => { + const normalized = FSUtil.windowsPath(input) + return path.resolve( + directory, + normalized === "~" + ? home + : normalized.startsWith("~/") || (process.platform === "win32" && normalized.startsWith("~\\")) + ? path.join(home, normalized.slice(2)) + : normalized, + ) +} + +const slash = (value: string) => value.replaceAll("\\", "/") +const invocation = (context: Invocation) => ({ + sessionID: context.sessionID, + agent: context.agent, + source: { type: "tool" as const, messageID: context.messageID, id: context.id }, +}) + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const location = yield* Location.Service + const permission = yield* Permission.Service + + const resolve = Effect.fn("FileAccess.resolve")(function* (input: ResolveInput) { + const absolute = AbsolutePath.make(resolvePath(location.directory, input.path)) + const worktree = path.resolve(location.project.directory) + const internal = + FSUtil.contains(location.directory, absolute) || + (worktree !== path.parse(worktree).root && FSUtil.contains(worktree, absolute)) + if (internal) { + return { + absolute, + resource: slash(path.relative(location.directory, absolute) || "."), + } satisfies Target + } + const type = + input.kind === "directory" + ? "Directory" + : input.kind === "file" + ? "File" + : (yield* fs.stat(absolute).pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.undefined))) + ?.type + const directory = AbsolutePath.make(type === "Directory" ? absolute : path.dirname(absolute)) + return { + absolute, + resource: slash(absolute), + externalDirectory: { + action: "external_directory", + directory, + resource: slash(path.join(directory, "*")), + save: slash(path.join((yield* Project.root(fs, directory)) ?? directory, "*")), + }, + } satisfies Target + }) + + const authorizeExternal = Effect.fn("FileAccess.authorizeExternal")(function* ( + targets: readonly Target[], + context: Invocation, + metadata?: Permission.AssertInput["metadata"], + ) { + const external = Array.dedupeWith( + targets.flatMap((target) => (target.externalDirectory ? [target.externalDirectory] : [])), + (left, right) => left.resource === right.resource, + ) + if (external.length === 0) return + yield* permission.assert({ + action: "external_directory", + resources: external.map((item) => item.resource), + save: external.map((item) => item.save), + ...(metadata === undefined ? {} : { metadata }), + ...invocation(context), + }) + }) + + const authorizeRead = Effect.fn("FileAccess.authorizeRead")(function* ( + file: string, + context: Invocation, + options?: ReadOptions, + ) { + const target = yield* resolve({ path: file, kind: options ? "file" : undefined }) + const sibling = options && path.dirname(target.absolute) === path.dirname(options.siblingOf.absolute) + + // Filename recovery shares the directory approval, but checks the recovered file's own read rules. + if (!sibling) yield* authorizeExternal([target], context) + yield* permission.assert({ + action: "read", + resources: [target.resource], + save: ["*"], + ...invocation(context), + }) + return target + }) + + return Service.of({ resolve, authorizeExternal, authorizeRead }) + }), +) + +export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node, Location.node, Permission.node] }) diff --git a/packages/core/src/file-mutation.ts b/packages/core/src/file-mutation.ts index c043dc0e9b5..2914868273a 100644 --- a/packages/core/src/file-mutation.ts +++ b/packages/core/src/file-mutation.ts @@ -7,11 +7,9 @@ import { FSUtil } from "@opencode-ai/util/fs-util" import { Bom } from "@opencode-ai/util/bom" import { Environment } from "./environment/index.js" import type { Files } from "./environment/index.js" +import type { FileAccess } from "./file-access.js" -export interface Target { - readonly absolute: string - readonly resource: string -} +export type Target = Pick export interface WriteInput { readonly target: Target diff --git a/packages/core/src/instance.ts b/packages/core/src/instance.ts index e3ddac1bcf2..cf600c54111 100644 --- a/packages/core/src/instance.ts +++ b/packages/core/src/instance.ts @@ -17,7 +17,7 @@ import { Image } from "./image.js" import { LocationWatcher } from "./filesystem/location-watcher.js" import { Integration } from "./integration.js" import { Location } from "./location.js" -import { LocationMutation } from "./location-mutation.js" +import { FileAccess } from "./file-access.js" import { ModelResolver } from "./model-resolver.js" import { Mcp } from "./mcp/index.js" import { Permission } from "./permission.js" @@ -82,7 +82,7 @@ const nodes = [ Skill.node, InstructionBuiltIns.node, InstructionDiscovery.node, - LocationMutation.node, + FileAccess.node, FileMutation.node, Formatter.node, Mcp.node, diff --git a/packages/core/src/location-mutation.ts b/packages/core/src/location-mutation.ts index 0d8044a9923..8390377cc8e 100644 --- a/packages/core/src/location-mutation.ts +++ b/packages/core/src/location-mutation.ts @@ -1,130 +1,3 @@ -export * as LocationMutation from "./location-mutation.js" - -import { makeLocationNode } from "@opencode-ai/util/effect/app-node" -import path from "path" -import { Context, Effect, Layer, Schema } from "effect" -import { FSUtil } from "@opencode-ai/util/fs-util" -import { Global } from "@opencode-ai/util/global" -import { Location } from "./location.js" -import { Project } from "./project.js" -import { AbsolutePath } from "./schema.js" - -export const Kind = Schema.Literals(["file", "directory"]) -export type Kind = typeof Kind.Type - -/** - * Mutation paths do not accept project references. A leading `~` expands to - * the home directory; other relative paths resolve from the active Location. - * Paths outside it and its non-root project worktree require separate - * `external_directory` approval. - */ -export const ResolveInput = Schema.Struct({ - path: Schema.String, - /** Selects the external approval boundary; it does not validate the target type. */ - kind: Kind.pipe(Schema.optional), -}) -export type ResolveInput = typeof ResolveInput.Type - -export interface ExternalDirectoryAuthorization { - readonly action: "external_directory" - /** Lexical directory used as the external approval boundary. */ - readonly directory: string - /** `external_directory` permission resource. */ - readonly resource: string - readonly save: string -} - -export const externalDirectoryPermission = (input: ExternalDirectoryAuthorization) => ({ - action: input.action, - resources: [input.resource], - save: [input.save], -}) - -export interface Target { - /** Absolute lexical path. */ - readonly absolute: string - /** Permission resource: Location-relative for internal paths, absolute for external paths. */ - readonly resource: string - readonly externalDirectory?: ExternalDirectoryAuthorization -} - -export interface Interface { - /** - * Resolve a path and derive its permission resources. A leading `~` expands - * to the home directory; other relative paths resolve from the Location. - * Paths outside it and its non-root project worktree require separate - * `external_directory` approval. This does not approve the mutation. - */ - readonly resolve: (input: ResolveInput) => Effect.Effect -} - -/** Lexical absolute path, normalizing Windows shell paths and expanding `~` before resolution. */ -export const resolvePath = (directory: string, input: string, home = Global.Path.home) => { - const normalized = FSUtil.windowsPath(input) - return path.resolve( - directory, - normalized === "~" - ? home - : normalized.startsWith("~/") || (process.platform === "win32" && normalized.startsWith("~\\")) - ? path.join(home, normalized.slice(2)) - : normalized, - ) -} - -export class Service extends Context.Service()("@opencode/LocationMutation") {} - -const slash = (value: string) => value.replaceAll("\\", "/") - -const layer = Layer.effect( - Service, - Effect.gen(function* () { - const fs = yield* FSUtil.Service - const location = yield* Location.Service - - const resolve = Effect.fnUntraced(function* (input: ResolveInput) { - const absolute = resolvePath(location.directory, input.path) - const worktree = path.resolve(location.project.directory) - const internal = - FSUtil.contains(location.directory, absolute) || - (worktree !== path.parse(worktree).root && FSUtil.contains(worktree, absolute)) - if (internal) { - return { - absolute, - resource: slash(path.relative(location.directory, absolute) || "."), - } satisfies Target - } - const type = - input.kind === "directory" - ? "Directory" - : input.kind === "file" - ? "File" - : (yield* fs.stat(absolute).pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.undefined))) - ?.type - const externalDirectory = type === "Directory" ? absolute : path.dirname(absolute) - const externalResource = slash(path.join(externalDirectory, "*")) - return { - absolute, - resource: slash(absolute), - externalDirectory: { - action: "external_directory", - directory: externalDirectory, - resource: externalResource, - save: slash( - path.join( - (yield* Project.root(fs, AbsolutePath.make(externalDirectory))) ?? externalDirectory, - "*", - ), - ), - }, - } satisfies Target - }) - - return Service.of({ resolve }) - }), -) - -export const node = makeLocationNode({ - service: Service, - layer, - deps: [FSUtil.node, Location.node], -}) +/** @deprecated Use FileAccess for path resolution and authorization. */ +export { FileAccess as LocationMutation } from "./file-access.js" +export * from "./file-access.js" diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index 5db3ce82145..e71ca7e273d 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -31,6 +31,7 @@ import { ConfigWorktreePlugin } from "../config/plugin/worktree.js" import { Worktree } from "../worktree.js" import { Bus } from "../bus.js" import { Environment } from "../environment/index.js" +import { FileAccess } from "../file-access.js" import { FileMutation } from "../file-mutation.js" import { Formatter } from "../formatter.js" import { Form } from "../form.js" @@ -44,7 +45,6 @@ import { Integration } from "../integration.js" import { Job } from "../job.js" import { KV } from "../kv.js" import { Location } from "../location.js" -import { LocationMutation } from "../location-mutation.js" import { ModelsDev } from "../models-dev.js" import { Mcp } from "../mcp/index.js" import { Npm } from "@opencode-ai/util/npm" @@ -103,6 +103,7 @@ const services = [ Credential.Service, Bus.Service, Environment.Service, + FileAccess.Service, FileMutation.Service, Formatter.Service, LocationWatcherPolicy.Service, @@ -116,7 +117,6 @@ const services = [ Job.Service, KV.Service, Location.Service, - LocationMutation.Service, ModelsDev.Service, Mcp.Service, Npm.Service, @@ -152,6 +152,7 @@ export const requirements = LayerNode.group([ Credential.node, Bus.node, Environment.node, + FileAccess.node, FileMutation.node, Formatter.node, LocationWatcherPolicy.node, @@ -165,7 +166,6 @@ export const requirements = LayerNode.group([ Job.node, KV.node, Location.node, - LocationMutation.node, ModelsDev.node, Mcp.node, Npm.node, diff --git a/packages/core/src/tool/plugin/edit.ts b/packages/core/src/tool/plugin/edit.ts index 165cfef6cea..d5033c76969 100644 --- a/packages/core/src/tool/plugin/edit.ts +++ b/packages/core/src/tool/plugin/edit.ts @@ -15,7 +15,7 @@ import { Environment } from "../../environment/index.js" import { FileMutation } from "../../file-mutation.js" import { Formatter } from "../../formatter.js" import { Location } from "../../location.js" -import { LocationMutation } from "../../location-mutation.js" +import { FileAccess } from "../../file-access.js" import { Permission } from "../../permission.js" import { fileDiff } from "./file-diff.js" @@ -109,7 +109,7 @@ const findLineOccurrences = (content: string, search: string) => { export const Plugin = { id: "opencode.tool.edit", effect: Effect.fn("EditTool.Plugin")(function* (ctx: Context) { - const mutation = yield* LocationMutation.Service + const access = yield* FileAccess.Service const fileMutation = yield* FileMutation.Service const environment = yield* Environment.Service const formatter = yield* Formatter.Service @@ -143,16 +143,8 @@ export const Plugin = { }) } - const target = yield* mutation.resolve({ path: input.path, kind: "file" }) - const external = target.externalDirectory - if (external) { - yield* permission.assert({ - ...LocationMutation.externalDirectoryPermission(external), - sessionID: context.sessionID, - agent: context.agent, - source: permissionSource, - }) - } + const target = yield* access.resolve({ path: input.path, kind: "file" }) + yield* access.authorizeExternal([target], context) const original = yield* FileMutation.readText(environment.files, target.absolute).pipe( Effect.catchTag("Environment.NotFound", () => @@ -218,7 +210,7 @@ export const Plugin = { replacements, } satisfies Output }).pipe( - fileMutation.withLock([LocationMutation.resolvePath(location.directory, input.path)]), + fileMutation.withLock([FileAccess.resolvePath(location.directory, input.path)]), Effect.map((output) => ({ output, content: `Edited ${output.files[0]?.file} (${output.replacements} replacement${output.replacements === 1 ? "" : "s"})`, diff --git a/packages/core/src/tool/plugin/glob.ts b/packages/core/src/tool/plugin/glob.ts index b47df989292..6847e106e09 100644 --- a/packages/core/src/tool/plugin/glob.ts +++ b/packages/core/src/tool/plugin/glob.ts @@ -7,7 +7,7 @@ import path from "path" import { Environment } from "../../environment/index.js" import { FileSystem } from "../../filesystem.js" import { Location } from "../../location.js" -import { LocationMutation } from "../../location-mutation.js" +import { FileAccess } from "../../file-access.js" import { Ripgrep } from "../../ripgrep.js" import { RelativePath } from "../../schema.js" import { Permission } from "../../permission.js" @@ -48,7 +48,7 @@ export const Plugin = { const environment = yield* Environment.Service const ripgrep = yield* Ripgrep.Service const location = yield* Location.Service - const mutation = yield* LocationMutation.Service + const access = yield* FileAccess.Service const permission = yield* Permission.Service yield* ctx.tool @@ -63,15 +63,8 @@ export const Plugin = { Effect.gen(function* () { const searchPath = input.path === "undefined" || input.path === "null" ? undefined : input.path const source = { type: "tool" as const, messageID: context.messageID, id: context.id } - const target = yield* mutation.resolve({ path: searchPath ?? ".", kind: "directory" }) - const external = target.externalDirectory - if (external) - yield* permission.assert({ - ...LocationMutation.externalDirectoryPermission(external), - sessionID: context.sessionID, - agent: context.agent, - source, - }) + const target = yield* access.resolve({ path: searchPath ?? ".", kind: "directory" }) + yield* access.authorizeExternal([target], context) yield* permission.assert({ action: name, resources: [input.pattern], diff --git a/packages/core/src/tool/plugin/grep.ts b/packages/core/src/tool/plugin/grep.ts index be4e5076fbe..0f1b321da0b 100644 --- a/packages/core/src/tool/plugin/grep.ts +++ b/packages/core/src/tool/plugin/grep.ts @@ -7,7 +7,7 @@ import path from "path" import { Environment } from "../../environment/index.js" import { FileSystem } from "../../filesystem.js" import { Location } from "../../location.js" -import { LocationMutation } from "../../location-mutation.js" +import { FileAccess } from "../../file-access.js" import { Permission } from "../../permission.js" import { Ripgrep } from "../../ripgrep.js" import { RelativePath } from "../../schema.js" @@ -67,7 +67,7 @@ export const Plugin = { const environment = yield* Environment.Service const ripgrep = yield* Ripgrep.Service const location = yield* Location.Service - const mutation = yield* LocationMutation.Service + const access = yield* FileAccess.Service const permission = yield* Permission.Service yield* ctx.tool @@ -82,14 +82,8 @@ export const Plugin = { execute: (input, context) => Effect.gen(function* () { const source = { type: "tool" as const, messageID: context.messageID, id: context.id } - const target = yield* mutation.resolve({ path: input.path ?? "." }) - if (target.externalDirectory) - yield* permission.assert({ - ...LocationMutation.externalDirectoryPermission(target.externalDirectory), - sessionID: context.sessionID, - agent: context.agent, - source, - }) + const target = yield* access.resolve({ path: input.path ?? "." }) + yield* access.authorizeExternal([target], context) yield* permission.assert({ action: name, resources: [input.pattern], diff --git a/packages/core/src/tool/plugin/patch.ts b/packages/core/src/tool/plugin/patch.ts index 3efd3cc682f..1a3e73dce83 100644 --- a/packages/core/src/tool/plugin/patch.ts +++ b/packages/core/src/tool/plugin/patch.ts @@ -9,7 +9,7 @@ import { Environment } from "../../environment/index.js" import { Formatter } from "../../formatter.js" import { FileMutation } from "../../file-mutation.js" import { Location } from "../../location.js" -import { LocationMutation } from "../../location-mutation.js" +import { FileAccess } from "../../file-access.js" import { Patch } from "@opencode-ai/util/patch" import { Permission } from "../../permission.js" import DESCRIPTION from "../patch.txt" @@ -45,29 +45,29 @@ export const toModelContent = (output: Output) => type Prepared = | (Extract & { - readonly target: LocationMutation.Target + readonly target: FileAccess.Target readonly content: string readonly before: string readonly after: string }) | (Extract & { - readonly target: LocationMutation.Target + readonly target: FileAccess.Target readonly before: string readonly after: string }) | (Extract & { - readonly target: LocationMutation.Target + readonly target: FileAccess.Target readonly content: string readonly before: string readonly after: string - readonly moveTarget?: LocationMutation.Target + readonly moveTarget?: FileAccess.Target }) export const Plugin = { id: "opencode.tool.patch", effect: Effect.fn("PatchTool.Plugin")(function* (ctx: Context) { const environment = yield* Environment.Service - const mutation = yield* LocationMutation.Service + const access = yield* FileAccess.Service const fileMutation = yield* FileMutation.Service const formatter = yield* Formatter.Service const location = yield* Location.Service @@ -86,9 +86,9 @@ export const Plugin = { const parsed = Patch.parse(input.patchText) const lockTargets = Result.isSuccess(parsed) ? parsed.success.flatMap((hunk) => [ - LocationMutation.resolvePath(location.directory, hunk.path), + FileAccess.resolvePath(location.directory, hunk.path), ...(hunk.type === "update" && hunk.movePath - ? [LocationMutation.resolvePath(location.directory, hunk.movePath)] + ? [FileAccess.resolvePath(location.directory, hunk.movePath)] : []), ]) : [] @@ -114,17 +114,11 @@ export const Plugin = { const prepared: Prepared[] = [] const updates = new Map() const resolveTarget = Effect.fnUntraced(function* (value: string) { - const target = yield* mutation.resolve({ path: value, kind: "file" }) + const target = yield* access.resolve({ path: value, kind: "file" }) if (!target.externalDirectory) return target - yield* permission.assert({ - ...LocationMutation.externalDirectoryPermission(target.externalDirectory), - metadata: { - filepath: target.absolute, - parentDir: target.externalDirectory.directory, - }, - sessionID: context.sessionID, - agent: context.agent, - source, + yield* access.authorizeExternal([target], context, { + filepath: target.absolute, + parentDir: target.externalDirectory.directory, }) return target }) diff --git a/packages/core/src/tool/plugin/read.ts b/packages/core/src/tool/plugin/read.ts index b18ce6b5fad..e3b34a0244b 100644 --- a/packages/core/src/tool/plugin/read.ts +++ b/packages/core/src/tool/plugin/read.ts @@ -6,8 +6,7 @@ import { ToolFailure } from "@opencode-ai/ai" import { Effect, Schema } from "effect" import { FSUtil } from "@opencode-ai/util/fs-util" import { Location } from "../../location.js" -import { LocationMutation } from "../../location-mutation.js" -import { Permission } from "../../permission.js" +import { FileAccess } from "../../file-access.js" import { SessionInstructions } from "../../session/instructions.js" import { AbsolutePath } from "../../schema.js" import { ReadToolFileSystem } from "../read-filesystem.js" @@ -31,8 +30,7 @@ export const Plugin = { id: "opencode.tool.read", effect: Effect.fn("ReadTool.Plugin")(function* (ctx: Context) { const reader = yield* ReadToolFileSystem.Service - const mutation = yield* LocationMutation.Service - const permission = yield* Permission.Service + const access = yield* FileAccess.Service const sessionInstructions = yield* SessionInstructions.Service const fs = yield* FSUtil.Service const location = yield* Location.Service @@ -48,37 +46,13 @@ export const Plugin = { output: Output, execute: (input, context) => { return Effect.gen(function* () { - const source = { - type: "tool" as const, - messageID: context.messageID, - id: context.id, - } - const authorize = (target: LocationMutation.Target, authorizeExternal = true) => - Effect.gen(function* () { - if (target.externalDirectory && authorizeExternal) - yield* permission.assert({ - ...LocationMutation.externalDirectoryPermission(target.externalDirectory), - sessionID: context.sessionID, - agent: context.agent, - source, - }) - yield* permission.assert({ - action: name, - resources: [target.resource], - save: ["*"], - sessionID: context.sessionID, - agent: context.agent, - source, - }) - }) - const read = (target: LocationMutation.Target) => - reader.read(AbsolutePath.make(target.absolute), target.resource, { + const read = (target: FileAccess.Target) => + reader.read(target.absolute, target.resource, { offset: input.offset, limit: input.limit, }) - const requested = yield* mutation.resolve({ path: input.path }) - yield* authorize(requested) + const requested = yield* access.authorizeRead(input.path, context) const result = yield* read(requested).pipe( Effect.map((content) => ({ content, target: requested, path: input.path })), Effect.catchIf( @@ -89,9 +63,7 @@ export const Plugin = { Effect.orElseSucceed(() => undefined), ) if (!alternate) return yield* missing(input.path, requested.absolute) - const target = yield* mutation.resolve({ path: alternate, kind: "file" }) - // The candidate is a sibling under the external directory already approved above. - yield* authorize(target, false) + const target = yield* access.authorizeRead(alternate, context, { siblingOf: requested }) const content = yield* read(target).pipe( Effect.catchIf( (error) => error instanceof Environment.NotFound, diff --git a/packages/core/src/tool/plugin/shell.ts b/packages/core/src/tool/plugin/shell.ts index bb1c0a44094..b286df95a24 100644 --- a/packages/core/src/tool/plugin/shell.ts +++ b/packages/core/src/tool/plugin/shell.ts @@ -8,7 +8,7 @@ import { Deferred, Effect, Schema, Scope } from "effect" import { Config } from "../../config.js" import { Environment } from "../../environment/index.js" import { Job } from "../../job.js" -import { LocationMutation } from "../../location-mutation.js" +import { FileAccess } from "../../file-access.js" import { Permission } from "../../permission.js" import { NonNegativeInt } from "../../schema.js" import { Session } from "../../session.js" @@ -104,7 +104,7 @@ export const Plugin = { const jobs = yield* Job.Service const scope = yield* Scope.Scope const environment = yield* Environment.Service - const mutation = yield* LocationMutation.Service + const access = yield* FileAccess.Service const shell = yield* Shell.Service const shellSelect = yield* ShellSelect.Service const compatibleShell = shellSelect.resolve({ priority: "compat" }) @@ -117,30 +117,18 @@ export const Plugin = { messageID: context.messageID, id: context.id, } - const target = yield* mutation.resolve({ path: invocation.cwd, kind: "directory" }) + const target = yield* access.resolve({ path: invocation.cwd, kind: "directory" }) invocation.cwd = target.absolute const timeout = invocation.timeout const portable = Config.latest(yield* config.entries(), "experimental")?.portable_shell_scanner === true const parsed = yield* ShellParse.scan(invocation.command, invocation.shell, target.absolute, { portable }) const directories = yield* Effect.forEach(parsed.directories, (directory) => - mutation.resolve({ - path: LocationMutation.resolvePath(target.absolute, directory), + access.resolve({ + path: FileAccess.resolvePath(target.absolute, directory), kind: "directory", }), ) - const external = [target, ...directories] - .map((item) => item.externalDirectory) - .filter((item) => item !== undefined) - .filter((item, index, items) => items.findIndex((other) => other.resource === item.resource) === index) - if (external.length > 0) - yield* permission.assert({ - action: "external_directory", - resources: external.map((item) => item.resource), - save: external.map((item) => item.save), - sessionID: context.sessionID, - agent: context.agent, - source, - }) + yield* access.authorizeExternal([target, ...directories], context) if (parsed.commands.length > 0) yield* permission.assert({ action: name, diff --git a/packages/core/src/tool/plugin/write.ts b/packages/core/src/tool/plugin/write.ts index 4f4f0b2660b..62ab149ac0b 100644 --- a/packages/core/src/tool/plugin/write.ts +++ b/packages/core/src/tool/plugin/write.ts @@ -13,7 +13,7 @@ import { Bom } from "@opencode-ai/util/bom" import { Environment } from "../../environment/index.js" import { FileMutation } from "../../file-mutation.js" import { Formatter } from "../../formatter.js" -import { LocationMutation } from "../../location-mutation.js" +import { FileAccess } from "../../file-access.js" import { Permission } from "../../permission.js" import { fileDiff } from "./file-diff.js" @@ -46,7 +46,7 @@ export const toModelContent = (output: Output) => export const Plugin = { id: "opencode.tool.write", effect: Effect.fn("WriteTool.Plugin")(function* (ctx: Context) { - const mutation = yield* LocationMutation.Service + const access = yield* FileAccess.Service const fileMutation = yield* FileMutation.Service const environment = yield* Environment.Service const formatter = yield* Formatter.Service @@ -68,15 +68,8 @@ export const Plugin = { messageID: context.messageID, id: context.id, } - const target = yield* mutation.resolve({ path: input.path, kind: "file" }) - const external = target.externalDirectory - if (external) - yield* permission.assert({ - ...LocationMutation.externalDirectoryPermission(external), - sessionID: context.sessionID, - agent: context.agent, - source, - }) + const target = yield* access.resolve({ path: input.path, kind: "file" }) + yield* access.authorizeExternal([target], context) const current = yield* FileMutation.readText(environment.files, target.absolute).pipe( Effect.catchTag("Environment.NotFound", () => Effect.undefined), ) diff --git a/packages/core/test/location-mutation.test.ts b/packages/core/test/file-access-path.test.ts similarity index 67% rename from packages/core/test/location-mutation.test.ts rename to packages/core/test/file-access-path.test.ts index 640bcc8bfd9..80cc925a3cb 100644 --- a/packages/core/test/location-mutation.test.ts +++ b/packages/core/test/file-access-path.test.ts @@ -4,17 +4,20 @@ import { describe, expect, test } from "bun:test" import { Effect, Layer, Schema } from "effect" import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { Location } from "@opencode-ai/core/location" -import { LocationMutation } from "@opencode-ai/core/location-mutation" +import { FileAccess } from "@opencode-ai/core/file-access" +import { Permission } from "@opencode-ai/core/permission" import { AbsolutePath } from "@opencode-ai/core/schema" import { Global } from "@opencode-ai/util/global" -import { tmpdir } from "./fixture/tmpdir" +import { tmpdirScoped, withTempDir } from "./fixture/tmpdir" import { location } from "./fixture/location" import { it } from "./lib/effect" +import { permissionLayer } from "./lib/permission" function provide(directory: string, projectDirectory = directory) { return Effect.provide( - LayerNode.compile(LocationMutation.node, { + LayerNode.compile(FileAccess.node, { replacements: [ + Permission.node.replace(permissionLayer()), Location.node.replace( Layer.succeed( Location.Service, @@ -31,21 +34,14 @@ function provide(directory: string, projectDirectory = directory) { ) } -function withTmp(f: (directory: string) => Effect.Effect) { - return Effect.acquireRelease( - Effect.promise(() => tmpdir()), - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), - ).pipe(Effect.flatMap((tmp) => f(tmp.path))) -} - -describe("LocationMutation", () => { +describe("FileAccess.resolve", () => { it.live("resolves an active relative existing file target", () => - withTmp((directory) => + withTempDir(({ path: directory }) => Effect.gen(function* () { const targetPath = path.join(directory, "hello.txt") yield* Effect.promise(() => fs.writeFile(targetPath, "hello")) - const mutation = yield* LocationMutation.Service - const target = yield* mutation.resolve({ path: "hello.txt" }) + const access = yield* FileAccess.Service + const target = yield* access.resolve({ path: "hello.txt" }) expect(target).toMatchObject({ absolute: targetPath, @@ -57,11 +53,11 @@ describe("LocationMutation", () => { ) it.live("resolves an active relative prospective file target", () => - withTmp((directory) => + withTempDir(({ path: directory }) => Effect.gen(function* () { yield* Effect.promise(() => fs.mkdir(path.join(directory, "src"))) - const mutation = yield* LocationMutation.Service - const target = yield* mutation.resolve({ path: path.join("src", "new.txt") }) + const access = yield* FileAccess.Service + const target = yield* access.resolve({ path: path.join("src", "new.txt") }) expect(target).toMatchObject({ absolute: path.join(directory, "src", "new.txt"), resource: "src/new.txt", @@ -71,10 +67,10 @@ describe("LocationMutation", () => { ) it.live("requires external-directory authorization for a relative lexical escape", () => - withTmp((directory) => + withTempDir(({ path: directory }) => Effect.gen(function* () { - const mutation = yield* LocationMutation.Service - const target = yield* mutation.resolve({ path: "../outside.txt" }) + const access = yield* FileAccess.Service + const target = yield* access.resolve({ path: "../outside.txt" }) const root = path.dirname(directory) expect(target).toMatchObject({ absolute: path.join(root, "outside.txt"), @@ -89,11 +85,12 @@ describe("LocationMutation", () => { ) it.live("allows a relative path outside the Location but inside the project worktree", () => - withTmp((directory) => + withTempDir(({ path: directory }) => Effect.gen(function* () { const active = path.join(directory, "packages", "opencode") yield* Effect.promise(() => fs.mkdir(active, { recursive: true })) - const target = yield* (yield* LocationMutation.Service).resolve({ path: "../../README.md" }) + const access = yield* FileAccess.Service + const target = yield* access.resolve({ path: "../../README.md" }) expect(target).toMatchObject({ absolute: path.join(directory, "README.md"), resource: "../../README.md", @@ -104,37 +101,34 @@ describe("LocationMutation", () => { ) it.live("does not treat a filesystem-root project sentinel as an internal boundary", () => - withTmp((directory) => + withTempDir(({ path: directory }) => Effect.gen(function* () { - const target = yield* (yield* LocationMutation.Service).resolve({ path: "../outside.txt" }) + const access = yield* FileAccess.Service + const target = yield* access.resolve({ path: "../outside.txt" }) expect(target.externalDirectory).toBeDefined() }).pipe(provide(directory, path.parse(directory).root)), ), ) it.live("resolves a prospective target below an external symlink lexically", () => - withTmp((directory) => { - const outside = `${directory}-outside` - return Effect.gen(function* () { + withTempDir(({ path: directory }) => + Effect.gen(function* () { if (process.platform === "win32") return - yield* Effect.promise(async () => { - await fs.mkdir(outside) - await fs.symlink(outside, path.join(directory, "escape")) - }) - const mutation = yield* LocationMutation.Service - const target = yield* mutation.resolve({ path: path.join("escape", "new.txt") }) + const outside = yield* tmpdirScoped() + yield* Effect.promise(() => fs.symlink(outside.path, path.join(directory, "escape"))) + const access = yield* FileAccess.Service + const target = yield* access.resolve({ path: path.join("escape", "new.txt") }) expect(target).toMatchObject({ absolute: path.join(directory, "escape", "new.txt"), resource: "escape/new.txt", }) expect(target.externalDirectory).toBeUndefined() - yield* Effect.promise(() => fs.rm(outside, { recursive: true, force: true })) - }).pipe(provide(directory)) - }), + }).pipe(provide(directory)), + ), ) it.live("follows an in-location symlink using ordinary filesystem semantics", () => - withTmp((directory) => + withTempDir(({ path: directory }) => Effect.gen(function* () { if (process.platform === "win32") return yield* Effect.promise(async () => { @@ -142,8 +136,8 @@ describe("LocationMutation", () => { await fs.symlink(path.join(directory, "actual"), path.join(directory, "linked")) }) - const mutation = yield* LocationMutation.Service - expect(yield* mutation.resolve({ path: "linked/new.txt" })).toMatchObject({ + const access = yield* FileAccess.Service + expect(yield* access.resolve({ path: "linked/new.txt" })).toMatchObject({ absolute: path.join(directory, "linked", "new.txt"), resource: "linked/new.txt", }) @@ -152,11 +146,11 @@ describe("LocationMutation", () => { ) it.live("accepts an explicit absolute in-location target without external approval", () => - withTmp((directory) => + withTempDir(({ path: directory }) => Effect.gen(function* () { const targetPath = path.join(directory, "new.txt") - const mutation = yield* LocationMutation.Service - const target = yield* mutation.resolve({ path: targetPath }) + const access = yield* FileAccess.Service + const target = yield* access.resolve({ path: targetPath }) expect(target).toMatchObject({ absolute: targetPath, resource: "new.txt", @@ -167,12 +161,12 @@ describe("LocationMutation", () => { ) it.live("requires external-directory authorization for an explicit external absolute target", () => - withTmp((directory) => - withTmp((outside) => + withTempDir(({ path: directory }) => + withTempDir(({ path: outside }) => Effect.gen(function* () { const targetPath = path.join(outside, "new.txt") - const mutation = yield* LocationMutation.Service - const target = yield* mutation.resolve({ path: targetPath }) + const access = yield* FileAccess.Service + const target = yield* access.resolve({ path: targetPath }) const root = outside expect(target).toMatchObject({ absolute: path.join(root, "new.txt"), @@ -188,26 +182,26 @@ describe("LocationMutation", () => { ) it.live("resolves an existing external file target", () => - withTmp((directory) => - withTmp((outside) => + withTempDir(({ path: directory }) => + withTempDir(({ path: outside }) => Effect.gen(function* () { const targetPath = path.join(outside, "existing.txt") yield* Effect.promise(() => fs.writeFile(targetPath, "existing")) - const mutation = yield* LocationMutation.Service - const target = yield* mutation.resolve({ path: targetPath }) + const access = yield* FileAccess.Service + const target = yield* access.resolve({ path: targetPath }) expect(target).toMatchObject({ absolute: targetPath }) - expect(target.externalDirectory?.directory).toBe(outside) + expect(target.externalDirectory?.directory).toBe(AbsolutePath.make(outside)) }).pipe(provide(directory)), ), ), ) it.live("uses an explicit file kind without treating an existing directory as the target boundary", () => - withTmp((directory) => - withTmp((outside) => + withTempDir(({ path: directory }) => + withTempDir(({ path: outside }) => Effect.gen(function* () { - const mutation = yield* LocationMutation.Service - const target = yield* mutation.resolve({ path: outside, kind: "file" }) + const access = yield* FileAccess.Service + const target = yield* access.resolve({ path: outside, kind: "file" }) expect(target.externalDirectory).toMatchObject({ directory: path.dirname(outside), resource: path.join(path.dirname(outside), "*").replaceAll("\\", "/"), @@ -218,12 +212,12 @@ describe("LocationMutation", () => { ) it.live("authorizes prospective external descendants at their lexical parent", () => - withTmp((directory) => - withTmp((outside) => + withTempDir(({ path: directory }) => + withTempDir(({ path: outside }) => Effect.gen(function* () { const targetPath = path.join(outside, "new", "nested", "file.txt") - const mutation = yield* LocationMutation.Service - const target = yield* mutation.resolve({ path: targetPath }) + const access = yield* FileAccess.Service + const target = yield* access.resolve({ path: targetPath }) const parent = path.dirname(targetPath) expect(target.externalDirectory).toMatchObject({ directory: parent, @@ -234,19 +228,18 @@ describe("LocationMutation", () => { ), ) - test("ignores unknown mutation input fields", () => { - expect(Object.keys(LocationMutation.ResolveInput.fields)).toEqual(["path", "kind"]) - expect(Schema.decodeUnknownSync(LocationMutation.ResolveInput)({ path: "README.md", reference: "docs" })).toEqual({ + test("ignores unknown path input fields", () => { + expect(Schema.decodeUnknownSync(FileAccess.ResolveInput)({ path: "README.md", reference: "docs" })).toEqual({ path: "README.md", }) }) test("expands a leading tilde against the home directory", () => { const home = path.resolve("/Users/aiden") - expect(LocationMutation.resolvePath("/project", "~", home)).toBe(home) - expect(LocationMutation.resolvePath("/project", "~/notes.md", home)).toBe(path.resolve(home, "notes.md")) - expect(LocationMutation.resolvePath("/project", "~draft.md", home)).toBe(path.resolve("/project", "~draft.md")) - expect(LocationMutation.resolvePath("/project", "~\\notes.md", home)).toBe( + expect(FileAccess.resolvePath("/project", "~", home)).toBe(home) + expect(FileAccess.resolvePath("/project", "~/notes.md", home)).toBe(path.resolve(home, "notes.md")) + expect(FileAccess.resolvePath("/project", "~draft.md", home)).toBe(path.resolve("/project", "~draft.md")) + expect(FileAccess.resolvePath("/project", "~\\notes.md", home)).toBe( process.platform === "win32" ? path.resolve(home, "notes.md") : path.resolve("/project", "~\\notes.md"), ) }) @@ -257,16 +250,16 @@ describe("LocationMutation", () => { ["/cygdrive/c/Users/aiden/notes.md", "C:/Users/aiden/notes.md"], ["/mnt/c/Users/aiden/notes.md", "C:/Users/aiden/notes.md"], ])("normalizes Windows shell drive path %s before resolution", (input, windows) => { - expect(LocationMutation.resolvePath("/project", input)).toBe( + expect(FileAccess.resolvePath("/project", input)).toBe( process.platform === "win32" ? path.resolve(windows) : path.resolve(input), ) }) it.live("resolves a tilde path as an external home target", () => - withTmp((directory) => + withTempDir(({ path: directory }) => Effect.gen(function* () { - const mutation = yield* LocationMutation.Service - const target = yield* mutation.resolve({ path: "~/notes.md" }) + const access = yield* FileAccess.Service + const target = yield* access.resolve({ path: "~/notes.md" }) const absolute = path.resolve(Global.Path.home, "notes.md") expect(target).toMatchObject({ absolute, @@ -282,8 +275,8 @@ describe("LocationMutation", () => { it.live("treats a tilde path as in-location when the location is home", () => Effect.gen(function* () { - const mutation = yield* LocationMutation.Service - const target = yield* mutation.resolve({ path: "~/notes.md" }) + const access = yield* FileAccess.Service + const target = yield* access.resolve({ path: "~/notes.md" }) expect(target).toMatchObject({ absolute: path.resolve(Global.Path.home, "notes.md"), resource: "notes.md", diff --git a/packages/core/test/file-access.test.ts b/packages/core/test/file-access.test.ts new file mode 100644 index 00000000000..5e9d44af217 --- /dev/null +++ b/packages/core/test/file-access.test.ts @@ -0,0 +1,178 @@ +import { describe, expect } from "bun:test" +import fs from "fs/promises" +import path from "path" +import { Effect } from "effect" +import { FileAccess } from "@opencode-ai/core/file-access" +import { Location } from "@opencode-ai/core/location" +import { Permission } from "@opencode-ai/core/permission" +import { Session } from "@opencode-ai/core/session" +import { Tool } from "@opencode-ai/core/tool" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/util/effect/layer-node" +import { tempLocationLayer } from "./fixture/location" +import { tmpdirScoped } from "./fixture/tmpdir" +import { it } from "./lib/effect" +import { permissionLayer } from "./lib/permission" +import { toolIdentity } from "./lib/tool" + +const invocation = { + ...toolIdentity, + sessionID: Session.ID.make("ses_file_access"), + id: Tool.CallID.make("call-read"), +} +const slash = (file: string) => file.replaceAll("\\", "/") + +function provide(requests: Permission.AssertInput[], denied?: string) { + return Effect.provide( + AppNodeBuilder.build(LayerNode.group([FileAccess.node, Location.node]), [ + Location.node.replace(tempLocationLayer), + Permission.node.replace( + permissionLayer({ + assert: (input) => + Effect.gen(function* () { + requests.push(input) + if (input.action === denied) + yield* new Permission.BlockedError({ + rules: [], + permission: input.action, + resources: input.resources, + }) + }), + }), + ), + ]), + ) +} + +describe("FileAccess.authorizeRead", () => { + it.live("returns an absolute target and preserves invocation identity on the read assertion", () => { + const requests: Permission.AssertInput[] = [] + return Effect.gen(function* () { + const access = yield* FileAccess.Service + const location = yield* Location.Service + const target = yield* access.authorizeRead("src/../README.md", invocation) + const absolute: AbsolutePath = target.absolute + + expect(absolute).toBe(AbsolutePath.make(path.join(location.directory, "README.md"))) + expect(target.externalDirectory).toBeUndefined() + expect(requests).toEqual([ + { + action: "read", + resources: ["README.md"], + save: ["*"], + sessionID: invocation.sessionID, + agent: invocation.agent, + source: { type: "tool", messageID: invocation.messageID, id: invocation.id }, + }, + ]) + }).pipe(provide(requests)) + }) + + it.live("authorizes an external directory before the file's read rules", () => { + const requests: Permission.AssertInput[] = [] + return Effect.gen(function* () { + const access = yield* FileAccess.Service + const target = yield* access.authorizeRead("../notes.txt", invocation) + + expect(requests).toMatchObject([ + { action: "external_directory", resources: [slash(path.join(path.dirname(target.absolute), "*"))] }, + { action: "read", resources: [slash(target.absolute)] }, + ]) + for (const request of requests) { + expect(request).toMatchObject({ + sessionID: invocation.sessionID, + agent: invocation.agent, + source: { type: "tool", messageID: invocation.messageID, id: invocation.id }, + }) + } + }).pipe(provide(requests)) + }) + + for (const action of ["external_directory", "read"]) { + it.live(`propagates ${action} denial without continuing authorization`, () => { + const requests: Permission.AssertInput[] = [] + return Effect.gen(function* () { + const access = yield* FileAccess.Service + const error = yield* access.authorizeRead("../notes.txt", invocation).pipe(Effect.flip) + + expect(error).toBeInstanceOf(Permission.BlockedError) + expect(requests.map((request) => request.action)).toEqual( + action === "external_directory" ? ["external_directory"] : ["external_directory", "read"], + ) + }).pipe(provide(requests, action)) + }) + } + + it.live("reuses a sibling's directory approval only for the supplied recovery call", () => { + const requests: Permission.AssertInput[] = [] + return Effect.gen(function* () { + const access = yield* FileAccess.Service + const requested = yield* access.authorizeRead("../report final.txt", invocation) + const recovered = yield* access.authorizeRead("../report\u202ffinal.txt", invocation, { siblingOf: requested }) + yield* access.authorizeRead("../notes.txt", invocation) + + expect(requests.map((request) => request.action)).toEqual([ + "external_directory", + "read", + "read", + "external_directory", + "read", + ]) + expect(requests[2].resources).toEqual([slash(recovered.absolute)]) + }).pipe(provide(requests)) + }) + + it.live("checks the external directory for a target that is not a sibling", () => { + const requests: Permission.AssertInput[] = [] + return Effect.gen(function* () { + const access = yield* FileAccess.Service + const requested = yield* access.authorizeRead("README.md", invocation) + yield* access.authorizeRead("../notes.txt", invocation, { siblingOf: requested }) + + expect(requests.map((request) => request.action)).toEqual(["read", "external_directory", "read"]) + }).pipe(provide(requests)) + }) + + it.live("batches external resources in first-seen order and preserves broader repository saves", () => { + const requests: Permission.AssertInput[] = [] + return Effect.gen(function* () { + const external = yield* tmpdirScoped() + const git = path.join(external.path, "git") + const hg = path.join(external.path, "hg") + yield* Effect.promise(async () => { + await fs.mkdir(path.join(git, ".git"), { recursive: true }) + await fs.mkdir(path.join(git, "nested")) + await fs.mkdir(path.join(hg, ".hg"), { recursive: true }) + await fs.mkdir(path.join(hg, "nested")) + }) + const access = yield* FileAccess.Service + const first = yield* access.resolve({ path: path.join(git, "nested", "a.txt"), kind: "file" }) + const second = yield* access.resolve({ path: path.join(git, "nested", "b.txt"), kind: "file" }) + const third = yield* access.resolve({ path: path.join(hg, "nested", "c.txt"), kind: "file" }) + const internal = yield* access.resolve({ path: "README.md" }) + const metadata = { filepath: first.absolute, parentDir: path.dirname(first.absolute) } + + yield* access.authorizeExternal([first, internal, second, third, first], invocation, metadata) + + expect(requests).toEqual([ + { + action: "external_directory", + resources: [slash(path.join(git, "nested", "*")), slash(path.join(hg, "nested", "*"))], + save: [slash(path.join(git, "*")), slash(path.join(hg, "*"))], + metadata, + sessionID: invocation.sessionID, + agent: invocation.agent, + source: { type: "tool", messageID: invocation.messageID, id: invocation.id }, + }, + ]) + + yield* access.authorizeExternal([internal], invocation) + expect(requests).toHaveLength(1) + yield* access.authorizeExternal([second], invocation) + expect(requests).toHaveLength(2) + expect(requests[1].resources).toEqual([slash(path.join(git, "nested", "*"))]) + expect(Object.hasOwn(requests[1], "metadata")).toBe(false) + }).pipe(provide(requests)) + }) +}) diff --git a/packages/core/test/file-mutation.test.ts b/packages/core/test/file-mutation.test.ts index 08b5ad770ff..9ac33e0b7c6 100644 --- a/packages/core/test/file-mutation.test.ts +++ b/packages/core/test/file-mutation.test.ts @@ -7,12 +7,14 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { FileMutation } from "@opencode-ai/core/file-mutation" import { Environment } from "@opencode-ai/core/environment/index" import { Location } from "@opencode-ai/core/location" -import { LocationMutation } from "@opencode-ai/core/location-mutation" +import { FileAccess } from "@opencode-ai/core/file-access" +import { Permission } from "@opencode-ai/core/permission" import { AbsolutePath } from "@opencode-ai/core/schema" import { type EnvironmentFilesTransform, transformEnvironmentFiles } from "./fixture/environment" import { location } from "./fixture/location" -import { tmpdir } from "./fixture/tmpdir" +import { withTempDir } from "./fixture/tmpdir" import { it } from "./lib/effect" +import { permissionLayer } from "./lib/permission" function provide(directory: string, transformFiles: EnvironmentFilesTransform = () => ({})) { const activeLocation = Layer.succeed( @@ -20,27 +22,22 @@ function provide(directory: string, transformFiles: EnvironmentFilesTransform = Location.Service.of(location({ directory: AbsolutePath.make(directory) })), ) return Effect.provide( - AppNodeBuilder.build(LayerNode.group([LocationMutation.node, FileMutation.node]), [ + AppNodeBuilder.build(LayerNode.group([FileAccess.node, FileMutation.node]), [ Location.node.replace(activeLocation), + Permission.node.replace(permissionLayer()), Environment.node.replace(transformEnvironmentFiles(transformFiles)), ]), ) } -function withTmp(f: (directory: string) => Effect.Effect) { - return Effect.acquireRelease( - Effect.promise(() => tmpdir()), - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), - ).pipe(Effect.flatMap((tmp) => f(tmp.path))) -} - describe("FileMutation", () => { it.live("writes an existing internal file and returns a stable result", () => - withTmp((directory) => + withTempDir(({ path: directory }) => Effect.gen(function* () { const targetPath = path.join(directory, "hello.txt") yield* Effect.promise(() => fs.writeFile(targetPath, "before")) - const target = yield* (yield* LocationMutation.Service).resolve({ path: "hello.txt" }) + const access = yield* FileAccess.Service + const target = yield* access.resolve({ path: "hello.txt" }) expect(yield* (yield* FileMutation.Service).write({ target, content: "after" })).toEqual({ operation: "write", @@ -54,9 +51,10 @@ describe("FileMutation", () => { ) it.live("writes a prospective internal file and creates parent directories", () => - withTmp((directory) => + withTempDir(({ path: directory }) => Effect.gen(function* () { - const target = yield* (yield* LocationMutation.Service).resolve({ + const access = yield* FileAccess.Service + const target = yield* access.resolve({ path: path.join("src", "nested", "hello.txt"), }) const result = yield* (yield* FileMutation.Service).write({ target, content: "hello" }) @@ -73,12 +71,13 @@ describe("FileMutation", () => { ) it.live("preserves exactly one BOM for text writes and normalizes created text", () => - withTmp((directory) => + withTempDir(({ path: directory }) => Effect.gen(function* () { const preservedPath = path.join(directory, "preserved.txt") yield* Effect.promise(() => fs.writeFile(preservedPath, "\uFEFFbefore")) - const preserved = yield* (yield* LocationMutation.Service).resolve({ path: "preserved.txt" }) - const created = yield* (yield* LocationMutation.Service).resolve({ path: "created.txt" }) + const access = yield* FileAccess.Service + const preserved = yield* access.resolve({ path: "preserved.txt" }) + const created = yield* access.resolve({ path: "created.txt" }) const files = yield* FileMutation.Service yield* files.writeTextPreservingBom({ target: preserved, content: "\uFEFFafter" }) @@ -91,11 +90,12 @@ describe("FileMutation", () => { ) it.live("writes an explicitly resolved external target", () => - withTmp((directory) => - withTmp((outside) => + withTempDir(({ path: directory }) => + withTempDir(({ path: outside }) => Effect.gen(function* () { const targetPath = path.join(outside, "external.txt") - const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath }) + const access = yield* FileAccess.Service + const target = yield* access.resolve({ path: targetPath }) const result = yield* (yield* FileMutation.Service).write({ target, content: "external" }) expect(result).toEqual({ @@ -111,7 +111,7 @@ describe("FileMutation", () => { ) it.live("serializes concurrent writes to the same absolute target", () => - withTmp((directory) => + withTempDir(({ path: directory }) => Effect.gen(function* () { const targetPath = path.join(directory, "shared.txt") yield* Effect.promise(() => fs.writeFile(targetPath, "initial")) @@ -133,10 +133,10 @@ describe("FileMutation", () => { ) yield* Effect.gen(function* () { - const mutation = yield* LocationMutation.Service + const access = yield* FileAccess.Service const files = yield* FileMutation.Service - const firstPlan = yield* mutation.resolve({ path: "shared.txt" }) - const secondPlan = yield* mutation.resolve({ path: "shared.txt" }) + const firstPlan = yield* access.resolve({ path: "shared.txt" }) + const secondPlan = yield* access.resolve({ path: "shared.txt" }) const first = yield* files.write({ target: firstPlan, content: "first" }).pipe(Effect.forkChild) yield* Deferred.await(firstStarted) const second = yield* files.write({ target: secondPlan, content: "second" }).pipe(Effect.forkChild) @@ -154,7 +154,7 @@ describe("FileMutation", () => { ) it.live("shares transaction locks across Location service instances", () => - withTmp((directory) => + withTempDir(({ path: directory }) => Effect.gen(function* () { const firstStarted = yield* Deferred.make() const releaseFirst = yield* Deferred.make() @@ -183,7 +183,7 @@ describe("FileMutation", () => { ) it.live("allows transaction locks for distinct resolved paths to proceed independently", () => - withTmp((directory) => + withTempDir(({ path: directory }) => Effect.gen(function* () { const firstStarted = yield* Deferred.make() const releaseFirst = yield* Deferred.make() @@ -205,7 +205,7 @@ describe("FileMutation", () => { ) it.live("allows distinct absolute targets to proceed independently", () => - withTmp((directory) => + withTempDir(({ path: directory }) => Effect.gen(function* () { const firstStarted = yield* Deferred.make() const releaseFirst = yield* Deferred.make() @@ -222,10 +222,10 @@ describe("FileMutation", () => { ) yield* Effect.gen(function* () { - const mutation = yield* LocationMutation.Service + const access = yield* FileAccess.Service const files = yield* FileMutation.Service - const firstPlan = yield* mutation.resolve({ path: "first.txt" }) - const secondPlan = yield* mutation.resolve({ path: "second.txt" }) + const firstPlan = yield* access.resolve({ path: "first.txt" }) + const secondPlan = yield* access.resolve({ path: "second.txt" }) const first = yield* files.write({ target: firstPlan, content: "first" }).pipe(Effect.forkChild) yield* Deferred.await(firstStarted) const second = yield* files.write({ target: secondPlan, content: "second" }).pipe(Effect.forkChild) diff --git a/packages/core/test/session-instructions.test.ts b/packages/core/test/session-instructions.test.ts index bd9decea5c2..418c708e054 100644 --- a/packages/core/test/session-instructions.test.ts +++ b/packages/core/test/session-instructions.test.ts @@ -12,7 +12,7 @@ import { FSUtil } from "@opencode-ai/util/fs-util" import { Global } from "@opencode-ai/util/global" import { Image } from "@opencode-ai/core/image" import { Location } from "@opencode-ai/core/location" -import { LocationMutation } from "@opencode-ai/core/location-mutation" +import { FileAccess } from "@opencode-ai/core/file-access" import { Model } from "@opencode-ai/core/model" import { Permission } from "@opencode-ai/core/permission" import { Project } from "@opencode-ai/core/project" @@ -42,7 +42,7 @@ const readToolNode = makeLocationNode({ deps: [ Tool.node, ReadToolFileSystem.node, - LocationMutation.node, + FileAccess.node, Image.node, Permission.node, SessionInstructions.node, @@ -64,7 +64,7 @@ const testLayer = AppNodeBuilder.build( Session.node, Location.node, FSUtil.node, - LocationMutation.node, + FileAccess.node, ReadToolFileSystem.node, readToolNode, Tool.node, diff --git a/packages/core/test/tool-edit.test.ts b/packages/core/test/tool-edit.test.ts index 9cdfe584def..2e7f0b1337d 100644 --- a/packages/core/test/tool-edit.test.ts +++ b/packages/core/test/tool-edit.test.ts @@ -8,7 +8,7 @@ import { Environment } from "@opencode-ai/core/environment/index" import { FileMutation } from "@opencode-ai/core/file-mutation" import { Formatter } from "@opencode-ai/core/formatter" import { Location } from "@opencode-ai/core/location" -import { LocationMutation } from "@opencode-ai/core/location-mutation" +import { FileAccess } from "@opencode-ai/core/file-access" import { Permission } from "@opencode-ai/core/permission" import { AbsolutePath } from "@opencode-ai/core/schema" import { Session } from "@opencode-ai/core/session" @@ -27,7 +27,7 @@ const editToolNode = makeLocationNode({ layer: Layer.effectDiscard(registerToolPlugin(EditTool.Plugin)), deps: [ Tool.node, - LocationMutation.node, + FileAccess.node, FileMutation.node, Environment.node, Formatter.node, @@ -91,7 +91,7 @@ const withTool = ( return yield* body(registry) }).pipe( Effect.provide( - AppNodeBuilder.build(LayerNode.group([Tool.node, LocationMutation.node, FileMutation.node, editToolNode]), [ + AppNodeBuilder.build(LayerNode.group([Tool.node, FileAccess.node, FileMutation.node, editToolNode]), [ Environment.node.replace( transformEnvironmentFiles((files) => ({ read: (target, range) => diff --git a/packages/core/test/tool-patch.test.ts b/packages/core/test/tool-patch.test.ts index 01aac61f476..99055430d27 100644 --- a/packages/core/test/tool-patch.test.ts +++ b/packages/core/test/tool-patch.test.ts @@ -8,7 +8,7 @@ import { Environment } from "@opencode-ai/core/environment/index" import { Formatter } from "@opencode-ai/core/formatter" import { FileMutation } from "@opencode-ai/core/file-mutation" import { Location } from "@opencode-ai/core/location" -import { LocationMutation } from "@opencode-ai/core/location-mutation" +import { FileAccess } from "@opencode-ai/core/file-access" import { Permission } from "@opencode-ai/core/permission" import { AbsolutePath } from "@opencode-ai/core/schema" import { Session } from "@opencode-ai/core/session" @@ -27,7 +27,7 @@ const patchToolNode = makeLocationNode({ layer: Layer.effectDiscard(registerToolPlugin(PatchTool.Plugin)), deps: [ Tool.node, - LocationMutation.node, + FileAccess.node, FileMutation.node, Environment.node, Formatter.node, @@ -99,7 +99,7 @@ const withTool = ( return yield* body(yield* Tool.Service) }).pipe( Effect.provide( - AppNodeBuilder.build(LayerNode.group([Tool.node, LocationMutation.node, FileMutation.node, patchToolNode]), [ + AppNodeBuilder.build(LayerNode.group([Tool.node, FileAccess.node, FileMutation.node, patchToolNode]), [ Environment.node.replace( transformEnvironmentFiles((files) => ({ read: (target, range) => diff --git a/packages/core/test/tool-read.test.ts b/packages/core/test/tool-read.test.ts index 776235c610b..61e646ffd10 100644 --- a/packages/core/test/tool-read.test.ts +++ b/packages/core/test/tool-read.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect } from "bun:test" import path from "path" -import { Effect, Exit, Layer } from "effect" +import { Effect, Exit, Layer, Result } from "effect" import { Config } from "@opencode-ai/core/config" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/util/effect/layer-node" @@ -12,7 +12,7 @@ import { Permission } from "@opencode-ai/core/permission" import { Session } from "@opencode-ai/core/session" import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema" import { Global } from "@opencode-ai/util/global" -import { LocationMutation } from "@opencode-ai/core/location-mutation" +import { FileAccess } from "@opencode-ai/core/file-access" import { location } from "./fixture/location" import { Tool } from "@opencode-ai/core/tool" import { ReadTool } from "@opencode-ai/core/tool/plugin/read" @@ -30,7 +30,7 @@ const readToolNode = makeLocationNode({ deps: [ Tool.node, ReadToolFileSystem.node, - LocationMutation.node, + FileAccess.node, Image.node, Permission.node, SessionInstructions.node, @@ -47,7 +47,7 @@ const readCalls: { page: ReadToolFileSystem.PageInput }[] = [] const listCalls: AbsolutePath[] = [] -let resolveFailure: unknown +let readDefect: unknown let directoryEntries: string[] = [] let directoryEntryDetails: Environment.DirEntry[] = [] let readResult: ReadToolFileSystem.FileContent | ReadToolFileSystem.TextPage | ReadToolFileSystem.ListPage = { @@ -69,7 +69,7 @@ const reader = Layer.succeed( }, read: (input, resource, page = {}) => { readCalls.push({ input, page }) - if (resolveFailure !== undefined) return Effect.die(resolveFailure) + if (readDefect !== undefined) return Effect.die(readDefect) if (readOverride) return readOverride(input, resource, page) if (readFailure !== undefined) return Effect.fail(readFailure) return Effect.succeed(readResult) @@ -77,13 +77,14 @@ const reader = Layer.succeed( }), ) let allow = true +let deniedResource: string | undefined const permission = permissionLayer({ assert: (input) => Effect.sync(() => { assertions.push(input) }).pipe( Effect.andThen( - allow + allow && !input.resources.some((resource) => resource === deniedResource) ? Effect.void : Effect.fail( new Permission.BlockedError({ @@ -112,30 +113,6 @@ const locationLayer = Layer.succeed( Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(process.cwd()) })), ) -const mutation = Layer.succeed( - LocationMutation.Service, - LocationMutation.Service.of({ - resolve: (input) => { - const absolute = path.resolve(process.cwd(), input.path) - const external = path.isAbsolute(input.path) && !FSUtil.contains(process.cwd(), absolute) - const resource = external ? absolute.replaceAll("\\", "/") : path.relative(process.cwd(), absolute) || "." - const directory = path.dirname(absolute) - const externalResource = path.join(directory, "*").replaceAll("\\", "/") - return Effect.succeed({ - absolute, - resource, - externalDirectory: external - ? { - action: "external_directory" as const, - directory, - resource: externalResource, - save: externalResource, - } - : undefined, - }) - }, - }), -) const unavailableImage = Layer.mock(Image.Service, { normalize: () => Effect.fail(new Image.ResizerUnavailableError()), }) @@ -146,7 +123,6 @@ const readLayer = (imageLayer: Layer.Layer) => Permission.node.replace(permission), Config.node.replace(config), Image.node.replace(imageLayer), - LocationMutation.node.replace(mutation), FSUtil.node.replace(testFileSystem), Location.node.replace(locationLayer), Global.node.replace(Global.layerWith({ data: Global.Path.data })), @@ -165,7 +141,8 @@ describe("ReadTool", () => { readCalls.length = 0 listCalls.length = 0 allow = true - resolveFailure = undefined + deniedResource = undefined + readDefect = undefined directoryEntries = [] directoryEntryDetails = [] readResult = { @@ -620,18 +597,21 @@ describe("ReadTool", () => { it.effect("preserves unexpected filesystem defects", () => Effect.gen(function* () { - resolveFailure = new Error("unexpected") + readDefect = new Error("unexpected") const registry = yield* Tool.Service - expect( - Exit.isFailure( - yield* executeTool(registry, { - sessionID, - ...toolIdentity, - call: { type: "tool-call", id: "call-defect", name: "read", input: { path: "README.md" } }, - }).pipe(Effect.exit), - ), - ).toBe(true) + const exit = yield* executeTool(registry, { + sessionID, + ...toolIdentity, + call: { type: "tool-call", id: "call-defect", name: "read", input: { path: "README.md" } }, + }).pipe(Effect.exit) + expect(Result.getOrThrow(Exit.findDefect(exit))).toBe(readDefect) + expect(readCalls).toEqual([ + { + input: AbsolutePath.make(path.join(process.cwd(), "README.md")), + page: { offset: undefined, limit: undefined }, + }, + ]) }), ) @@ -721,6 +701,57 @@ describe("ReadTool", () => { }), ) + it.effect("recovers an external filename without repeating directory approval", () => + Effect.gen(function* () { + const directory = path.join(path.parse(process.cwd()).root, "external-read") + const requested = path.join(directory, "report final.txt") + const recovered = path.join(directory, "report\u202ffinal.txt") + directoryEntryDetails = [{ name: path.basename(recovered), type: "file" }] + readOverride = (input) => + input === requested ? Effect.fail(new Environment.NotFound({ path: requested })) : Effect.succeed(readResult) + const registry = yield* Tool.Service + + expect( + yield* executeTool(registry, { + sessionID, + ...toolIdentity, + call: { type: "tool-call", id: "call-external-recovery", name: "read", input: { path: requested } }, + }), + ).toMatchObject({ status: "completed" }) + expect(assertions).toMatchObject([ + { action: "external_directory", resources: [path.join(directory, "*").replaceAll("\\", "/")] }, + { action: "read", resources: [requested.replaceAll("\\", "/")] }, + { action: "read", resources: [recovered.replaceAll("\\", "/")] }, + ]) + expect(readCalls.map((call) => call.input)).toEqual([AbsolutePath.make(requested), AbsolutePath.make(recovered)]) + }), + ) + + it.effect("does not read a recovered filename denied by its own read rules", () => + Effect.gen(function* () { + const requested = path.join(process.cwd(), "report final.txt") + const recovered = path.join(process.cwd(), "report\u202ffinal.txt") + deniedResource = path.basename(recovered) + directoryEntryDetails = [{ name: path.basename(recovered), type: "file" }] + readOverride = (input) => + input === requested ? Effect.fail(new Environment.NotFound({ path: requested })) : Effect.succeed(readResult) + const registry = yield* Tool.Service + + expect( + yield* executeTool(registry, { + sessionID, + ...toolIdentity, + call: { type: "tool-call", id: "call-denied-recovery", name: "read", input: { path: requested } }, + }), + ).toMatchObject({ status: "error", error: { type: "permission.rejected" } }) + expect(assertions).toMatchObject([ + { action: "read", resources: [path.basename(requested)] }, + { action: "read", resources: [path.basename(recovered)] }, + ]) + expect(readCalls.map((call) => call.input)).toEqual([AbsolutePath.make(requested)]) + }), + ) + it.effect("does not recover ambiguous files", () => Effect.gen(function* () { const requested = "report final.txt" @@ -860,30 +891,6 @@ describe("ReadTool", () => { }), ) - it.effect("preserves unexpected resolution defects", () => - Effect.gen(function* () { - const registry = yield* Tool.Service - - resolveFailure = new Error("missing") - expect( - Exit.isFailure( - yield* executeTool(registry, { - sessionID, - ...toolIdentity, - call: { type: "tool-call", id: "call-missing", name: "read", input: { path: "missing.txt" } }, - }).pipe(Effect.exit), - ), - ).toBe(true) - - expect(readCalls).toEqual([ - { - input: AbsolutePath.make(path.join(process.cwd(), "missing.txt")), - page: { offset: undefined, limit: undefined }, - }, - ]) - }), - ) - it.effect("forwards pagination and returns bounded text pages with continuation", () => Effect.gen(function* () { readResult = new ReadToolFileSystem.TextPage({ diff --git a/packages/core/test/tool-search.test.ts b/packages/core/test/tool-search.test.ts index dec824ab238..99fbde1bd98 100644 --- a/packages/core/test/tool-search.test.ts +++ b/packages/core/test/tool-search.test.ts @@ -8,7 +8,7 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { Environment } from "@opencode-ai/core/environment/index" import { FileSystem } from "@opencode-ai/core/filesystem" import { Location } from "@opencode-ai/core/location" -import { LocationMutation } from "@opencode-ai/core/location-mutation" +import { FileAccess } from "@opencode-ai/core/file-access" import { Permission } from "@opencode-ai/core/permission" import { Ripgrep } from "@opencode-ai/core/ripgrep" import { AbsolutePath } from "@opencode-ai/core/schema" @@ -25,12 +25,12 @@ import { executeTool, registerToolPlugin, toolIdentity } from "./lib/tool" const globToolNode = makeLocationNode({ name: "test/glob-tool-plugin", layer: Layer.effectDiscard(registerToolPlugin(GlobTool.Plugin)), - deps: [Tool.node, Environment.node, Ripgrep.node, Location.node, LocationMutation.node, Permission.node], + deps: [Tool.node, Environment.node, Ripgrep.node, Location.node, FileAccess.node, Permission.node], }) const grepToolNode = makeLocationNode({ name: "test/grep-tool-plugin", layer: Layer.effectDiscard(registerToolPlugin(GrepTool.Plugin)), - deps: [Tool.node, Environment.node, Ripgrep.node, Location.node, LocationMutation.node, Permission.node], + deps: [Tool.node, Environment.node, Ripgrep.node, Location.node, FileAccess.node, Permission.node], }) const sessionID = Session.ID.make("ses_search_tool_test") diff --git a/packages/core/test/tool-shell.test.ts b/packages/core/test/tool-shell.test.ts index 85f368ae79e..4096abcc0e0 100644 --- a/packages/core/test/tool-shell.test.ts +++ b/packages/core/test/tool-shell.test.ts @@ -16,7 +16,7 @@ import { Environment } from "@opencode-ai/core/environment/index" import { FSUtil } from "@opencode-ai/util/fs-util" import { Global } from "@opencode-ai/util/global" import { Location } from "@opencode-ai/core/location" -import { LocationMutation } from "@opencode-ai/core/location-mutation" +import { FileAccess } from "@opencode-ai/core/file-access" import { LocationServiceMap } from "@opencode-ai/core/location-service-map" import { Model } from "@opencode-ai/core/model" import { Provider } from "@opencode-ai/core/provider" @@ -131,7 +131,7 @@ const shellPluginSupervisor = makeLocationNode({ deps: [ Config.node, Environment.node, - LocationMutation.node, + FileAccess.node, Permission.node, Session.node, Job.node, diff --git a/packages/core/test/tool-write.test.ts b/packages/core/test/tool-write.test.ts index f1a18a28e8f..0c734a27e40 100644 --- a/packages/core/test/tool-write.test.ts +++ b/packages/core/test/tool-write.test.ts @@ -8,7 +8,7 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { Environment } from "@opencode-ai/core/environment/index" import { Location } from "@opencode-ai/core/location" -import { LocationMutation } from "@opencode-ai/core/location-mutation" +import { FileAccess } from "@opencode-ai/core/file-access" import { Permission } from "@opencode-ai/core/permission" import { AbsolutePath } from "@opencode-ai/core/schema" import { Session } from "@opencode-ai/core/session" @@ -25,7 +25,7 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from " const writeToolNode = makeLocationNode({ name: "test/write-tool-plugin", layer: Layer.effectDiscard(registerToolPlugin(WriteTool.Plugin)), - deps: [Tool.node, LocationMutation.node, FileMutation.node, Environment.node, Formatter.node, Permission.node], + deps: [Tool.node, FileAccess.node, FileMutation.node, Environment.node, Formatter.node, Permission.node], }) const sessionID = Session.ID.make("ses_write_tool_test") @@ -79,7 +79,7 @@ const withTool = ( return yield* body(registry) }).pipe( Effect.provide( - AppNodeBuilder.build(LayerNode.group([Tool.node, LocationMutation.node, FileMutation.node, writeToolNode]), [ + AppNodeBuilder.build(LayerNode.group([Tool.node, FileAccess.node, FileMutation.node, writeToolNode]), [ Environment.node.replace( transformEnvironmentFiles((files) => ({ write: (target, content) => diff --git a/packages/tui/test/component/session-tabs-mouse.test.tsx b/packages/tui/test/component/session-tabs-mouse.test.tsx index e6d37bc96b9..bf5301e8802 100644 --- a/packages/tui/test/component/session-tabs-mouse.test.tsx +++ b/packages/tui/test/component/session-tabs-mouse.test.tsx @@ -1,7 +1,7 @@ /** @jsxImportSource @opentui/solid */ import { testRender } from "@opentui/solid" import { MouseButton } from "@opentui/core" -import { expect, test } from "bun:test" +import { expect, setSystemTime, test } from "bun:test" import { createSignal } from "solid-js" import { ConfigProvider } from "../../src/config" import { ClientProvider } from "../../src/context/client" @@ -182,16 +182,21 @@ test("double-clicking a preview tab keeps it open without promoting permanent ta app.renderer.start() await app.waitForFrame((frame) => frame.includes("Second")) + // Keep click timing independent of renderer delays on busy CI runners. + setSystemTime(new Date(1_000)) await app.mockMouse.doubleClick(5, 0) expect(promoted).toEqual([]) + setSystemTime(new Date(2_000)) await app.mockMouse.click(40, 0) expect(active()).toBe("second") expect(promoted).toEqual([]) + setSystemTime(new Date(2_100)) await app.mockMouse.click(40, 0) expect(promoted).toEqual(["second"]) } finally { + setSystemTime() app.renderer.destroy() } })