mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-13 19:46:16 +03:00
feat(core): add Node-style fs and path to Code Mode
This commit is contained in:
@@ -0,0 +1,354 @@
|
||||
export * as CodeModeFs from "./fs.js"
|
||||
|
||||
import { Extension } from "@opencode/codemode"
|
||||
import type { Context as ToolContext } from "@opencode/schema/tool"
|
||||
import { Effect } from "effect"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { Environment } from "../environment/index.js"
|
||||
import { FileAccess } from "../file-access.js"
|
||||
import { FileMutation } from "../file-mutation.js"
|
||||
import { FileWrite } from "../file-write.js"
|
||||
import { Permission } from "../permission.js"
|
||||
import { CodeModeExecution } from "./execution.js"
|
||||
|
||||
/** The services the `fs` global needs from the execute call's Location. */
|
||||
export type Services = FileAccess.Service | FileWrite.Service | FileMutation.Service | Environment.Service
|
||||
|
||||
type PathLike = string | URL
|
||||
type EncodingOption = BufferEncoding | { readonly encoding?: BufferEncoding | null } | null | undefined
|
||||
type Data = string | Uint8Array
|
||||
|
||||
// Reads ask the read permission and writes go through FileWrite, so the rules, prompts, diffs and formatting match the
|
||||
// read and write tools. Errors carry Node's `code`, `syscall` and `path`, so `e.code === "ENOENT"` works.
|
||||
const readFile = (file: PathLike, options?: EncodingOption) =>
|
||||
run("open", file, (host, file) =>
|
||||
Effect.gen(function* () {
|
||||
const target = yield* host.access.authorizeRead(file, host.execution)
|
||||
const bytes = (yield* host.files.read(target.absolute)).bytes
|
||||
const encoding = encodingOf(options)
|
||||
return encoding ? Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString(encoding) : bytes
|
||||
}),
|
||||
)
|
||||
|
||||
const writeFile = (file: PathLike, data: Data, options?: EncodingOption) =>
|
||||
run("open", file, (host, file) => write(host, file, bytesOrText(data, options)))
|
||||
|
||||
const appendFile = (file: PathLike, data: Data, options?: EncodingOption) =>
|
||||
run("open", file, (host, file) =>
|
||||
Effect.gen(function* () {
|
||||
const target = yield* host.access.resolve({ path: file, kind: "file" })
|
||||
const current = yield* host.files.read(target.absolute).pipe(
|
||||
Effect.map((result) => result.bytes),
|
||||
Effect.catchTag("Environment.NotFound", () => Effect.succeed(new Uint8Array())),
|
||||
)
|
||||
const added = bytesOrText(data, options)
|
||||
yield* write(host, file, Buffer.concat([current, typeof added === "string" ? Buffer.from(added) : added]))
|
||||
}),
|
||||
)
|
||||
|
||||
const readdir = (
|
||||
file: PathLike,
|
||||
options?: { readonly withFileTypes?: boolean; readonly recursive?: boolean } | BufferEncoding | null,
|
||||
) =>
|
||||
run("scandir", file, (host, file) =>
|
||||
Effect.gen(function* () {
|
||||
const target = yield* host.access.authorizeRead(file, host.execution)
|
||||
const recursive = typeof options === "object" && options?.recursive === true
|
||||
const entries = yield* walk(host.files, target.absolute, recursive)
|
||||
if (typeof options !== "object" || options?.withFileTypes !== true) return entries.map((entry) => entry.relative)
|
||||
return entries.map((entry) => ({
|
||||
name: path.basename(entry.relative),
|
||||
parentPath: path.join(file, path.dirname(entry.relative)),
|
||||
...kind(entry.type),
|
||||
}))
|
||||
}),
|
||||
)
|
||||
|
||||
const stat = (file: PathLike) => run("stat", file, (host, file) => statOf(host, file, true))
|
||||
|
||||
const lstat = (file: PathLike) => run("lstat", file, (host, file) => statOf(host, file, false))
|
||||
|
||||
const access = (file: PathLike, _mode?: number) =>
|
||||
run("access", file, (host, file) =>
|
||||
Effect.gen(function* () {
|
||||
const target = yield* host.access.authorizeRead(file, host.execution)
|
||||
yield* Environment.typeFollowing(host.files, target.absolute)
|
||||
}),
|
||||
)
|
||||
|
||||
const existsSync = (file: PathLike) =>
|
||||
run("access", file, (host, file) =>
|
||||
Effect.gen(function* () {
|
||||
const target = yield* host.access.authorizeRead(file, host.execution)
|
||||
return yield* Environment.exists(host.files, target.absolute)
|
||||
}),
|
||||
)
|
||||
|
||||
const mkdir = (file: PathLike, options?: { readonly recursive?: boolean } | number) =>
|
||||
run("mkdir", file, (host, file) =>
|
||||
Effect.gen(function* () {
|
||||
const target = yield* host.fileWrite.target(file, host.execution)
|
||||
yield* host.fileWrite.authorize(target, host.execution)
|
||||
if (typeof options !== "object" || options.recursive !== true) {
|
||||
if (yield* Environment.exists(host.files, target.absolute)) return yield* systemError("EEXIST", "mkdir", file)
|
||||
if (!(yield* Environment.exists(host.files, path.dirname(target.absolute))))
|
||||
return yield* systemError("ENOENT", "mkdir", file)
|
||||
}
|
||||
yield* host.files.mkdir(target.absolute)
|
||||
}),
|
||||
)
|
||||
|
||||
const rm = (file: PathLike, options?: { readonly recursive?: boolean; readonly force?: boolean }) =>
|
||||
run("rm", file, (host, file) =>
|
||||
Effect.gen(function* () {
|
||||
const target = yield* host.fileWrite.target(file, host.execution)
|
||||
const info = yield* optionalStat(host, target.absolute)
|
||||
if (info === undefined) return options?.force === true ? undefined : yield* systemError("ENOENT", "rm", file)
|
||||
if (info.type === "directory" && options?.recursive !== true) return yield* systemError("EISDIR", "rm", file)
|
||||
// Every file inside a removed directory is authorized, so an edit rule for one of them still applies.
|
||||
yield* host.fileWrite.authorize(yield* tree(host, target, info), host.execution)
|
||||
yield* host.files.remove(target.absolute)
|
||||
}),
|
||||
)
|
||||
|
||||
const unlink = (file: PathLike) =>
|
||||
run("unlink", file, (host, file) =>
|
||||
Effect.gen(function* () {
|
||||
const target = yield* host.fileWrite.target(file, host.execution)
|
||||
const info = yield* host.files.stat(target.absolute)
|
||||
if (info.type === "directory") return yield* systemError("EISDIR", "unlink", file)
|
||||
yield* host.fileWrite.authorize(target, host.execution)
|
||||
yield* host.files.remove(target.absolute)
|
||||
}),
|
||||
)
|
||||
|
||||
const rename = (from: PathLike, to: PathLike) =>
|
||||
run("rename", from, (host, file) =>
|
||||
Effect.gen(function* () {
|
||||
const source = yield* host.fileWrite.target(file, host.execution)
|
||||
const destination = yield* host.fileWrite.target(toPath(to), host.execution)
|
||||
const info = yield* host.files.stat(source.absolute)
|
||||
const existing = yield* optionalStat(host, destination.absolute)
|
||||
if (existing?.type === "directory")
|
||||
return yield* systemError(info.type === "directory" ? "ENOTEMPTY" : "EISDIR", "rename", file)
|
||||
if (existing && info.type === "directory") return yield* systemError("ENOTDIR", "rename", file)
|
||||
const moved = yield* tree(host, source, info)
|
||||
const arriving = yield* Effect.forEach(moved.slice(1), (item) =>
|
||||
host.access.resolve({
|
||||
path: path.join(destination.absolute, path.relative(source.absolute, item.absolute)),
|
||||
kind: "file",
|
||||
}),
|
||||
)
|
||||
yield* host.fileWrite.authorize([...moved, destination, ...arriving], host.execution)
|
||||
yield* host.files.move(source.absolute, destination.absolute)
|
||||
}),
|
||||
)
|
||||
|
||||
const copyFile = (from: PathLike, to: PathLike, _mode?: number) =>
|
||||
run("copyfile", from, (host, file) =>
|
||||
Effect.gen(function* () {
|
||||
const source = yield* host.access.authorizeRead(file, host.execution)
|
||||
yield* write(host, toPath(to), (yield* host.files.read(source.absolute)).bytes)
|
||||
}),
|
||||
)
|
||||
|
||||
const constants = { F_OK: 0, R_OK: 4, W_OK: 2, X_OK: 1, COPYFILE_EXCL: 1 }
|
||||
|
||||
export const extension = Extension.make({
|
||||
name: "fs",
|
||||
globals: {
|
||||
fs: {
|
||||
promises: {
|
||||
readFile: Extension.async(readFile),
|
||||
writeFile: Extension.async(writeFile),
|
||||
appendFile: Extension.async(appendFile),
|
||||
readdir: Extension.async(readdir),
|
||||
stat: Extension.async(stat),
|
||||
lstat: Extension.async(lstat),
|
||||
access: Extension.async(access),
|
||||
mkdir: Extension.async(mkdir),
|
||||
rm: Extension.async(rm),
|
||||
unlink: Extension.async(unlink),
|
||||
rename: Extension.async(rename),
|
||||
copyFile: Extension.async(copyFile),
|
||||
constants,
|
||||
},
|
||||
// Sync variants return the same Effect, which a synchronous global blocks on.
|
||||
readFileSync: (...args: Parameters<typeof readFile>) => readFile(...args),
|
||||
writeFileSync: (...args: Parameters<typeof writeFile>) => writeFile(...args),
|
||||
appendFileSync: (...args: Parameters<typeof appendFile>) => appendFile(...args),
|
||||
readdirSync: (...args: Parameters<typeof readdir>) => readdir(...args),
|
||||
statSync: (...args: Parameters<typeof stat>) => stat(...args),
|
||||
lstatSync: (...args: Parameters<typeof lstat>) => lstat(...args),
|
||||
accessSync: (...args: Parameters<typeof access>) => access(...args),
|
||||
existsSync,
|
||||
mkdirSync: (...args: Parameters<typeof mkdir>) => mkdir(...args),
|
||||
rmSync: (...args: Parameters<typeof rm>) => rm(...args),
|
||||
unlinkSync: (...args: Parameters<typeof unlink>) => unlink(...args),
|
||||
renameSync: (...args: Parameters<typeof rename>) => rename(...args),
|
||||
copyFileSync: (...args: Parameters<typeof copyFile>) => copyFile(...args),
|
||||
constants,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
/** What to show for an fs call: the method and the path, plus the destination of a rename or copy. */
|
||||
export const display = (name: string, args: ReadonlyArray<unknown>) => {
|
||||
const method = name.slice(name.lastIndexOf(".") + 1)
|
||||
const shown: Record<string, string> = { method, path: String(args[0]) }
|
||||
if (method.startsWith("rename") || method.startsWith("copyFile")) shown.destination = String(args[1])
|
||||
return shown
|
||||
}
|
||||
|
||||
const run = <A, E, R>(syscall: string, file: PathLike, body: (host: Host, file: string) => Effect.Effect<A, E, R>) => {
|
||||
const resolved = toPath(file)
|
||||
return Effect.gen(function* () {
|
||||
const host: Host = {
|
||||
execution: yield* CodeModeExecution.Service,
|
||||
access: yield* FileAccess.Service,
|
||||
fileWrite: yield* FileWrite.Service,
|
||||
mutation: yield* FileMutation.Service,
|
||||
files: (yield* Environment.Service).files,
|
||||
}
|
||||
return yield* body(host, resolved)
|
||||
}).pipe(Effect.mapError((error) => nodeError(error, syscall, resolved)))
|
||||
}
|
||||
|
||||
type Host = {
|
||||
readonly execution: ToolContext
|
||||
readonly access: FileAccess.Interface
|
||||
readonly fileWrite: FileWrite.Interface
|
||||
readonly mutation: FileMutation.Interface
|
||||
readonly files: Environment.Files
|
||||
}
|
||||
|
||||
// Text, including bytes that are valid UTF-8, goes through FileWrite like the write tool; other bytes are written as is
|
||||
// after the same approval, without a diff.
|
||||
const write = (host: Host, file: string, content: Data) =>
|
||||
Effect.gen(function* () {
|
||||
const text = typeof content === "string" ? content : utf8(content)
|
||||
if (text !== undefined) return void (yield* host.fileWrite.write({ path: file, content: text }, host.execution))
|
||||
const target = yield* host.fileWrite.target(file, host.execution)
|
||||
yield* host.fileWrite.authorize(target, host.execution)
|
||||
yield* host.mutation.write({ target, content })
|
||||
})
|
||||
|
||||
const utf8 = (bytes: Uint8Array) => {
|
||||
try {
|
||||
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
const statOf = (host: Host, file: string, follow: boolean) =>
|
||||
Effect.gen(function* () {
|
||||
const target = yield* host.access.authorizeRead(file, host.execution)
|
||||
const info = yield* host.files.stat(target.absolute)
|
||||
const followed =
|
||||
follow && info.type === "symlink"
|
||||
? yield* host.files.read(target.absolute, { offset: 0, length: 0 }).pipe(
|
||||
Effect.map((result) => result.info),
|
||||
Effect.catchTag("Environment.WrongKind", (error) => Effect.succeed({ ...info, type: error.actual })),
|
||||
)
|
||||
: info
|
||||
return { size: followed.size, mtimeMs: followed.mtimeMs, mtime: new Date(followed.mtimeMs), ...kind(followed.type) }
|
||||
})
|
||||
|
||||
const optionalStat = (host: Host, absolute: string) =>
|
||||
host.files.stat(absolute).pipe(Effect.catchTag("Environment.NotFound", () => Effect.undefined))
|
||||
|
||||
const kind = (type: Environment.FileType) => ({
|
||||
isFile: () => type === "file",
|
||||
isDirectory: () => type === "directory",
|
||||
isSymbolicLink: () => type === "symlink",
|
||||
})
|
||||
|
||||
// A target and, for a directory, everything inside it.
|
||||
const tree = (host: Host, target: FileAccess.Target, info: Environment.FileInfo) =>
|
||||
info.type !== "directory"
|
||||
? Effect.succeed([target])
|
||||
: walk(host.files, target.absolute, true).pipe(
|
||||
Effect.flatMap((entries) =>
|
||||
Effect.forEach(entries, (entry) =>
|
||||
host.access.resolve({
|
||||
path: path.join(target.absolute, entry.relative),
|
||||
kind: entry.type === "directory" ? "directory" : "file",
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.map((inside) => [target, ...inside]),
|
||||
)
|
||||
|
||||
type Entry = { readonly relative: string; readonly type: Environment.FileType }
|
||||
|
||||
const walk = (
|
||||
files: Environment.Files,
|
||||
directory: string,
|
||||
recursive: boolean,
|
||||
prefix = "",
|
||||
): Effect.Effect<Array<Entry>, Environment.NotFound | Environment.WrongKind | Environment.Failed> =>
|
||||
files.list(directory).pipe(
|
||||
Effect.flatMap((entries) =>
|
||||
Effect.forEach(entries, (entry) => {
|
||||
const item = { relative: prefix === "" ? entry.name : path.join(prefix, entry.name), type: entry.type }
|
||||
if (!recursive || entry.type !== "directory") return Effect.succeed([item])
|
||||
return walk(files, path.join(directory, entry.name), true, item.relative).pipe(
|
||||
Effect.map((inside) => [item, ...inside]),
|
||||
)
|
||||
}),
|
||||
),
|
||||
Effect.map((items) => items.flat()),
|
||||
)
|
||||
|
||||
const encodingOf = (options: EncodingOption) => (typeof options === "string" ? options : options?.encoding) ?? undefined
|
||||
|
||||
const bytesOrText = (data: Data, options: EncodingOption): Data => {
|
||||
const encoding = encodingOf(options)
|
||||
if (typeof data !== "string" || encoding === undefined || encoding === "utf8" || encoding === "utf-8") return data
|
||||
return Buffer.from(data, encoding)
|
||||
}
|
||||
|
||||
const toPath = (file: PathLike) => {
|
||||
if (file instanceof URL) return fileURLToPath(file)
|
||||
if (typeof file === "string") return file
|
||||
throw new TypeError(`The "path" argument must be a string or a file URL. Received ${typeof file}`)
|
||||
}
|
||||
|
||||
const descriptions = {
|
||||
ENOENT: [-2, "no such file or directory"],
|
||||
EEXIST: [-17, "file already exists"],
|
||||
EISDIR: [-21, "illegal operation on a directory"],
|
||||
ENOTDIR: [-20, "not a directory"],
|
||||
ENOTEMPTY: [-66, "directory not empty"],
|
||||
} as const
|
||||
|
||||
const systemError = (code: keyof typeof descriptions, syscall: string, file: string) =>
|
||||
Effect.fail(nodeSystemError(code, syscall, file))
|
||||
|
||||
const nodeSystemError = (code: string, syscall: string, file: string, message?: string) => {
|
||||
const known = code in descriptions ? descriptions[code as keyof typeof descriptions] : undefined
|
||||
return Object.assign(new Error(known ? `${code}: ${known[1]}, ${syscall} '${file}'` : (message ?? code)), {
|
||||
...(known ? { errno: known[0] } : {}),
|
||||
code,
|
||||
syscall,
|
||||
path: file,
|
||||
})
|
||||
}
|
||||
|
||||
const nodeError = (error: unknown, syscall: string, file: string) => {
|
||||
if (error instanceof Environment.NotFound) return nodeSystemError("ENOENT", syscall, file)
|
||||
if (error instanceof Environment.WrongKind)
|
||||
return nodeSystemError(error.actual === "directory" ? "EISDIR" : "ENOTDIR", syscall, file)
|
||||
if (error instanceof Environment.Failed) {
|
||||
const cause: unknown = error.cause
|
||||
const code = cause instanceof Error && "code" in cause && typeof cause.code === "string" ? cause.code : undefined
|
||||
if (code !== undefined && cause instanceof Error) return nodeSystemError(code, syscall, file, cause.message)
|
||||
return new Error(`${syscall} '${file}' failed: ${cause instanceof Error ? cause.message : String(cause)}`)
|
||||
}
|
||||
// The feedback is the user talking to the model, so it must reach the program as the error's message.
|
||||
if (error instanceof Permission.CorrectedError)
|
||||
return new Error(`The user rejected ${syscall} ${file}: ${error.feedback}`)
|
||||
return error
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export * as CodeModePath from "./path.js"
|
||||
|
||||
import { Extension } from "@opencode/codemode"
|
||||
import path from "path"
|
||||
|
||||
// Only the functions and separators cross: `posix` and `win32` point back at the module, which a global cannot hold.
|
||||
const { posix: _posix, win32: _win32, ...functions } = path.posix
|
||||
|
||||
export const extension = Extension.make({ name: "path", globals: { path: functions } })
|
||||
@@ -18,6 +18,8 @@ import { Permission } from "../permission.js"
|
||||
import { CodeModeCatalog } from "./catalog.js"
|
||||
import { CodeModeExecution } from "./execution.js"
|
||||
import { CodeModeFetch } from "./fetch.js"
|
||||
import { CodeModeFs } from "./fs.js"
|
||||
import { CodeModePath } from "./path.js"
|
||||
|
||||
const ExecuteFile = Schema.Struct({
|
||||
data: Schema.String,
|
||||
@@ -74,7 +76,7 @@ export type Inventory = {
|
||||
// Invariant model-facing guidance; the changing tool catalog is delivered through Instructions.
|
||||
const description = [
|
||||
"Run JavaScript in a confined Code Mode runtime to script tool calls and HTTP requests and compose their results.",
|
||||
"`fetch` is available for HTTP requests. Imports, direct filesystem access, and timers are unavailable: there is no `setTimeout` or sleep. All other external access goes through `tools`.",
|
||||
"`fetch` is available for HTTP requests, and Node's `fs` (with `fs.promises`) and `path` for files, also importable as `node:fs`, `fs/promises` and `node:path`. Other modules and packages, and timers, are unavailable: there is no `setTimeout` or sleep. All other external access goes through `tools`.",
|
||||
"Within `{ code }`, the only callable tools are those explicitly listed in the Code Mode catalog instructions or returned by the `search` function. Inside `{ code }`, ignore tools shown outside the Code Mode catalog. They are not available in the Code Mode runtime.",
|
||||
'Call tools through `tools` using only exact paths and signatures from the catalog. Do not infer or normalize tool names; preserve bracket notation such as `tools.<namespace>["tool-name"](input)`.',
|
||||
"Prefer an explicit `return`; if omitted, the final top-level expression becomes the result.",
|
||||
@@ -85,6 +87,7 @@ export const create = (
|
||||
inventory: Inventory,
|
||||
executeTool: (name: string, tool: Info, input: unknown, context: ToolContext) => Effect.Effect<Result, Error>,
|
||||
permission: Permission.Interface,
|
||||
fileServices: Context.Context<CodeModeFs.Services>,
|
||||
) => {
|
||||
return {
|
||||
name: "execute",
|
||||
@@ -147,6 +150,7 @@ export const create = (
|
||||
),
|
||||
Effect.provideService(CodeModeExecution.Service, context),
|
||||
Effect.provideService(Permission.Service, permission),
|
||||
Effect.provideContext(fileServices),
|
||||
)
|
||||
const toolCalls = yield* Ref.get(calls)
|
||||
const collected = (yield* Ref.get(files))
|
||||
@@ -210,6 +214,8 @@ function progressHooks(record: (update: (items: Array<ExecuteCall>) => Array<Exe
|
||||
case "fetch":
|
||||
return start(call, { tool: call.name, status: "running", input: CodeModeFetch.display(call.args) })
|
||||
default:
|
||||
if (call.extension === "fs")
|
||||
return start(call, { tool: "fs", status: "running", input: CodeModeFs.display(call.name, call.args) })
|
||||
return Effect.void
|
||||
}
|
||||
},
|
||||
@@ -262,15 +268,21 @@ function renderCatalog(root: CatalogNode): ReadonlyArray<CodeModeCatalog.Tool |
|
||||
|
||||
// Inventories are immutable snapshots, so tool definitions, schemas, and the search index are built
|
||||
// once per inventory and shared by its catalog and every execution.
|
||||
const runtimes = new WeakMap<Inventory, CodeMode.Runtime<Run | CodeModeExecution.Service | Permission.Service>>()
|
||||
const runtimes = new WeakMap<
|
||||
Inventory,
|
||||
CodeMode.Runtime<Run | CodeModeExecution.Service | Permission.Service | CodeModeFs.Services>
|
||||
>()
|
||||
|
||||
function runtime(inventory: Inventory) {
|
||||
const cached = runtimes.get(inventory)
|
||||
if (cached) return cached
|
||||
const tools = inventoryTools(inventory)
|
||||
const created = CodeMode.make<typeof tools, [typeof CodeModeFetch.extension]>({
|
||||
const created = CodeMode.make<
|
||||
typeof tools,
|
||||
[typeof CodeModeFetch.extension, typeof CodeModeFs.extension, typeof CodeModePath.extension]
|
||||
>({
|
||||
tools,
|
||||
extensions: [CodeModeFetch.extension],
|
||||
extensions: [CodeModeFetch.extension, CodeModeFs.extension, CodeModePath.extension],
|
||||
hooks: runHooks,
|
||||
})
|
||||
runtimes.set(inventory, created)
|
||||
@@ -278,7 +290,7 @@ function runtime(inventory: Inventory) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs one program over `inventory` alone with no model step and no `fetch`, binding `bindings` as outer globals.
|
||||
* Runs one program over `inventory` alone with no model step and no `fetch`, `fs` or `path`, binding `bindings` as outer globals.
|
||||
*/
|
||||
export const check = (
|
||||
inventory: Inventory,
|
||||
|
||||
@@ -28,9 +28,9 @@ export interface Interface {
|
||||
file: string,
|
||||
context: FileAccess.Invocation,
|
||||
) => Effect.Effect<FileAccess.Target, Environment.Failed | Permission.Error | SessionErrors.NotFoundError>
|
||||
/** Request edit approval for a resolved target, showing the change when it is known. */
|
||||
/** Request edit approval for resolved targets in one request, showing the change when it is known. */
|
||||
readonly authorize: (
|
||||
target: FileAccess.Target,
|
||||
target: FileAccess.Target | ReadonlyArray<FileAccess.Target>,
|
||||
context: FileAccess.Invocation,
|
||||
preview?: FileDiff.Info,
|
||||
) => Effect.Effect<void, Permission.Error | SessionErrors.NotFoundError>
|
||||
@@ -67,13 +67,13 @@ const layer = Layer.effect(
|
||||
})
|
||||
|
||||
const authorize = Effect.fn("FileWrite.authorize")(function* (
|
||||
target: FileAccess.Target,
|
||||
target: FileAccess.Target | ReadonlyArray<FileAccess.Target>,
|
||||
context: FileAccess.Invocation,
|
||||
preview?: FileDiff.Info,
|
||||
) {
|
||||
yield* permission.assert({
|
||||
action: "edit",
|
||||
resources: [target.resource],
|
||||
resources: [...new Set((Array.isArray(target) ? target : [target]).map((item) => item.resource))],
|
||||
save: ["*"],
|
||||
metadata: preview ? { files: [preview] } : undefined,
|
||||
...Permission.invocation(context),
|
||||
|
||||
@@ -9,7 +9,12 @@ import { Context, Effect, Layer, Ref, Result, Schema, SchemaIssue, Types } from
|
||||
import { makeLocationNode } from "@opencode/util/effect/app-node"
|
||||
import type { Agent } from "./agent.js"
|
||||
import { CodeModeCatalog } from "./codemode/catalog.js"
|
||||
import type { CodeModeFs } from "./codemode/fs.js"
|
||||
import { CodeModeTool } from "./codemode/tool.js"
|
||||
import { Environment } from "./environment/index.js"
|
||||
import { FileAccess } from "./file-access.js"
|
||||
import { FileMutation } from "./file-mutation.js"
|
||||
import { FileWrite } from "./file-write.js"
|
||||
import { Image } from "./image.js"
|
||||
import { Permission } from "./permission.js"
|
||||
import { PluginHooks } from "./plugin/hooks.js"
|
||||
@@ -87,6 +92,7 @@ const layer = Layer.effect(
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const image = yield* Image.Service
|
||||
const permission = yield* Permission.Service
|
||||
const files = yield* Effect.context<CodeModeFs.Services>()
|
||||
|
||||
type NormalizedItem = Tool.Content | "decode" | "size"
|
||||
const normalizeImages = Effect.fnUntraced(function* (content: ReadonlyArray<Tool.Content>) {
|
||||
@@ -274,6 +280,7 @@ const layer = Layer.effect(
|
||||
codeModeInventory,
|
||||
(name, tool, input, context) => run(tool, name, input, context),
|
||||
permission,
|
||||
files,
|
||||
)
|
||||
: undefined
|
||||
const codeModeCatalog = !codeModeEnabled
|
||||
@@ -415,5 +422,13 @@ function namespaceError(name: string) {
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [PluginHooks.node, Image.node, Permission.node],
|
||||
deps: [
|
||||
PluginHooks.node,
|
||||
Image.node,
|
||||
Permission.node,
|
||||
Environment.node,
|
||||
FileAccess.node,
|
||||
FileMutation.node,
|
||||
FileWrite.node,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -68,7 +68,7 @@ import { advance, drain } from "./lib/clock"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { registerIntegrationPolicy, setOrganizationPolicy } from "./fixture/policy"
|
||||
import { imagePassthrough } from "./lib/image"
|
||||
import { location } from "./fixture/location"
|
||||
import { location, tempLocationLayer } from "./fixture/location"
|
||||
import { tmpdirScoped } from "./fixture/tmpdir"
|
||||
import { hostEnvironmentLayer, recordingEnvironmentLayer } from "./fixture/environment"
|
||||
import {
|
||||
@@ -586,6 +586,7 @@ const it = testEffect(
|
||||
Permission.node.replace(permissions),
|
||||
Bus.node.replace(events),
|
||||
Image.node.replace(imagePassthrough),
|
||||
Location.node.replace(tempLocationLayer),
|
||||
Global.node.replace(Global.layerWith({ data: outputData })),
|
||||
]),
|
||||
)
|
||||
@@ -2534,6 +2535,7 @@ testEffect(Layer.empty).live("preserves plugin transforms through MCP catalog up
|
||||
Effect.provide(
|
||||
Layer.fresh(
|
||||
AppNodeBuilder.build(LayerNode.group([Tool.node, McpTool.node, Bus.node]), [
|
||||
Location.node.replace(tempLocationLayer),
|
||||
Mcp.node.replace(
|
||||
Layer.mock(Mcp.Service, {
|
||||
awaitStartup: () => Effect.void,
|
||||
@@ -2586,6 +2588,7 @@ testEffect(Layer.empty).effect("coalesces queued MCP tool notifications after in
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
AppNodeBuilder.build(LayerNode.group([Tool.node, McpTool.node, Bus.node]), [
|
||||
Location.node.replace(tempLocationLayer),
|
||||
Mcp.node.replace(
|
||||
Layer.mock(Mcp.Service, {
|
||||
awaitStartup: () => Effect.void,
|
||||
@@ -2626,6 +2629,7 @@ const startupRegistry = (url: string) =>
|
||||
Bus.node.replace(events),
|
||||
Image.node.replace(imagePassthrough),
|
||||
Global.node.replace(Global.layerWith({ data: outputData })),
|
||||
Location.node.replace(tempLocationLayer),
|
||||
])
|
||||
|
||||
testEffect(Layer.empty).effect("flush waits for a server that connects within its startup window", () =>
|
||||
|
||||
@@ -1,16 +1,32 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import type { CodeModeFs } from "@opencode/core/codemode/fs"
|
||||
import { CodeModeTool } from "@opencode/core/codemode/tool"
|
||||
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
|
||||
import { Environment } from "@opencode/core/environment/index"
|
||||
import { FileAccess } from "@opencode/core/file-access"
|
||||
import { FileMutation } from "@opencode/core/file-mutation"
|
||||
import { FileWrite } from "@opencode/core/file-write"
|
||||
import { Formatter } from "@opencode/core/formatter"
|
||||
import { Location } from "@opencode/core/location"
|
||||
import { Permission } from "@opencode/core/permission"
|
||||
import { AbsolutePath } from "@opencode/core/schema"
|
||||
import { Tool } from "@opencode/core/tool"
|
||||
import { execute } from "@opencode/core/tool/runtime"
|
||||
import { Agent } from "@opencode/schema/agent"
|
||||
import { Session } from "@opencode/schema/session"
|
||||
import { SessionMessage } from "@opencode/schema/session-message"
|
||||
import type { Info } from "@opencode/schema/tool"
|
||||
import { Deferred, Effect, Fiber, Schema } from "effect"
|
||||
import { LayerNode } from "@opencode/util/effect/layer-node"
|
||||
import { Deferred, Effect, Fiber, Layer, Schema } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { hostEnvironmentLayer } from "./fixture/environment"
|
||||
import { location } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { it } from "./lib/effect"
|
||||
import { permissionService } from "./lib/permission"
|
||||
import { permissionService, permissionLayer } from "./lib/permission"
|
||||
|
||||
const context = {
|
||||
sessionID: Session.ID.make("ses_execute"),
|
||||
@@ -22,14 +38,33 @@ const context = {
|
||||
|
||||
const allowAll = permissionService({ assert: () => Effect.void })
|
||||
|
||||
const createCodeMode = (tools: ReadonlyMap<string, Info>, permission = allowAll) =>
|
||||
CodeModeTool.create({ tools }, (_, tool, input, context) => execute(tool, input, context), permission)
|
||||
// The file services of a Location at `directory`, asking `permission` like the execute call's own.
|
||||
const fileServices = (directory: string, permission: Partial<Permission.Interface> = { assert: () => Effect.void }) =>
|
||||
Effect.runSync(
|
||||
Effect.context<CodeModeFs.Services>().pipe(
|
||||
Effect.provide(
|
||||
AppNodeBuilder.build(LayerNode.group([Environment.node, FileAccess.node, FileMutation.node, FileWrite.node]), [
|
||||
Environment.node.replace(hostEnvironmentLayer),
|
||||
Location.node.replace(
|
||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
|
||||
),
|
||||
Formatter.node.replace(Layer.mock(Formatter.Service, { file: () => Effect.succeed(false) })),
|
||||
Permission.node.replace(permissionLayer(permission)),
|
||||
]),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const unusedFiles = fileServices(os.tmpdir())
|
||||
|
||||
const createCodeMode = (tools: ReadonlyMap<string, Info>, permission = allowAll, files = unusedFiles) =>
|
||||
CodeModeTool.create({ tools }, (_, tool, input, context) => execute(tool, input, context), permission, files)
|
||||
|
||||
test("execute describes invariant Code Mode behavior", () => {
|
||||
expect(createCodeMode(new Map()).description).toBe(
|
||||
[
|
||||
"Run JavaScript in a confined Code Mode runtime to script tool calls and HTTP requests and compose their results.",
|
||||
"`fetch` is available for HTTP requests. Imports, direct filesystem access, and timers are unavailable: there is no `setTimeout` or sleep. All other external access goes through `tools`.",
|
||||
"`fetch` is available for HTTP requests, and Node's `fs` (with `fs.promises`) and `path` for files, also importable as `node:fs`, `fs/promises` and `node:path`. Other modules and packages, and timers, are unavailable: there is no `setTimeout` or sleep. All other external access goes through `tools`.",
|
||||
"Within `{ code }`, the only callable tools are those explicitly listed in the Code Mode catalog instructions or returned by the `search` function. Inside `{ code }`, ignore tools shown outside the Code Mode catalog. They are not available in the Code Mode runtime.",
|
||||
'Call tools through `tools` using only exact paths and signatures from the catalog. Do not infer or normalize tool names; preserve bracket notation such as `tools.<namespace>["tool-name"](input)`.',
|
||||
"Prefer an explicit `return`; if omitted, the final top-level expression becomes the result.",
|
||||
@@ -143,6 +178,92 @@ describe("fetch permissions", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("fs", () => {
|
||||
const source = { type: "tool" as const, messageID: context.messageID, id: context.id }
|
||||
|
||||
test("fs reads and writes files in the session's directory, asking read and edit permission as the execute call", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Bun.write(path.join(tmp.path, "notes.txt"), "hello")
|
||||
const asked: Array<Permission.AssertInput> = []
|
||||
const record = { assert: (input: Permission.AssertInput) => Effect.sync(() => void asked.push(input)) }
|
||||
const result = await Effect.runPromise(
|
||||
createCodeMode(new Map(), permissionService(record), fileServices(tmp.path, record)).execute(
|
||||
{
|
||||
code: `import { readFile } from "node:fs/promises"
|
||||
const text = await readFile("notes.txt", "utf8")
|
||||
fs.writeFileSync("out/copy.txt", text + " world")
|
||||
return [text, fs.readdirSync("out"), path.join("out", "..", "notes.txt")]`,
|
||||
},
|
||||
context,
|
||||
),
|
||||
)
|
||||
|
||||
expect(JSON.parse(result.output.output)).toEqual(["hello", ["copy.txt"], "notes.txt"])
|
||||
expect(await Bun.file(path.join(tmp.path, "out", "copy.txt")).text()).toBe("hello world")
|
||||
expect(asked.map((input) => [input.action, input.resources, input.sessionID, input.source])).toEqual([
|
||||
["read", ["notes.txt"], context.sessionID, source],
|
||||
["edit", ["out/copy.txt"], context.sessionID, source],
|
||||
["read", ["out"], context.sessionID, source],
|
||||
])
|
||||
expect(asked[1]?.metadata).toMatchObject({ files: [{ file: "out/copy.txt", status: "added", additions: 1 }] })
|
||||
expect(result.output.toolCalls).toEqual([
|
||||
{ tool: "fs", status: "completed", input: { method: "readFile", path: "notes.txt" } },
|
||||
{ tool: "fs", status: "completed", input: { method: "writeFileSync", path: "out/copy.txt" } },
|
||||
{ tool: "fs", status: "completed", input: { method: "readdirSync", path: "out" } },
|
||||
])
|
||||
})
|
||||
|
||||
test("a denied write never touches disk: the sync call throws and the promise rejects", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const denyEdits = {
|
||||
assert: (input: Permission.AssertInput) =>
|
||||
input.action === "edit"
|
||||
? Effect.fail(
|
||||
new Permission.BlockedError({ rules: [], permission: input.action, resources: input.resources }),
|
||||
)
|
||||
: Effect.void,
|
||||
}
|
||||
const result = await Effect.runPromise(
|
||||
createCodeMode(new Map(), permissionService(denyEdits), fileServices(tmp.path, denyEdits)).execute(
|
||||
{
|
||||
code: `let thrown
|
||||
try { fs.writeFileSync("a.txt", "x") } catch (e) { thrown = e.message }
|
||||
const [rejected] = await Promise.allSettled([fs.promises.writeFile("b.txt", "x")])
|
||||
return [thrown, rejected.status, fs.existsSync("a.txt")]`,
|
||||
},
|
||||
context,
|
||||
),
|
||||
)
|
||||
|
||||
const [thrown, status, exists] = JSON.parse(result.output.output)
|
||||
expect(thrown).toContain("Permission denied")
|
||||
expect([status, exists]).toEqual(["rejected", false])
|
||||
expect(await fs.readdir(tmp.path)).toEqual([])
|
||||
})
|
||||
|
||||
test("a missing file fails with a Node-shaped ENOENT error", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const result = await Effect.runPromise(
|
||||
createCodeMode(new Map(), allowAll, fileServices(tmp.path)).execute(
|
||||
{
|
||||
code: `try { await fs.promises.readFile("missing.txt") } catch (e) {
|
||||
return [e instanceof Error, e.code, e.syscall, e.path, e.message]
|
||||
}`,
|
||||
},
|
||||
context,
|
||||
),
|
||||
)
|
||||
|
||||
expect(JSON.parse(result.output.output)).toEqual([
|
||||
true,
|
||||
"ENOENT",
|
||||
"open",
|
||||
"missing.txt",
|
||||
"ENOENT: no such file or directory, open 'missing.txt'",
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
test("canonical execution distinguishes declared, model-only, and raw schema outputs", async () => {
|
||||
const declared: Info = {
|
||||
name: "declared",
|
||||
@@ -279,10 +400,20 @@ test("executions share the prepared runtime of an inventory while keeping per-ca
|
||||
execute: (input, context) => Effect.succeed({ content: `${context.id}:${(input as { value: string }).value}` }),
|
||||
}
|
||||
const inventory = { tools: new Map([["echo", echo]]) }
|
||||
const codeMode = CodeModeTool.create(inventory, (_, tool, input, context) => execute(tool, input, context), allowAll)
|
||||
const codeMode = CodeModeTool.create(
|
||||
inventory,
|
||||
(_, tool, input, context) => execute(tool, input, context),
|
||||
allowAll,
|
||||
unusedFiles,
|
||||
)
|
||||
const run = (id: string) => {
|
||||
const progress: Array<Tool.Metadata> = []
|
||||
return CodeModeTool.create(inventory, (_, tool, input, context) => execute(tool, input, context), allowAll)
|
||||
return CodeModeTool.create(
|
||||
inventory,
|
||||
(_, tool, input, context) => execute(tool, input, context),
|
||||
allowAll,
|
||||
unusedFiles,
|
||||
)
|
||||
.execute(
|
||||
{ code: `return await tools.echo({ value: "${id}" })` },
|
||||
{ ...context, id: Tool.CallID.make(id), progress: (update) => Effect.sync(() => progress.push(update)) },
|
||||
|
||||
@@ -6,6 +6,8 @@ import { Form } from "@opencode/core/form"
|
||||
import { Permission } from "@opencode/core/permission"
|
||||
import { Session } from "@opencode/core/session"
|
||||
import { Tool } from "@opencode/core/tool"
|
||||
import { tempLocationLayer } from "./fixture/location"
|
||||
import { Location } from "@opencode/core/location"
|
||||
import { QuestionTool } from "@opencode/core/tool/plugin/question"
|
||||
import { Image } from "@opencode/core/image"
|
||||
import { testEffect } from "./lib/effect"
|
||||
@@ -69,6 +71,7 @@ const it = testEffect(
|
||||
Permission.node.replace(permission),
|
||||
Form.node.replace(form),
|
||||
Image.node.replace(imagePassthrough),
|
||||
Location.node.replace(tempLocationLayer),
|
||||
]),
|
||||
)
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ import { Session } from "@opencode/core/session"
|
||||
import { SessionMessage } from "@opencode/core/session/message"
|
||||
import { State } from "@opencode/core/state"
|
||||
import { Tool } from "@opencode/core/tool"
|
||||
import { tempLocationLayer } from "./fixture/location"
|
||||
import { Location } from "@opencode/core/location"
|
||||
import type { Info } from "@opencode/schema/tool"
|
||||
import { LayerNode } from "@opencode/util/effect/layer-node"
|
||||
import { codeModeListings, executeTool, toolDefinitions } from "./lib/tool"
|
||||
@@ -40,6 +42,7 @@ const imageStore = Layer.mock(Image.Service, {
|
||||
})
|
||||
const registryLayer = AppNodeBuilder.build(LayerNode.group([Tool.node, PluginHooks.node]), [
|
||||
Image.node.replace(imageStore),
|
||||
Location.node.replace(tempLocationLayer),
|
||||
Permission.node.replace(permissionLayer()),
|
||||
])
|
||||
const it = testEffect(registryLayer)
|
||||
|
||||
@@ -7,6 +7,8 @@ import { Permission } from "@opencode/core/permission"
|
||||
import { Schedule } from "@opencode/core/schedule"
|
||||
import { Session } from "@opencode/core/session"
|
||||
import { Tool } from "@opencode/core/tool"
|
||||
import { tempLocationLayer } from "./fixture/location"
|
||||
import { Location } from "@opencode/core/location"
|
||||
import { ScheduleTools } from "@opencode/core/tool/plugin/schedule"
|
||||
import { Document, Info, type Entry } from "@opencode/schema/config"
|
||||
import { ConfigExperimental } from "@opencode/schema/config/experimental"
|
||||
@@ -24,7 +26,11 @@ const names = ["schedule_create", "schedule_list", "schedule_pause", "schedule_r
|
||||
)
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(Tool.node, [Image.node.replace(imagePassthrough), Permission.node.replace(permissionLayer())]),
|
||||
AppNodeBuilder.build(Tool.node, [
|
||||
Image.node.replace(imagePassthrough),
|
||||
Permission.node.replace(permissionLayer()),
|
||||
Location.node.replace(tempLocationLayer),
|
||||
]),
|
||||
)
|
||||
|
||||
/** An in-memory Schedule service. The real one is covered in schedule.test.ts; this exercises the tool layer. */
|
||||
|
||||
@@ -14,6 +14,8 @@ import { SkillTool } from "@opencode/core/tool/plugin/skill"
|
||||
import { Tool } from "@opencode/core/tool"
|
||||
import { tmpdirScoped } from "./fixture/tmpdir"
|
||||
import { Image } from "@opencode/core/image"
|
||||
import { Location } from "@opencode/core/location"
|
||||
import { tempLocationLayer } from "./fixture/location"
|
||||
import { it } from "./lib/effect"
|
||||
import { imagePassthrough } from "./lib/image"
|
||||
import { permissionLayer } from "./lib/permission"
|
||||
@@ -61,6 +63,8 @@ describe("SkillTool", () => {
|
||||
Config.node.replace(Config.testLayer()),
|
||||
Permission.node.replace(permissionLayer({ assert: () => Effect.die("Blocked skill reached authorization") })),
|
||||
Image.node.replace(imagePassthrough),
|
||||
Location.node.replace(tempLocationLayer),
|
||||
Location.node.replace(tempLocationLayer),
|
||||
]),
|
||||
),
|
||||
),
|
||||
@@ -113,6 +117,9 @@ describe("SkillTool", () => {
|
||||
Permission.node.replace(permission),
|
||||
Skill.node.replace(skills),
|
||||
Image.node.replace(imagePassthrough),
|
||||
Location.node.replace(tempLocationLayer),
|
||||
Location.node.replace(tempLocationLayer),
|
||||
Location.node.replace(tempLocationLayer),
|
||||
])
|
||||
|
||||
return yield* Effect.gen(function* () {
|
||||
|
||||
@@ -6,6 +6,8 @@ import { Image } from "@opencode/core/image"
|
||||
import { Permission } from "@opencode/core/permission"
|
||||
import { Session } from "@opencode/core/session"
|
||||
import { Tool } from "@opencode/core/tool"
|
||||
import { tempLocationLayer } from "./fixture/location"
|
||||
import { Location } from "@opencode/core/location"
|
||||
import { TodoTool } from "@opencode/core/tool/plugin/todo"
|
||||
import { Document, Info, type Entry } from "@opencode/schema/config"
|
||||
import { ConfigExperimental } from "@opencode/schema/config/experimental"
|
||||
@@ -24,7 +26,11 @@ const todos = [
|
||||
] as const
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(Tool.node, [Image.node.replace(imagePassthrough), Permission.node.replace(permissionLayer())]),
|
||||
AppNodeBuilder.build(Tool.node, [
|
||||
Image.node.replace(imagePassthrough),
|
||||
Permission.node.replace(permissionLayer()),
|
||||
Location.node.replace(tempLocationLayer),
|
||||
]),
|
||||
)
|
||||
|
||||
const setup = (experimental: ConfigExperimental.Info | undefined) =>
|
||||
|
||||
@@ -8,6 +8,8 @@ import { LayerNodePlatform } from "@opencode/util/effect/app-node-platform"
|
||||
import { Permission } from "@opencode/core/permission"
|
||||
import { Session } from "@opencode/core/session"
|
||||
import { Tool } from "@opencode/core/tool"
|
||||
import { tempLocationLayer } from "./fixture/location"
|
||||
import { Location } from "@opencode/core/location"
|
||||
import { WebFetchTool } from "@opencode/core/tool/plugin/webfetch"
|
||||
import { makeLocationNode } from "@opencode/util/effect/app-node"
|
||||
import { Image } from "@opencode/core/image"
|
||||
@@ -58,6 +60,7 @@ const toolLayer = (replacements: LayerNode.Replacements = []) =>
|
||||
AppNodeBuilder.build(LayerNode.group([Tool.node, webFetchToolNode]), [
|
||||
Permission.node.replace(permission),
|
||||
Image.node.replace(imagePassthrough),
|
||||
Location.node.replace(tempLocationLayer),
|
||||
...replacements,
|
||||
])
|
||||
const it = testEffect(toolLayer([LayerNodePlatform.httpClient.replace(http)]))
|
||||
|
||||
@@ -13,6 +13,8 @@ import { Tool } from "@opencode/core/tool"
|
||||
import { WebSearchTool } from "@opencode/core/tool/plugin/websearch"
|
||||
import { makeLocationNode } from "@opencode/util/effect/app-node"
|
||||
import { Image } from "@opencode/core/image"
|
||||
import { Location } from "@opencode/core/location"
|
||||
import { tempLocationLayer } from "./fixture/location"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { imagePassthrough } from "./lib/image"
|
||||
import { permissionLayer } from "./lib/permission"
|
||||
@@ -91,6 +93,7 @@ const setup = Effect.gen(function* () {
|
||||
}),
|
||||
),
|
||||
Image.node.replace(imagePassthrough),
|
||||
Location.node.replace(tempLocationLayer),
|
||||
]),
|
||||
)
|
||||
return Object.assign(fixture, { websearch, kv, registry: Context.get(context, Tool.Service) })
|
||||
|
||||
Reference in New Issue
Block a user