mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-07 01:29:44 -04:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a355f7f48 | |||
| 0520cc88fc | |||
| 4b61fbd92d |
@@ -17,6 +17,7 @@
|
||||
"opencode": "./bin/opencode"
|
||||
},
|
||||
"exports": {
|
||||
"./environment": "./src/environment/index.ts",
|
||||
"./session/runner": "./src/session/runner/index.ts",
|
||||
"./instructions": "./src/instructions/index.ts",
|
||||
"./*": "./src/*.ts"
|
||||
|
||||
@@ -41,7 +41,7 @@ export type Result =
|
||||
| { readonly type: "rejected"; readonly diagnostics: readonly Diagnostic[] }
|
||||
|
||||
const options = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
|
||||
const unsupportedTopLevel = ["logLevel", "server", "subagent_depth", "layout"] as const
|
||||
const unsupportedTopLevel = ["logLevel", "server", "small_model", "subagent_depth", "layout"] as const
|
||||
const unsupportedExperimental = [
|
||||
"disable_paste_summary",
|
||||
"batch_tool",
|
||||
@@ -113,23 +113,6 @@ export function normalize(input: unknown): Result {
|
||||
const legacyAgents = mapValues(decodeMap(input.agent, ConfigAgentV1.Info, ["agent"], diagnostics), (value) =>
|
||||
canonical(ConfigAgent.Info, ConfigMigrateV1.migrateAgent(value)),
|
||||
)
|
||||
const legacySmallModel = own(input, "small_model")
|
||||
? decodeValue(Schema.String, input.small_model, ["small_model"], diagnostics)
|
||||
: undefined
|
||||
const migratedSmallModel = legacySmallModel
|
||||
? ConfigMigrateV1.migrate({ small_model: legacySmallModel }).agents?.title?.model
|
||||
: undefined
|
||||
if (legacySmallModel && !migratedSmallModel)
|
||||
diagnostics.push({
|
||||
kind: "unsupported",
|
||||
path: ["small_model"],
|
||||
message: "omitted unsupported legacy model reference",
|
||||
})
|
||||
if (migratedSmallModel)
|
||||
legacyAgents.title = {
|
||||
model: migratedSmallModel,
|
||||
...legacyAgents.title,
|
||||
}
|
||||
const modeAgents = mapValues(decodeMap(input.mode, ConfigAgentV1.Info, ["mode"], diagnostics), (value) =>
|
||||
canonical(ConfigAgent.Info, ConfigMigrateV1.migrateAgent({ ...value, mode: "primary" })),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
||||
import type { FilesImpl } from "./files"
|
||||
|
||||
export interface Driver {
|
||||
readonly spawner: ChildProcessSpawner["Service"]
|
||||
readonly overrides?: Partial<FilesImpl>
|
||||
}
|
||||
|
||||
export * as EnvironmentDriver from "./driver"
|
||||
@@ -0,0 +1,185 @@
|
||||
import { Effect, Stream } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
||||
import { collectStream } from "@opencode-ai/util/process"
|
||||
import { Failed, NotFound, WrongKind, type FileInfo, type FileType, type FilesImpl } from "./files"
|
||||
|
||||
const MAX_DATA_BYTES = 64 * 1024 * 1024
|
||||
const MAX_ERROR_BYTES = 64 * 1024
|
||||
const NOT_FOUND = 44
|
||||
const WRONG_KIND = 45
|
||||
const FAILED = 46
|
||||
|
||||
const loadMetadata = (flags = "") => `
|
||||
metadata=$(stat ${flags} -c '%F\t%s\t%Y' -- "$1" 2>&1) || {
|
||||
case "$metadata" in
|
||||
*'No such file or directory'*|*'Not a directory'*) exit ${NOT_FOUND} ;;
|
||||
*) printf '%s' "$metadata" >&2; exit ${FAILED} ;;
|
||||
esac
|
||||
}
|
||||
`
|
||||
|
||||
const statScript = `
|
||||
${loadMetadata()}
|
||||
printf '%s\n' "$metadata"
|
||||
`
|
||||
|
||||
const readScript = `
|
||||
${loadMetadata("-L")}
|
||||
kind=\${metadata%% *}
|
||||
if [ "$kind" != 'regular file' ] && [ "$kind" != 'regular empty file' ]; then
|
||||
printf '%s' "$kind" >&2
|
||||
exit ${WRONG_KIND}
|
||||
fi
|
||||
printf '%s\n' "$metadata"
|
||||
if [ "$2" = range ]; then
|
||||
dd if="$1" iflag=skip_bytes,count_bytes skip="$3" count="$4" status=none
|
||||
else
|
||||
cat -- "$1"
|
||||
fi
|
||||
`
|
||||
|
||||
const listScript = `
|
||||
${loadMetadata()}
|
||||
kind=\${metadata%% *}
|
||||
if [ "$kind" != directory ]; then
|
||||
printf '%s' "$kind" >&2
|
||||
exit ${WRONG_KIND}
|
||||
fi
|
||||
find "$1" -mindepth 1 -maxdepth 1 -printf '%y\0%f\0'
|
||||
`
|
||||
|
||||
interface Result {
|
||||
readonly exitCode: number
|
||||
readonly stdout: Uint8Array
|
||||
readonly stderr: Uint8Array
|
||||
}
|
||||
|
||||
export const execDefaults = (spawner: ChildProcessSpawner["Service"]): FilesImpl => {
|
||||
const run = (
|
||||
path: string,
|
||||
script: string,
|
||||
args: ReadonlyArray<string> = [],
|
||||
stdin?: Uint8Array,
|
||||
): Effect.Effect<Result, Failed> =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const command = ChildProcess.make("sh", ["-c", script, "sh", path, ...args], {
|
||||
env: { LC_ALL: "C" },
|
||||
extendEnv: true,
|
||||
stdin: stdin === undefined ? undefined : Stream.make(stdin),
|
||||
})
|
||||
const handle = yield* spawner.spawn(command).pipe(Effect.mapError((cause) => new Failed({ path, cause })))
|
||||
const [stdout, stderr, exitCode] = yield* Effect.all(
|
||||
[
|
||||
collectStream(handle.stdout, MAX_DATA_BYTES),
|
||||
collectStream(handle.stderr, MAX_ERROR_BYTES),
|
||||
handle.exitCode,
|
||||
],
|
||||
{ concurrency: "unbounded" },
|
||||
).pipe(Effect.mapError((cause) => new Failed({ path, cause })))
|
||||
if (stdout.truncated || stderr.truncated) {
|
||||
return yield* new Failed({ path, cause: new Error("Process output exceeded its collection limit") })
|
||||
}
|
||||
return { exitCode, stdout: stdout.buffer, stderr: stderr.buffer }
|
||||
}),
|
||||
)
|
||||
|
||||
const classify = <A>(
|
||||
path: string,
|
||||
result: Result,
|
||||
success: (stdout: Uint8Array) => A,
|
||||
): Effect.Effect<A, NotFound | WrongKind | Failed> => {
|
||||
if (result.exitCode === 0) return Effect.sync(() => success(result.stdout))
|
||||
if (result.exitCode === NOT_FOUND) return Effect.fail(new NotFound({ path }))
|
||||
if (result.exitCode === WRONG_KIND) {
|
||||
return Effect.fail(new WrongKind({ path, actual: parseType(new TextDecoder().decode(result.stderr)) }))
|
||||
}
|
||||
return Effect.fail(processFailure(path, result))
|
||||
}
|
||||
|
||||
const stat: FilesImpl["stat"] = (path) =>
|
||||
run(path, statScript).pipe(Effect.flatMap((result) => classifyStat(path, result)))
|
||||
|
||||
const complete = (path: string, result: Result) =>
|
||||
result.exitCode === 0 ? Effect.void : Effect.fail(processFailure(path, result))
|
||||
|
||||
return {
|
||||
stat,
|
||||
read: (path, range) =>
|
||||
run(
|
||||
path,
|
||||
readScript,
|
||||
range === undefined ? ["whole"] : ["range", String(range.offset), String(range.length)],
|
||||
).pipe(
|
||||
Effect.flatMap((result) =>
|
||||
classify(path, result, (stdout) => {
|
||||
const newline = stdout.indexOf(10)
|
||||
if (newline < 0) throw new Error("Missing read metadata header")
|
||||
return {
|
||||
info: parseInfo(stdout.slice(0, newline)),
|
||||
bytes: stdout.slice(newline + 1),
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
write: (path, bytes) =>
|
||||
run(path, `mkdir -p "$(dirname "$1")" && cat > "$1"`, [], bytes).pipe(
|
||||
Effect.flatMap((result) => complete(path, result)),
|
||||
),
|
||||
list: (path) => run(path, listScript).pipe(Effect.flatMap((result) => classify(path, result, parseList))),
|
||||
remove: (path) => run(path, `rm -rf -- "$1"`).pipe(Effect.flatMap((result) => complete(path, result))),
|
||||
move: (from, to) =>
|
||||
run(
|
||||
from,
|
||||
`${loadMetadata()}
|
||||
mv -- "$1" "$2"`,
|
||||
[to],
|
||||
).pipe(Effect.flatMap((result) => classifyMove(from, result))),
|
||||
mkdir: (path) => run(path, `mkdir -p -- "$1"`).pipe(Effect.flatMap((result) => complete(path, result))),
|
||||
}
|
||||
}
|
||||
|
||||
const classifyStat = (path: string, result: Result): Effect.Effect<FileInfo, NotFound | Failed> => {
|
||||
if (result.exitCode === 0) return Effect.sync(() => parseInfo(result.stdout))
|
||||
if (result.exitCode === NOT_FOUND) return Effect.fail(new NotFound({ path }))
|
||||
return Effect.fail(processFailure(path, result))
|
||||
}
|
||||
|
||||
const classifyMove = (path: string, result: Result): Effect.Effect<void, NotFound | Failed> => {
|
||||
if (result.exitCode === 0) return Effect.void
|
||||
if (result.exitCode === NOT_FOUND) return Effect.fail(new NotFound({ path }))
|
||||
return Effect.fail(processFailure(path, result))
|
||||
}
|
||||
|
||||
const processFailure = (path: string, result: Result) =>
|
||||
new Failed({
|
||||
path,
|
||||
cause: new Error(new TextDecoder().decode(result.stderr).trim() || `Process exited with code ${result.exitCode}`),
|
||||
})
|
||||
|
||||
const parseInfo = (bytes: Uint8Array): FileInfo => {
|
||||
const [rawType, rawSize, rawMtime] = new TextDecoder().decode(bytes).trim().split("\t")
|
||||
const size = Number(rawSize)
|
||||
const mtimeMs = Number(rawMtime) * 1_000
|
||||
if (!rawType || !Number.isFinite(size) || !Number.isFinite(mtimeMs)) throw new Error("Invalid stat output")
|
||||
return { type: parseType(rawType), size, mtimeMs }
|
||||
}
|
||||
|
||||
const parseType = (value: string): FileType => {
|
||||
if (value === "regular file" || value === "regular empty file" || value === "f") return "file"
|
||||
if (value === "directory" || value === "d") return "directory"
|
||||
if (value === "symbolic link" || value === "l") return "symlink"
|
||||
return "other"
|
||||
}
|
||||
|
||||
const parseList = (bytes: Uint8Array) => {
|
||||
const fields = new TextDecoder().decode(bytes).split("\0")
|
||||
fields.pop()
|
||||
if (fields.length % 2 !== 0) throw new Error("Invalid find output")
|
||||
return fields
|
||||
.filter((_, index) => index % 2 === 0)
|
||||
.map((type, index) => ({ name: fields[index * 2 + 1], type: parseType(type) }))
|
||||
}
|
||||
|
||||
export * as EnvironmentExecDefaults from "./exec-defaults"
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
|
||||
export const FileType = Schema.Literals(["file", "directory", "symlink", "other"])
|
||||
export type FileType = typeof FileType.Type
|
||||
|
||||
export interface FileInfo {
|
||||
readonly type: FileType
|
||||
readonly size: number
|
||||
readonly mtimeMs: number
|
||||
}
|
||||
|
||||
export interface DirEntry {
|
||||
readonly name: string
|
||||
readonly type: FileType
|
||||
}
|
||||
|
||||
export class NotFound extends Schema.TaggedErrorClass<NotFound>()("Environment.NotFound", {
|
||||
path: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class WrongKind extends Schema.TaggedErrorClass<WrongKind>()("Environment.WrongKind", {
|
||||
path: Schema.String,
|
||||
actual: FileType,
|
||||
}) {}
|
||||
|
||||
export class Failed extends Schema.TaggedErrorClass<Failed>()("Environment.Failed", {
|
||||
path: Schema.String,
|
||||
cause: Schema.Defect(),
|
||||
}) {}
|
||||
|
||||
export interface FilesImpl {
|
||||
/**
|
||||
* Reads a file, following a final symlink so `info` describes the target whose bytes are returned.
|
||||
* The process-backed default caps collected output at 64 MiB; larger whole-file reads fail with
|
||||
* `Failed`, so callers must use ranges for larger files.
|
||||
*/
|
||||
readonly read: (
|
||||
path: string,
|
||||
range?: { readonly offset: number; readonly length: number },
|
||||
) => Effect.Effect<{ readonly info: FileInfo; readonly bytes: Uint8Array }, NotFound | WrongKind | Failed>
|
||||
readonly write: (path: string, bytes: Uint8Array) => Effect.Effect<void, Failed>
|
||||
/** Describes the path entry itself, so a final symlink is reported as `symlink` rather than followed. */
|
||||
readonly stat: (path: string) => Effect.Effect<FileInfo, NotFound | Failed>
|
||||
/** Lists a directory entry without following a final symlink; intermediate symlinks are traversed. */
|
||||
readonly list: (path: string) => Effect.Effect<ReadonlyArray<DirEntry>, NotFound | WrongKind | Failed>
|
||||
readonly remove: (path: string) => Effect.Effect<void, Failed>
|
||||
readonly move: (from: string, to: string) => Effect.Effect<void, NotFound | Failed>
|
||||
readonly mkdir: (path: string) => Effect.Effect<void, Failed>
|
||||
}
|
||||
|
||||
export interface Files extends FilesImpl {}
|
||||
|
||||
export * as EnvironmentFiles from "./files"
|
||||
@@ -0,0 +1,24 @@
|
||||
export * as Environment from "./index"
|
||||
|
||||
export { type Driver } from "./driver"
|
||||
export {
|
||||
type DirEntry,
|
||||
Failed,
|
||||
type FileInfo,
|
||||
type Files,
|
||||
type FilesImpl,
|
||||
type FileType,
|
||||
NotFound,
|
||||
WrongKind,
|
||||
} from "./files"
|
||||
export { execDefaults } from "./exec-defaults"
|
||||
export { makeMemoryDriver, type MemoryDriver } from "./memory"
|
||||
|
||||
import type { Driver } from "./driver"
|
||||
import { execDefaults } from "./exec-defaults"
|
||||
import type { Files } from "./files"
|
||||
|
||||
export const makeFiles = (driver: Driver): Files => ({
|
||||
...execDefaults(driver.spawner),
|
||||
...driver.overrides,
|
||||
})
|
||||
@@ -0,0 +1,168 @@
|
||||
import path from "node:path"
|
||||
import { Effect, PlatformError } from "effect"
|
||||
import { make } from "effect/unstable/process/ChildProcessSpawner"
|
||||
import type { Driver } from "./driver"
|
||||
import { Failed, NotFound, WrongKind, type FileInfo, type FilesImpl, type FileType } from "./files"
|
||||
|
||||
type Node =
|
||||
| { readonly type: "file"; readonly bytes: Uint8Array; readonly mtimeMs: number }
|
||||
| { readonly type: "directory"; readonly mtimeMs: number }
|
||||
| { readonly type: "symlink"; readonly target: string; readonly mtimeMs: number }
|
||||
|
||||
export interface MemoryDriver extends Driver {
|
||||
readonly symlink: (target: string, path: string) => Effect.Effect<void, Failed>
|
||||
}
|
||||
|
||||
export const makeMemoryDriver = (): MemoryDriver => {
|
||||
const nodes = new Map<string, Node>([["/", { type: "directory", mtimeMs: Date.now() }]])
|
||||
const key = (value: string) => path.posix.resolve("/", value)
|
||||
const info = (node: Node): FileInfo => ({
|
||||
type: node.type,
|
||||
size:
|
||||
node.type === "file"
|
||||
? node.bytes.length
|
||||
: node.type === "symlink"
|
||||
? new TextEncoder().encode(node.target).length
|
||||
: 0,
|
||||
mtimeMs: node.mtimeMs,
|
||||
})
|
||||
const resolveKey = (value: string, followFinal: boolean, seen = new Set<string>()): string | undefined => {
|
||||
const normalized = key(value)
|
||||
const parts = normalized.split("/").filter(Boolean)
|
||||
const base = "/"
|
||||
const walk = (current: string, index: number): string | undefined => {
|
||||
if (index === parts.length) return current
|
||||
const part = parts[index]
|
||||
const candidate = path.posix.join(current, part)
|
||||
const node = nodes.get(candidate)
|
||||
if (node?.type !== "symlink" || (!followFinal && index === parts.length - 1)) return walk(candidate, index + 1)
|
||||
if (seen.has(candidate)) return undefined
|
||||
seen.add(candidate)
|
||||
const target = path.posix.resolve(path.posix.dirname(candidate), node.target)
|
||||
return resolveKey(path.posix.join(target, ...parts.slice(index + 1)), followFinal, seen)
|
||||
}
|
||||
return walk(base, 0)
|
||||
}
|
||||
const lookup = (value: string) => nodes.get(resolveKey(value, false) ?? key(value))
|
||||
const requireParent = (value: string) => {
|
||||
const parentPath = path.posix.dirname(key(value))
|
||||
const parent = nodes.get(resolveKey(parentPath, true) ?? parentPath)
|
||||
if (!parent) throw new Error(`Parent directory does not exist: ${path.posix.dirname(value)}`)
|
||||
if (parent.type !== "directory") throw new Error(`Parent is not a directory: ${path.posix.dirname(value)}`)
|
||||
}
|
||||
const mkdirSync = (value: string) => {
|
||||
const target = resolveKey(value, false) ?? key(value)
|
||||
const existing = nodes.get(target)
|
||||
if (existing?.type === "directory") return
|
||||
if (existing) throw new Error(`Path is not a directory: ${value}`)
|
||||
const parent = path.posix.dirname(target)
|
||||
if (parent !== target) mkdirSync(parent)
|
||||
nodes.set(target, { type: "directory", mtimeMs: Date.now() })
|
||||
}
|
||||
const failed = (value: string, cause: unknown) => new Failed({ path: value, cause })
|
||||
const overrides: FilesImpl = {
|
||||
stat: (value) => {
|
||||
const node = lookup(value)
|
||||
return node ? Effect.succeed(info(node)) : Effect.fail(new NotFound({ path: value }))
|
||||
},
|
||||
read: (value, range) => {
|
||||
const original = lookup(value)
|
||||
if (!original) return Effect.fail(new NotFound({ path: value }))
|
||||
if (original.type === "directory") return Effect.fail(new WrongKind({ path: value, actual: "directory" }))
|
||||
const resolved = resolveKey(value, true)
|
||||
const node = resolved === undefined ? undefined : nodes.get(resolved)
|
||||
if (!node) return Effect.fail(new NotFound({ path: value }))
|
||||
if (node.type !== "file") return Effect.fail(new WrongKind({ path: value, actual: node.type }))
|
||||
const bytes = range === undefined ? node.bytes : node.bytes.subarray(range.offset, range.offset + range.length)
|
||||
return Effect.succeed({ info: info(node), bytes: bytes.slice() })
|
||||
},
|
||||
write: (value, bytes) =>
|
||||
Effect.try({
|
||||
try: () => {
|
||||
mkdirSync(path.posix.dirname(key(value)))
|
||||
const existing = lookup(value)
|
||||
if (existing?.type === "directory") throw new Error(`Path is a directory: ${value}`)
|
||||
const target = existing?.type === "symlink" ? resolveKey(value, true) : resolveKey(value, false)
|
||||
if (!target) throw new Error(`Cannot resolve symlink: ${value}`)
|
||||
requireParent(target)
|
||||
nodes.set(target, { type: "file", bytes: bytes.slice(), mtimeMs: Date.now() })
|
||||
},
|
||||
catch: (cause) => failed(value, cause),
|
||||
}),
|
||||
list: (value) => {
|
||||
const target = resolveKey(value, false) ?? key(value)
|
||||
const node = nodes.get(target)
|
||||
if (!node) return Effect.fail(new NotFound({ path: value }))
|
||||
if (node.type !== "directory") return Effect.fail(new WrongKind({ path: value, actual: node.type }))
|
||||
const entries = [...nodes.entries()]
|
||||
.filter(([entry]) => entry !== target && path.posix.dirname(entry) === target)
|
||||
.map(([entry, child]) => ({ name: path.posix.basename(entry), type: child.type satisfies FileType }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
return Effect.succeed(entries)
|
||||
},
|
||||
remove: (value) =>
|
||||
Effect.sync(() => {
|
||||
const target = resolveKey(value, false) ?? key(value)
|
||||
for (const entry of nodes.keys()) {
|
||||
if (entry === target || entry.startsWith(`${target}/`)) nodes.delete(entry)
|
||||
}
|
||||
}),
|
||||
move: (from, to) => {
|
||||
const source = resolveKey(from, false) ?? key(from)
|
||||
const node = nodes.get(source)
|
||||
if (!node) return Effect.fail(new NotFound({ path: from }))
|
||||
return Effect.try({
|
||||
try: () => {
|
||||
const requested = resolveKey(to, false) ?? key(to)
|
||||
const destination =
|
||||
nodes.get(requested)?.type === "directory"
|
||||
? path.posix.join(requested, path.posix.basename(source))
|
||||
: requested
|
||||
if (node.type === "directory" && destination.startsWith(`${source}/`)) {
|
||||
throw new Error(`Cannot move a directory into itself: ${from}`)
|
||||
}
|
||||
const existing = nodes.get(destination)
|
||||
if (node.type === "directory" && existing && existing.type !== "directory") {
|
||||
throw new Error(`Cannot overwrite a non-directory with a directory: ${to}`)
|
||||
}
|
||||
requireParent(destination)
|
||||
const moved = [...nodes.entries()].filter(([entry]) => entry === source || entry.startsWith(`${source}/`))
|
||||
for (const [entry] of moved) nodes.delete(entry)
|
||||
for (const [entry, child] of moved) nodes.set(`${destination}${entry.slice(source.length)}`, child)
|
||||
},
|
||||
catch: (cause) => failed(from, cause),
|
||||
})
|
||||
},
|
||||
mkdir: (value) => Effect.try({ try: () => mkdirSync(value), catch: (cause) => failed(value, cause) }),
|
||||
}
|
||||
|
||||
const spawner = make((command) =>
|
||||
Effect.suspend(() => {
|
||||
const description = command._tag === "StandardCommand" ? command.command : "pipeline"
|
||||
return Effect.fail(
|
||||
PlatformError.systemError({
|
||||
_tag: "Unknown",
|
||||
module: "EnvironmentMemory",
|
||||
method: "spawn",
|
||||
pathOrDescriptor: description,
|
||||
cause: failed(description, new Error("The memory driver cannot spawn processes")),
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
return {
|
||||
spawner,
|
||||
overrides,
|
||||
symlink: (target, value) =>
|
||||
Effect.try({
|
||||
try: () => {
|
||||
requireParent(value)
|
||||
nodes.set(resolveKey(value, false) ?? key(value), { type: "symlink", target, mtimeMs: Date.now() })
|
||||
},
|
||||
catch: (cause) => failed(value, cause),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
export * as EnvironmentMemory from "./memory"
|
||||
@@ -7,7 +7,7 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Bom } from "@opencode-ai/util/bom"
|
||||
|
||||
export interface Target {
|
||||
readonly absolute: string
|
||||
readonly canonical: string
|
||||
readonly resource: string
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ export interface Interface {
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/FileMutation") {}
|
||||
|
||||
/**
|
||||
* Serialize file changes by absolute target. Conditional writes compare and
|
||||
* Serialize file changes by canonical target. Conditional writes compare and
|
||||
* write under the same process-local lock so cooperating OpenCode mutations do
|
||||
* not overwrite changes made from the same stale content.
|
||||
*/
|
||||
@@ -49,11 +49,11 @@ const layer = Layer.effect(
|
||||
const withTargetLock =
|
||||
(target: Target) =>
|
||||
<A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
locks.withLock(target.absolute)(Effect.uninterruptible(effect))
|
||||
locks.withLock(target.canonical)(Effect.uninterruptible(effect))
|
||||
|
||||
const writeResult = (target: Target, existed: boolean): WriteResult => ({
|
||||
operation: "write",
|
||||
target: target.absolute,
|
||||
target: target.canonical,
|
||||
resource: target.resource,
|
||||
existed,
|
||||
})
|
||||
@@ -61,8 +61,8 @@ const layer = Layer.effect(
|
||||
const write = Effect.fn("FileMutation.write")((input: WriteInput) =>
|
||||
withTargetLock(input.target)(
|
||||
Effect.gen(function* () {
|
||||
const existed = yield* fs.exists(input.target.absolute)
|
||||
yield* fs.writeWithDirs(input.target.absolute, input.content)
|
||||
const existed = yield* fs.exists(input.target.canonical)
|
||||
yield* fs.writeWithDirs(input.target.canonical, input.content)
|
||||
return writeResult(input.target, existed)
|
||||
}),
|
||||
),
|
||||
@@ -73,10 +73,10 @@ const layer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
const next = Bom.split(input.content)
|
||||
const current = yield* fs
|
||||
.readFile(input.target.absolute)
|
||||
.readFile(input.target.canonical)
|
||||
.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
|
||||
yield* fs.writeWithDirs(
|
||||
input.target.absolute,
|
||||
input.target.canonical,
|
||||
Bom.join(next.text, Boolean(current && Bom.has(current)) || next.bom),
|
||||
)
|
||||
return writeResult(input.target, current !== undefined)
|
||||
|
||||
@@ -23,9 +23,14 @@ export const ResolveInput = Schema.Struct({
|
||||
})
|
||||
export type ResolveInput = typeof ResolveInput.Type
|
||||
|
||||
export class PathError extends Schema.TaggedErrorClass<PathError>()("LocationMutation.PathError", {
|
||||
path: Schema.String,
|
||||
reason: Schema.Literal("non_directory_ancestor"),
|
||||
}) {}
|
||||
|
||||
export interface ExternalDirectoryAuthorization {
|
||||
readonly action: "external_directory"
|
||||
/** Lexical directory used as the external approval boundary. */
|
||||
/** Canonical existing directory used as the external approval boundary. */
|
||||
readonly directory: string
|
||||
/** `external_directory` permission resource. */
|
||||
readonly resource: string
|
||||
@@ -39,9 +44,9 @@ export const externalDirectoryPermission = (input: ExternalDirectoryAuthorizatio
|
||||
})
|
||||
|
||||
export interface Target {
|
||||
/** Absolute lexical path. */
|
||||
readonly absolute: string
|
||||
/** Permission resource: Location-relative for internal paths, absolute for external paths. */
|
||||
/** Canonical existing path, or missing path below a canonical directory. */
|
||||
readonly canonical: string
|
||||
/** Permission resource: Location-relative for internal paths, canonical for external paths. */
|
||||
readonly resource: string
|
||||
readonly externalDirectory?: ExternalDirectoryAuthorization
|
||||
}
|
||||
@@ -52,11 +57,25 @@ export interface Interface {
|
||||
* from the Location. Paths outside it require separate `external_directory`
|
||||
* approval. This does not approve the mutation.
|
||||
*/
|
||||
readonly resolve: (input: ResolveInput) => Effect.Effect<Target, FSUtil.Error>
|
||||
readonly resolve: (input: ResolveInput) => Effect.Effect<Target, PathError | FSUtil.Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/LocationMutation") {}
|
||||
|
||||
interface ResolvedPath {
|
||||
readonly canonical: string
|
||||
readonly type?:
|
||||
| "File"
|
||||
| "Directory"
|
||||
| "SymbolicLink"
|
||||
| "BlockDevice"
|
||||
| "CharacterDevice"
|
||||
| "FIFO"
|
||||
| "Socket"
|
||||
| "Unknown"
|
||||
readonly directory: string
|
||||
}
|
||||
|
||||
const slash = (value: string) => value.replaceAll("\\", "/")
|
||||
|
||||
const layer = Layer.effect(
|
||||
@@ -65,33 +84,65 @@ const layer = Layer.effect(
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
|
||||
function notFound<A>(effect: Effect.Effect<A, FSUtil.Error>) {
|
||||
return effect.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
|
||||
}
|
||||
|
||||
const resolvePath = Effect.fnUntraced(function* (absolute: string) {
|
||||
const existing = yield* notFound(fs.realPath(absolute))
|
||||
if (existing !== undefined) {
|
||||
const info = yield* fs.stat(existing)
|
||||
return {
|
||||
canonical: existing,
|
||||
type: info.type,
|
||||
directory: info.type === "Directory" ? existing : path.dirname(existing),
|
||||
} satisfies ResolvedPath
|
||||
}
|
||||
|
||||
let anchor = path.dirname(absolute)
|
||||
while (true) {
|
||||
const canonical = yield* notFound(fs.realPath(anchor))
|
||||
if (canonical !== undefined) {
|
||||
const info = yield* fs.stat(canonical)
|
||||
if (info.type !== "Directory") {
|
||||
return yield* new PathError({ path: absolute, reason: "non_directory_ancestor" })
|
||||
}
|
||||
return {
|
||||
canonical: path.resolve(canonical, path.relative(anchor, absolute)),
|
||||
directory: canonical,
|
||||
} satisfies ResolvedPath
|
||||
}
|
||||
const parent = path.dirname(anchor)
|
||||
if (parent === anchor) return yield* new PathError({ path: absolute, reason: "non_directory_ancestor" })
|
||||
anchor = parent
|
||||
}
|
||||
})
|
||||
|
||||
const resolve = Effect.fn("LocationMutation.resolve")(function* (input: ResolveInput) {
|
||||
const absolute = path.resolve(location.directory, input.path)
|
||||
if (FSUtil.contains(location.directory, absolute)) {
|
||||
return {
|
||||
absolute,
|
||||
resource: slash(path.relative(location.directory, absolute) || "."),
|
||||
} satisfies Target
|
||||
}
|
||||
const type =
|
||||
input.kind === "directory"
|
||||
? "Directory"
|
||||
: (yield* fs
|
||||
.stat(absolute)
|
||||
.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined))))?.type
|
||||
const externalDirectory = type === "Directory" ? absolute : path.dirname(absolute)
|
||||
// External access follows the requested path boundary. Symlinks reached through an
|
||||
// internal path intentionally retain internal permission semantics after canonicalization.
|
||||
const lexicallyInternal = FSUtil.contains(location.directory, absolute)
|
||||
|
||||
const resolved = yield* resolvePath(absolute)
|
||||
const external = !lexicallyInternal
|
||||
const resource = external ? slash(resolved.canonical) : slash(path.relative(location.directory, absolute) || ".")
|
||||
const externalDirectory =
|
||||
input.kind === "directory" && resolved.type === "Directory" ? resolved.canonical : resolved.directory
|
||||
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, "*"),
|
||||
),
|
||||
},
|
||||
canonical: resolved.canonical,
|
||||
resource,
|
||||
externalDirectory: external
|
||||
? {
|
||||
action: "external_directory",
|
||||
directory: externalDirectory,
|
||||
resource: externalResource,
|
||||
save: slash(
|
||||
path.join((yield* Project.root(fs, AbsolutePath.make(externalDirectory))) ?? externalDirectory, "*"),
|
||||
),
|
||||
}
|
||||
: undefined,
|
||||
} satisfies Target
|
||||
})
|
||||
|
||||
|
||||
@@ -116,120 +116,124 @@ export const Plugin = {
|
||||
|
||||
yield* ctx.tool
|
||||
.transform((draft) =>
|
||||
draft.add({
|
||||
name,
|
||||
options: { codemode: false, permission: "edit" },
|
||||
description:
|
||||
"Edit the contents of a file by finding and replacing exact text. When editing text from Read output, preserve the exact indentation (tabs or spaces) and omit the line-number prefix, such as `1: `. Never include the prefix in oldString or newString. The edit fails if oldString is not found. By default, oldString must identify a UNIQUE location. Multiple matches FAIL unless replaceAll is true. Add more surrounding context to disambiguate, or set replaceAll to true to replace every occurrence. Use replaceAll when the change should apply to every occurrence, such as renaming a variable.",
|
||||
input: Input,
|
||||
output: Output,
|
||||
execute: (input, context) => {
|
||||
return Effect.gen(function* () {
|
||||
const permissionSource = {
|
||||
type: "tool" as const,
|
||||
messageID: context.messageID,
|
||||
id: context.id,
|
||||
}
|
||||
if (input.oldString === input.newString) {
|
||||
return yield* new ToolFailure({
|
||||
message: "No changes to apply: oldString and newString are identical.",
|
||||
})
|
||||
}
|
||||
if (input.oldString === "") {
|
||||
return yield* new ToolFailure({
|
||||
message: "oldString must not be empty. Use write to create or overwrite a file.",
|
||||
})
|
||||
}
|
||||
draft.add(
|
||||
({
|
||||
name,
|
||||
options: { codemode: false, permission: "edit" },
|
||||
description:
|
||||
"Edit the contents of a file by finding and replacing exact text. When editing text from Read output, preserve the exact indentation (tabs or spaces) and omit the line-number prefix, such as `1: `. Never include the prefix in oldString or newString. The edit fails if oldString is not found. By default, oldString must identify a UNIQUE location. Multiple matches FAIL unless replaceAll is true. Add more surrounding context to disambiguate, or set replaceAll to true to replace every occurrence. Use replaceAll when the change should apply to every occurrence, such as renaming a variable.",
|
||||
input: Input,
|
||||
output: Output,
|
||||
execute: (input, context) => {
|
||||
return Effect.gen(function* () {
|
||||
const permissionSource = {
|
||||
type: "tool" as const,
|
||||
messageID: context.messageID,
|
||||
id: context.id,
|
||||
}
|
||||
if (input.oldString === input.newString) {
|
||||
return yield* new ToolFailure({
|
||||
message: "No changes to apply: oldString and newString are identical.",
|
||||
})
|
||||
}
|
||||
if (input.oldString === "") {
|
||||
return yield* new ToolFailure({
|
||||
message: "oldString must not be empty. Use write to create or overwrite a file.",
|
||||
})
|
||||
}
|
||||
|
||||
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* 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 info = yield* fs
|
||||
.stat(target.absolute)
|
||||
.pipe(
|
||||
Effect.catchReason("PlatformError", "NotFound", () =>
|
||||
Effect.fail(new ToolFailure({ message: `File not found: ${input.path}` })),
|
||||
const info = yield* fs.stat(target.canonical).pipe(
|
||||
Effect.catchReason("PlatformError", "NotFound", () =>
|
||||
Effect.fail(new ToolFailure({ message: `File not found: ${input.path}` })),
|
||||
),
|
||||
)
|
||||
if (info.type === "Directory") {
|
||||
return yield* new ToolFailure({ message: `Path is a directory, not a file: ${input.path}` })
|
||||
}
|
||||
const original = yield* Bom.readFile(fs, target.canonical)
|
||||
const source = original.text
|
||||
const ending = source.includes(crlf) ? crlf : "\n"
|
||||
const oldString = input.oldString.replaceAll(crlf, "\n").replaceAll("\n", ending)
|
||||
const newString = input.newString.replaceAll(crlf, "\n").replaceAll("\n", ending)
|
||||
const exact = findOccurrences(source, oldString)
|
||||
// These one-to-one mappings preserve offsets into the original source.
|
||||
const unicode =
|
||||
exact.length > 0 ? [] : findOccurrences(normalizeForMatch(source), normalizeForMatch(oldString))
|
||||
const trailing =
|
||||
exact.length > 0 || unicode.length > 0
|
||||
? []
|
||||
: findLineOccurrences(source, oldString)
|
||||
const matches = exact.length > 0 ? exact : unicode.length > 0 ? unicode : trailing
|
||||
const replacements = matches.length
|
||||
const replaced = (input.replaceAll === true ? matches : matches.slice(0, 1))
|
||||
.toReversed()
|
||||
.reduce(
|
||||
(content, match) =>
|
||||
`${content.slice(0, match.start)}${newString}${content.slice(match.end)}`,
|
||||
source,
|
||||
)
|
||||
const preview =
|
||||
replacements > 0 && (replacements === 1 || input.replaceAll === true)
|
||||
? fileDiff(target.resource, source, replaced)
|
||||
: undefined
|
||||
yield* permission.assert({
|
||||
action: "edit",
|
||||
resources: [target.resource],
|
||||
save: ["*"],
|
||||
metadata: preview ? { files: [preview] } : undefined,
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source: permissionSource,
|
||||
})
|
||||
if (replacements === 0) {
|
||||
return yield* new ToolFailure({
|
||||
message: `Could not find oldString in ${input.path}. It must match exactly, including whitespace and indentation.`,
|
||||
})
|
||||
}
|
||||
if (replacements > 1 && input.replaceAll !== true) {
|
||||
return yield* new ToolFailure({
|
||||
message: `Found ${replacements} matches for oldString, but expected exactly one. Add more surrounding context to make oldString unique, or set replaceAll to true to replace every occurrence.`,
|
||||
})
|
||||
}
|
||||
const replacementBom = replaced.startsWith("\uFEFF")
|
||||
const result = yield* files.write({
|
||||
target,
|
||||
content: Bom.join(replaced, original.bom || replacementBom),
|
||||
})
|
||||
const bom = original.bom || replacementBom
|
||||
const formatted = (yield* formatter.file(target.canonical))
|
||||
? yield* Bom.syncFile(fs, target.canonical, bom)
|
||||
: (yield* Bom.readFile(fs, target.canonical)).text
|
||||
return {
|
||||
files: [fileDiff(result.resource, source, formatted)],
|
||||
replacements,
|
||||
} satisfies Output
|
||||
}).pipe(
|
||||
Effect.map((output) => ({
|
||||
output,
|
||||
content: `Edited ${output.files[0]?.file} (${output.replacements} replacement${output.replacements === 1 ? "" : "s"})`,
|
||||
metadata: { files: output.files },
|
||||
})),
|
||||
Effect.mapError((error) =>
|
||||
error instanceof ToolFailure
|
||||
? error
|
||||
: new ToolFailure({ message: `Unable to edit ${input.path}`, error }),
|
||||
),
|
||||
)
|
||||
if (info.type === "Directory") {
|
||||
return yield* new ToolFailure({ message: `Path is a directory, not a file: ${input.path}` })
|
||||
}
|
||||
const original = yield* Bom.readFile(fs, target.absolute)
|
||||
const source = original.text
|
||||
const ending = source.includes(crlf) ? crlf : "\n"
|
||||
const oldString = input.oldString.replaceAll(crlf, "\n").replaceAll("\n", ending)
|
||||
const newString = input.newString.replaceAll(crlf, "\n").replaceAll("\n", ending)
|
||||
const exact = findOccurrences(source, oldString)
|
||||
// These one-to-one mappings preserve offsets into the original source.
|
||||
const unicode =
|
||||
exact.length > 0 ? [] : findOccurrences(normalizeForMatch(source), normalizeForMatch(oldString))
|
||||
const trailing = exact.length > 0 || unicode.length > 0 ? [] : findLineOccurrences(source, oldString)
|
||||
const matches = exact.length > 0 ? exact : unicode.length > 0 ? unicode : trailing
|
||||
const replacements = matches.length
|
||||
const replaced = (input.replaceAll === true ? matches : matches.slice(0, 1))
|
||||
.toReversed()
|
||||
.reduce(
|
||||
(content, match) => `${content.slice(0, match.start)}${newString}${content.slice(match.end)}`,
|
||||
source,
|
||||
)
|
||||
const preview =
|
||||
replacements > 0 && (replacements === 1 || input.replaceAll === true)
|
||||
? fileDiff(target.resource, source, replaced)
|
||||
: undefined
|
||||
yield* permission.assert({
|
||||
action: "edit",
|
||||
resources: [target.resource],
|
||||
save: ["*"],
|
||||
metadata: preview ? { files: [preview] } : undefined,
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source: permissionSource,
|
||||
})
|
||||
if (replacements === 0) {
|
||||
return yield* new ToolFailure({
|
||||
message: `Could not find oldString in ${input.path}. It must match exactly, including whitespace and indentation.`,
|
||||
})
|
||||
}
|
||||
if (replacements > 1 && input.replaceAll !== true) {
|
||||
return yield* new ToolFailure({
|
||||
message: `Found ${replacements} matches for oldString, but expected exactly one. Add more surrounding context to make oldString unique, or set replaceAll to true to replace every occurrence.`,
|
||||
})
|
||||
}
|
||||
const replacementBom = replaced.startsWith("\uFEFF")
|
||||
const result = yield* files.write({
|
||||
target,
|
||||
content: Bom.join(replaced, original.bom || replacementBom),
|
||||
})
|
||||
const bom = original.bom || replacementBom
|
||||
const formatted = (yield* formatter.file(target.absolute))
|
||||
? yield* Bom.syncFile(fs, target.absolute, bom)
|
||||
: (yield* Bom.readFile(fs, target.absolute)).text
|
||||
return {
|
||||
files: [fileDiff(result.resource, source, formatted)],
|
||||
replacements,
|
||||
} satisfies Output
|
||||
}).pipe(
|
||||
Effect.map((output) => ({
|
||||
output,
|
||||
content: `Edited ${output.files[0]?.file} (${output.replacements} replacement${output.replacements === 1 ? "" : "s"})`,
|
||||
metadata: { files: output.files },
|
||||
})),
|
||||
Effect.mapError((error) =>
|
||||
error instanceof ToolFailure
|
||||
? error
|
||||
: new ToolFailure({ message: `Unable to edit ${input.path}`, error }),
|
||||
),
|
||||
)
|
||||
},
|
||||
}),
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
|
||||
@@ -50,93 +50,96 @@ export const Plugin = {
|
||||
|
||||
yield* ctx.tool
|
||||
.transform((draft) =>
|
||||
draft.add({
|
||||
name,
|
||||
options: { codemode: false },
|
||||
description: 'Search file paths using a glob pattern (examples: "**/*.ts", "src/**/*.tsx").',
|
||||
input: Input,
|
||||
output: Output,
|
||||
execute: (input, context) =>
|
||||
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)
|
||||
draft.add(
|
||||
({
|
||||
name,
|
||||
options: { codemode: false },
|
||||
description:
|
||||
'Search file paths using a glob pattern (examples: "**/*.ts", "src/**/*.tsx").',
|
||||
input: Input,
|
||||
output: Output,
|
||||
execute: (input, context) =>
|
||||
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,
|
||||
})
|
||||
yield* permission.assert({
|
||||
...LocationMutation.externalDirectoryPermission(external),
|
||||
action: name,
|
||||
resources: [input.pattern],
|
||||
save: ["*"],
|
||||
metadata: {
|
||||
root: searchPath ?? ".",
|
||||
path: searchPath,
|
||||
limit: input.limit,
|
||||
},
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: [input.pattern],
|
||||
save: ["*"],
|
||||
metadata: {
|
||||
root: searchPath ?? ".",
|
||||
path: searchPath,
|
||||
limit: input.limit,
|
||||
},
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
const info = yield* fs
|
||||
.stat(target.absolute)
|
||||
.pipe(
|
||||
Effect.catchReason("PlatformError", "NotFound", () =>
|
||||
Effect.fail(new ToolFailure({ message: `Search path does not exist: ${searchPath ?? "."}` })),
|
||||
),
|
||||
)
|
||||
if (info.type !== "Directory")
|
||||
return yield* Effect.fail(
|
||||
new ToolFailure({ message: `Search path is not a directory: ${searchPath ?? "."}` }),
|
||||
)
|
||||
const root = path.resolve(location.directory, searchPath ?? ".")
|
||||
const limit = input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT
|
||||
const entries = yield* ripgrep
|
||||
.glob({
|
||||
cwd: target.absolute,
|
||||
pattern: input.pattern,
|
||||
limit: limit + 1,
|
||||
})
|
||||
.pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: FileSystem.DEFAULT_SEARCH_TIMEOUT_MS,
|
||||
orElse: () =>
|
||||
Effect.fail(
|
||||
new ToolFailure({
|
||||
message: `Search timed out after ${FileSystem.DEFAULT_SEARCH_TIMEOUT_MS / 1_000} seconds. Consider using a more specific path or pattern.`,
|
||||
const info = yield* fs
|
||||
.stat(target.canonical)
|
||||
.pipe(
|
||||
Effect.catchReason("PlatformError", "NotFound", () =>
|
||||
Effect.fail(new ToolFailure({ message: `Search path does not exist: ${searchPath ?? "."}` })),
|
||||
),
|
||||
)
|
||||
if (info.type !== "Directory")
|
||||
return yield* Effect.fail(
|
||||
new ToolFailure({ message: `Search path is not a directory: ${searchPath ?? "."}` }),
|
||||
)
|
||||
const root = path.resolve(location.directory, searchPath ?? ".")
|
||||
const limit = input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT
|
||||
const entries = yield* ripgrep
|
||||
.glob({
|
||||
cwd: target.canonical,
|
||||
pattern: input.pattern,
|
||||
limit: limit + 1,
|
||||
})
|
||||
.pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: FileSystem.DEFAULT_SEARCH_TIMEOUT_MS,
|
||||
orElse: () =>
|
||||
Effect.fail(
|
||||
new ToolFailure({
|
||||
message: `Search timed out after ${FileSystem.DEFAULT_SEARCH_TIMEOUT_MS / 1_000} seconds. Consider using a more specific path or pattern.`,
|
||||
}),
|
||||
),
|
||||
}),
|
||||
Effect.map((result) =>
|
||||
result.map((entry) =>
|
||||
FileSystem.Entry.make({
|
||||
...entry,
|
||||
path: RelativePath.make(path.relative(location.directory, path.resolve(root, entry.path))),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
Effect.map((result) =>
|
||||
result.map((entry) =>
|
||||
FileSystem.Entry.make({
|
||||
...entry,
|
||||
path: RelativePath.make(path.relative(location.directory, path.resolve(root, entry.path))),
|
||||
}),
|
||||
),
|
||||
)
|
||||
return { entries: entries.slice(0, limit), truncated: entries.length > limit }
|
||||
}).pipe(
|
||||
Effect.map((result) => ({
|
||||
output: result.entries,
|
||||
content: toModelContent(
|
||||
result.entries.map((entry) => ({ ...entry, path: path.resolve(location.directory, entry.path) })),
|
||||
result.truncated,
|
||||
),
|
||||
)
|
||||
return { entries: entries.slice(0, limit), truncated: entries.length > limit }
|
||||
}).pipe(
|
||||
Effect.map((result) => ({
|
||||
output: result.entries,
|
||||
content: toModelContent(
|
||||
result.entries.map((entry) => ({ ...entry, path: path.resolve(location.directory, entry.path) })),
|
||||
result.truncated,
|
||||
metadata: { count: result.entries.length, truncated: result.truncated },
|
||||
})),
|
||||
Effect.mapError((error) =>
|
||||
error instanceof ToolFailure
|
||||
? error
|
||||
: new ToolFailure({ message: `Unable to find files matching ${input.pattern}`, error }),
|
||||
),
|
||||
metadata: { count: result.entries.length, truncated: result.truncated },
|
||||
})),
|
||||
Effect.mapError((error) =>
|
||||
error instanceof ToolFailure
|
||||
? error
|
||||
: new ToolFailure({ message: `Unable to find files matching ${input.pattern}`, error }),
|
||||
),
|
||||
),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
|
||||
@@ -58,7 +58,7 @@ type Prepared =
|
||||
})
|
||||
|
||||
interface Target {
|
||||
readonly absolute: string
|
||||
readonly canonical: string
|
||||
readonly resource: string
|
||||
readonly externalDirectory?: {
|
||||
readonly directory: string
|
||||
@@ -76,256 +76,267 @@ export const Plugin = {
|
||||
|
||||
yield* ctx.tool
|
||||
.transform((draft) =>
|
||||
draft.add({
|
||||
name,
|
||||
options: { codemode: false, permission: "edit" },
|
||||
description: DESCRIPTION,
|
||||
input: Input,
|
||||
output: Output,
|
||||
execute: (input, context) => {
|
||||
const applied: Array<typeof Applied.Type> = []
|
||||
const fail = (operation: string, error: unknown) => {
|
||||
const completed = applied.map((item) => item.resource).join(", ")
|
||||
return new ToolFailure({
|
||||
message: `${operation}: ${errorMessage(error)}${completed ? `. Completed before failure: ${completed}` : ""}`,
|
||||
})
|
||||
}
|
||||
return Effect.gen(function* () {
|
||||
const source = {
|
||||
type: "tool" as const,
|
||||
messageID: context.messageID,
|
||||
id: context.id,
|
||||
}
|
||||
if (!input.patchText) return yield* new ToolFailure({ message: "patchText is required" })
|
||||
const hunks = yield* Effect.fromResult(Patch.parse(input.patchText)).pipe(
|
||||
Effect.mapError((error) => new ToolFailure({ message: `patch verification failed: ${error.message}` })),
|
||||
)
|
||||
if (hunks.length === 0) {
|
||||
return yield* new ToolFailure({ message: "patch rejected: empty patch" })
|
||||
}
|
||||
const prepared: Prepared[] = []
|
||||
const targets: Target[] = []
|
||||
const updates = new Map<string, string>()
|
||||
for (const hunk of hunks) {
|
||||
yield* Effect.gen(function* () {
|
||||
const target = resolveTarget(location, hunk.path)
|
||||
targets.push(target)
|
||||
if (target.externalDirectory) {
|
||||
yield* permission.assert({
|
||||
action: "external_directory",
|
||||
resources: [target.externalDirectory.resource],
|
||||
save: [target.externalDirectory.resource],
|
||||
metadata: {
|
||||
filepath: target.absolute,
|
||||
parentDir: target.externalDirectory.directory,
|
||||
},
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
draft.add(
|
||||
({
|
||||
name,
|
||||
options: { codemode: false, permission: "edit" },
|
||||
description: DESCRIPTION,
|
||||
input: Input,
|
||||
output: Output,
|
||||
execute: (input, context) => {
|
||||
const applied: Array<typeof Applied.Type> = []
|
||||
const fail = (operation: string, error: unknown) => {
|
||||
const completed = applied.map((item) => item.resource).join(", ")
|
||||
return new ToolFailure({
|
||||
message: `${operation}: ${errorMessage(error)}${completed ? `. Completed before failure: ${completed}` : ""}`,
|
||||
})
|
||||
}
|
||||
return Effect.gen(function* () {
|
||||
const source = {
|
||||
type: "tool" as const,
|
||||
messageID: context.messageID,
|
||||
id: context.id,
|
||||
}
|
||||
if (hunk.type === "add") {
|
||||
prepared.push({
|
||||
...hunk,
|
||||
target,
|
||||
before: "",
|
||||
after: Bom.split(
|
||||
hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`,
|
||||
).text,
|
||||
})
|
||||
return
|
||||
if (!input.patchText) return yield* new ToolFailure({ message: "patchText is required" })
|
||||
const hunks = yield* Effect.fromResult(Patch.parse(input.patchText)).pipe(
|
||||
Effect.mapError(
|
||||
(error) => new ToolFailure({ message: `patch verification failed: ${error.message}` }),
|
||||
),
|
||||
)
|
||||
if (hunks.length === 0) {
|
||||
return yield* new ToolFailure({ message: "patch rejected: empty patch" })
|
||||
}
|
||||
if (hunk.type === "delete") {
|
||||
const content = yield* Bom.readFile(fs, target.absolute).pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new ToolFailure({
|
||||
message: `patch verification failed: Failed to delete ${target.resource}: ${errorMessage(error)}`,
|
||||
}),
|
||||
),
|
||||
)
|
||||
prepared.push({ ...hunk, target, before: content.text, after: "" })
|
||||
return
|
||||
}
|
||||
const previous = updates.get(target.absolute)
|
||||
const original =
|
||||
previous ??
|
||||
(yield* Effect.gen(function* () {
|
||||
const stats = yield* fs.stat(target.absolute).pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new ToolFailure({
|
||||
message: `patch verification failed: Failed to read file to update ${target.absolute}: ${errorMessage(error)}`,
|
||||
}),
|
||||
),
|
||||
)
|
||||
if (stats.type === "Directory") {
|
||||
return yield* new ToolFailure({
|
||||
message: `patch verification failed: Failed to read file to update ${target.absolute}: path is a directory`,
|
||||
const prepared: Prepared[] = []
|
||||
const targets: Target[] = []
|
||||
const updates = new Map<string, string>()
|
||||
for (const hunk of hunks) {
|
||||
yield* Effect.gen(function* () {
|
||||
const target = resolveTarget(location, hunk.path)
|
||||
targets.push(target)
|
||||
if (target.externalDirectory) {
|
||||
yield* permission.assert({
|
||||
action: "external_directory",
|
||||
resources: [target.externalDirectory.resource],
|
||||
save: [target.externalDirectory.resource],
|
||||
metadata: {
|
||||
filepath: target.canonical,
|
||||
parentDir: target.externalDirectory.directory,
|
||||
},
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
}
|
||||
const content = yield* Bom.readFile(fs, target.absolute).pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new ToolFailure({
|
||||
message: `patch verification failed: Failed to read file to update ${target.absolute}: ${errorMessage(error)}`,
|
||||
}),
|
||||
),
|
||||
)
|
||||
return Bom.join(content.text, content.bom)
|
||||
}))
|
||||
const before = Bom.split(original).text
|
||||
const update = yield* Effect.try({
|
||||
try: () => Patch.derive(hunk.path, hunk.chunks, original),
|
||||
catch: (error) => new ToolFailure({ message: `patch verification failed: ${errorMessage(error)}` }),
|
||||
})
|
||||
const moveTarget = hunk.movePath ? resolveTarget(location, hunk.movePath) : undefined
|
||||
if (moveTarget) targets.push(moveTarget)
|
||||
if (moveTarget?.externalDirectory) {
|
||||
yield* permission.assert({
|
||||
action: "external_directory",
|
||||
resources: [moveTarget.externalDirectory.resource],
|
||||
save: [moveTarget.externalDirectory.resource],
|
||||
metadata: {
|
||||
filepath: moveTarget.absolute,
|
||||
parentDir: moveTarget.externalDirectory.directory,
|
||||
},
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
if (hunk.type === "add") {
|
||||
prepared.push({
|
||||
...hunk,
|
||||
target,
|
||||
before: "",
|
||||
after: Bom.split(
|
||||
hunk.contents.endsWith("\n") || hunk.contents === ""
|
||||
? hunk.contents
|
||||
: `${hunk.contents}\n`,
|
||||
).text,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (hunk.type === "delete") {
|
||||
const content = yield* Bom.readFile(fs, target.canonical).pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new ToolFailure({
|
||||
message: `patch verification failed: Failed to delete ${target.resource}: ${errorMessage(error)}`,
|
||||
}),
|
||||
),
|
||||
)
|
||||
prepared.push({ ...hunk, target, before: content.text, after: "" })
|
||||
return
|
||||
}
|
||||
const previous = updates.get(target.canonical)
|
||||
const original =
|
||||
previous ??
|
||||
(yield* Effect.gen(function* () {
|
||||
const stats = yield* fs.stat(target.canonical).pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new ToolFailure({
|
||||
message: `patch verification failed: Failed to read file to update ${target.canonical}: ${errorMessage(error)}`,
|
||||
}),
|
||||
),
|
||||
)
|
||||
if (stats.type === "Directory") {
|
||||
return yield* new ToolFailure({
|
||||
message: `patch verification failed: Failed to read file to update ${target.canonical}: path is a directory`,
|
||||
})
|
||||
}
|
||||
const content = yield* Bom.readFile(fs, target.canonical).pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new ToolFailure({
|
||||
message: `patch verification failed: Failed to read file to update ${target.canonical}: ${errorMessage(error)}`,
|
||||
}),
|
||||
),
|
||||
)
|
||||
return Bom.join(content.text, content.bom)
|
||||
}))
|
||||
const before = Bom.split(original).text
|
||||
const update = yield* Effect.try({
|
||||
try: () => Patch.derive(hunk.path, hunk.chunks, original),
|
||||
catch: (error) =>
|
||||
new ToolFailure({ message: `patch verification failed: ${errorMessage(error)}` }),
|
||||
})
|
||||
const moveTarget = hunk.movePath ? resolveTarget(location, hunk.movePath) : undefined
|
||||
if (moveTarget) targets.push(moveTarget)
|
||||
if (moveTarget?.externalDirectory) {
|
||||
yield* permission.assert({
|
||||
action: "external_directory",
|
||||
resources: [moveTarget.externalDirectory.resource],
|
||||
save: [moveTarget.externalDirectory.resource],
|
||||
metadata: {
|
||||
filepath: moveTarget.canonical,
|
||||
parentDir: moveTarget.externalDirectory.directory,
|
||||
},
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
}
|
||||
prepared.push({
|
||||
...hunk,
|
||||
target,
|
||||
content: Patch.joinBom(update.content, update.bom),
|
||||
before,
|
||||
after: update.content,
|
||||
moveTarget,
|
||||
})
|
||||
if (!moveTarget) updates.set(target.canonical, Patch.joinBom(update.content, update.bom))
|
||||
}).pipe(
|
||||
Effect.mapError((error) =>
|
||||
error instanceof ToolFailure
|
||||
? error
|
||||
: new ToolFailure({ message: `Unable to prepare patch at ${hunk.path}`, error }),
|
||||
),
|
||||
)
|
||||
}
|
||||
prepared.push({
|
||||
...hunk,
|
||||
target,
|
||||
content: Patch.joinBom(update.content, update.bom),
|
||||
before,
|
||||
after: update.content,
|
||||
moveTarget,
|
||||
|
||||
const patchFiles = prepared.map((change) => patchFile(change))
|
||||
yield* permission.assert({
|
||||
action: "edit",
|
||||
resources: [...new Set(targets.map((target) => target.resource))],
|
||||
save: ["*"],
|
||||
metadata: {
|
||||
filepath: targets.map((target) => target.resource).join(", "),
|
||||
diff: patchFiles.map((file) => `${file.patch}\n`).join(""),
|
||||
files: patchFiles,
|
||||
},
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
if (!moveTarget) updates.set(target.absolute, Patch.joinBom(update.content, update.bom))
|
||||
|
||||
yield* Effect.forEach(
|
||||
prepared,
|
||||
(change) =>
|
||||
Effect.gen(function* () {
|
||||
if (change.type === "add") {
|
||||
yield* fs
|
||||
.writeWithDirs(
|
||||
change.target.canonical,
|
||||
change.contents.endsWith("\n") || change.contents === ""
|
||||
? change.contents
|
||||
: `${change.contents}\n`,
|
||||
)
|
||||
.pipe(
|
||||
Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error)),
|
||||
)
|
||||
applied.push({
|
||||
type: change.type,
|
||||
resource: change.target.resource,
|
||||
target: change.target.canonical,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (change.type === "delete") {
|
||||
yield* fs
|
||||
.remove(change.target.canonical)
|
||||
.pipe(
|
||||
Effect.mapError((error) => fail(`Failed to delete ${change.target.resource}`, error)),
|
||||
)
|
||||
applied.push({
|
||||
type: change.type,
|
||||
resource: change.target.resource,
|
||||
target: change.target.canonical,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (change.moveTarget) {
|
||||
const moveTarget = change.moveTarget
|
||||
yield* fs
|
||||
.writeWithDirs(moveTarget.canonical, change.content)
|
||||
.pipe(Effect.mapError((error) => fail(`Failed to write ${moveTarget.resource}`, error)))
|
||||
yield* fs.remove(change.target.canonical).pipe(
|
||||
Effect.mapError((error) =>
|
||||
fail(`Wrote ${moveTarget.resource} but failed to remove ${change.target.resource}`, error),
|
||||
),
|
||||
)
|
||||
applied.push({
|
||||
type: change.type,
|
||||
resource: change.moveTarget.resource,
|
||||
target: change.moveTarget.canonical,
|
||||
})
|
||||
return
|
||||
}
|
||||
yield* fs
|
||||
.writeWithDirs(change.target.canonical, change.content)
|
||||
.pipe(Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error)))
|
||||
applied.push({
|
||||
type: change.type,
|
||||
resource: change.target.resource,
|
||||
target: change.target.canonical,
|
||||
})
|
||||
}),
|
||||
{ discard: true },
|
||||
)
|
||||
const formatted = new Map<string, string>()
|
||||
yield* Effect.forEach(
|
||||
[...new Set(applied.filter((item) => item.type !== "delete").map((item) => item.target))],
|
||||
(target) =>
|
||||
Effect.gen(function* () {
|
||||
const current = yield* Bom.readFile(fs, target).pipe(
|
||||
Effect.mapError((error) => fail(`Failed to read ${target}`, error)),
|
||||
)
|
||||
formatted.set(
|
||||
target,
|
||||
(yield* formatter.file(target))
|
||||
? yield* Bom.syncFile(fs, target, current.bom).pipe(
|
||||
Effect.mapError((error) => fail(`Failed to sync ${target}`, error)),
|
||||
)
|
||||
: current.text,
|
||||
)
|
||||
}),
|
||||
{ discard: true },
|
||||
)
|
||||
const files = yield* Effect.forEach(prepared, (change) => {
|
||||
if (change.type === "delete") return Effect.succeed(patchFile(change))
|
||||
const target = change.type === "update" && change.moveTarget ? change.moveTarget : change.target
|
||||
return Effect.succeed(patchFile(change, formatted.get(target.canonical)))
|
||||
})
|
||||
return { applied, files }
|
||||
}).pipe(
|
||||
Effect.map((output) => ({
|
||||
output,
|
||||
content: toModelOutput(output),
|
||||
metadata: { files: output.files },
|
||||
})),
|
||||
Effect.mapError((error) =>
|
||||
error instanceof ToolFailure
|
||||
? error
|
||||
: new ToolFailure({ message: `Unable to prepare patch at ${hunk.path}`, error }),
|
||||
: new ToolFailure({ message: "Unable to apply patch", error }),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const patchFiles = prepared.map((change) => patchFile(change))
|
||||
yield* permission.assert({
|
||||
action: "edit",
|
||||
resources: [...new Set(targets.map((target) => target.resource))],
|
||||
save: ["*"],
|
||||
metadata: {
|
||||
filepath: targets.map((target) => target.resource).join(", "),
|
||||
diff: patchFiles.map((file) => `${file.patch}\n`).join(""),
|
||||
files: patchFiles,
|
||||
},
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
|
||||
yield* Effect.forEach(
|
||||
prepared,
|
||||
(change) =>
|
||||
Effect.gen(function* () {
|
||||
if (change.type === "add") {
|
||||
yield* fs
|
||||
.writeWithDirs(
|
||||
change.target.absolute,
|
||||
change.contents.endsWith("\n") || change.contents === ""
|
||||
? change.contents
|
||||
: `${change.contents}\n`,
|
||||
)
|
||||
.pipe(Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error)))
|
||||
applied.push({
|
||||
type: change.type,
|
||||
resource: change.target.resource,
|
||||
target: change.target.absolute,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (change.type === "delete") {
|
||||
yield* fs
|
||||
.remove(change.target.absolute)
|
||||
.pipe(Effect.mapError((error) => fail(`Failed to delete ${change.target.resource}`, error)))
|
||||
applied.push({
|
||||
type: change.type,
|
||||
resource: change.target.resource,
|
||||
target: change.target.absolute,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (change.moveTarget) {
|
||||
const moveTarget = change.moveTarget
|
||||
yield* fs
|
||||
.writeWithDirs(moveTarget.absolute, change.content)
|
||||
.pipe(Effect.mapError((error) => fail(`Failed to write ${moveTarget.resource}`, error)))
|
||||
yield* fs
|
||||
.remove(change.target.absolute)
|
||||
.pipe(
|
||||
Effect.mapError((error) =>
|
||||
fail(`Wrote ${moveTarget.resource} but failed to remove ${change.target.resource}`, error),
|
||||
),
|
||||
)
|
||||
applied.push({
|
||||
type: change.type,
|
||||
resource: change.moveTarget.resource,
|
||||
target: change.moveTarget.absolute,
|
||||
})
|
||||
return
|
||||
}
|
||||
yield* fs
|
||||
.writeWithDirs(change.target.absolute, change.content)
|
||||
.pipe(Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error)))
|
||||
applied.push({
|
||||
type: change.type,
|
||||
resource: change.target.resource,
|
||||
target: change.target.absolute,
|
||||
})
|
||||
}),
|
||||
{ discard: true },
|
||||
)
|
||||
const formatted = new Map<string, string>()
|
||||
yield* Effect.forEach(
|
||||
[...new Set(applied.filter((item) => item.type !== "delete").map((item) => item.target))],
|
||||
(target) =>
|
||||
Effect.gen(function* () {
|
||||
const current = yield* Bom.readFile(fs, target).pipe(
|
||||
Effect.mapError((error) => fail(`Failed to read ${target}`, error)),
|
||||
)
|
||||
formatted.set(
|
||||
target,
|
||||
(yield* formatter.file(target))
|
||||
? yield* Bom.syncFile(fs, target, current.bom).pipe(
|
||||
Effect.mapError((error) => fail(`Failed to sync ${target}`, error)),
|
||||
)
|
||||
: current.text,
|
||||
)
|
||||
}),
|
||||
{ discard: true },
|
||||
)
|
||||
const files = yield* Effect.forEach(prepared, (change) => {
|
||||
if (change.type === "delete") return Effect.succeed(patchFile(change))
|
||||
const target = change.type === "update" && change.moveTarget ? change.moveTarget : change.target
|
||||
return Effect.succeed(patchFile(change, formatted.get(target.absolute)))
|
||||
})
|
||||
return { applied, files }
|
||||
}).pipe(
|
||||
Effect.map((output) => ({
|
||||
output,
|
||||
content: toModelOutput(output),
|
||||
metadata: { files: output.files },
|
||||
})),
|
||||
Effect.mapError((error) =>
|
||||
error instanceof ToolFailure ? error : new ToolFailure({ message: "Unable to apply patch", error }),
|
||||
),
|
||||
)
|
||||
},
|
||||
}),
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
@@ -354,7 +365,9 @@ function errorMessage(error: unknown) {
|
||||
|
||||
function patchFile(change: Prepared, after = change.after): typeof FileDiff.Info.Type {
|
||||
const target = (change.type === "update" ? change.moveTarget : undefined)?.resource ?? change.target.resource
|
||||
const patch = trimDiff(createTwoFilesPatch(change.target.absolute, change.target.absolute, change.before, after))
|
||||
const patch = trimDiff(
|
||||
createTwoFilesPatch(change.target.canonical, change.target.canonical, change.before, after),
|
||||
)
|
||||
const counts =
|
||||
change.type === "delete"
|
||||
? { additions: 0, deletions: change.before.split("\n").length }
|
||||
@@ -403,22 +416,22 @@ function trimDiff(diff: string) {
|
||||
}
|
||||
|
||||
function resolveTarget(location: Location.Interface, value: string): Target {
|
||||
const absolute =
|
||||
const canonical =
|
||||
process.platform === "win32"
|
||||
? FSUtil.normalizePath(path.resolve(location.directory, value))
|
||||
: path.resolve(location.directory, value)
|
||||
const projectRoot = path.parse(location.project.directory).root
|
||||
const external =
|
||||
!FSUtil.contains(location.directory, absolute) &&
|
||||
(location.project.directory === projectRoot || !FSUtil.contains(location.project.directory, absolute))
|
||||
const directory = path.dirname(absolute)
|
||||
!FSUtil.contains(location.directory, canonical) &&
|
||||
(location.project.directory === projectRoot || !FSUtil.contains(location.project.directory, canonical))
|
||||
const directory = path.dirname(canonical)
|
||||
const resource =
|
||||
process.platform === "win32"
|
||||
? FSUtil.normalizePathPattern(path.join(directory, "*"))
|
||||
: path.join(directory, "*").replaceAll("\\", "/")
|
||||
return {
|
||||
absolute,
|
||||
resource: path.relative(location.project.directory, absolute).replaceAll("\\", "/") || ".",
|
||||
canonical,
|
||||
resource: path.relative(location.project.directory, canonical).replaceAll("\\", "/") || ".",
|
||||
externalDirectory: external ? { directory, resource } : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,11 @@ const LocationInput = Schema.Struct({
|
||||
}),
|
||||
})
|
||||
export const Input = LocationInput
|
||||
const Output = Schema.Union([ReadToolFileSystem.FileContent, ReadToolFileSystem.TextPage, ReadToolFileSystem.ListPage])
|
||||
const Output = Schema.Union([
|
||||
ReadToolFileSystem.FileContent,
|
||||
ReadToolFileSystem.TextPage,
|
||||
ReadToolFileSystem.ListPage,
|
||||
])
|
||||
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.read",
|
||||
@@ -39,102 +43,105 @@ export const Plugin = {
|
||||
|
||||
yield* ctx.tool
|
||||
.transform((draft) =>
|
||||
draft.add({
|
||||
name,
|
||||
options: { codemode: false },
|
||||
description:
|
||||
"Read the contents of a file or directory. Supports text files, images, and PDFs. Images and PDFs are presented directly to the model. Each text line is prefixed by its 1-based line number as <line>: <content>. The prefix is for reference and is not part of the file content. Directory entries are returned one per line. Use offset and limit to read large files or directories in sections. Prefer one larger read over many small slices, and use grep to find specific content in large files.",
|
||||
input: Input,
|
||||
output: Output,
|
||||
execute: (input, context) => {
|
||||
return Effect.gen(function* () {
|
||||
const source = {
|
||||
type: "tool" as const,
|
||||
messageID: context.messageID,
|
||||
id: context.id,
|
||||
}
|
||||
const target = yield* mutation.resolve({ path: input.path })
|
||||
const external = target.externalDirectory
|
||||
if (external)
|
||||
draft.add(
|
||||
({
|
||||
name,
|
||||
options: { codemode: false },
|
||||
description:
|
||||
"Read the contents of a file or directory. Supports text files, images, and PDFs. Images and PDFs are presented directly to the model. Each text line is prefixed by its 1-based line number as <line>: <content>. The prefix is for reference and is not part of the file content. Directory entries are returned one per line. Use offset and limit to read large files or directories in sections. Prefer one larger read over many small slices, and use grep to find specific content in large files.",
|
||||
input: Input,
|
||||
output: Output,
|
||||
execute: (input, context) => {
|
||||
return Effect.gen(function* () {
|
||||
const source = {
|
||||
type: "tool" as const,
|
||||
messageID: context.messageID,
|
||||
id: context.id,
|
||||
}
|
||||
const target = yield* mutation.resolve({ path: input.path, kind: "directory" })
|
||||
const external = target.externalDirectory
|
||||
if (external)
|
||||
yield* permission.assert({
|
||||
...LocationMutation.externalDirectoryPermission(external),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
const resource = target.resource
|
||||
const absolute = AbsolutePath.make(target.canonical)
|
||||
yield* permission.assert({
|
||||
...LocationMutation.externalDirectoryPermission(external),
|
||||
action: name,
|
||||
resources: [resource],
|
||||
save: ["*"],
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
const resource = target.resource
|
||||
const absolute = AbsolutePath.make(target.absolute)
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: [resource],
|
||||
save: ["*"],
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
const type = yield* reader
|
||||
.inspect(absolute)
|
||||
.pipe(Effect.catchReason("PlatformError", "NotFound", () => missing(input.path, target.absolute)))
|
||||
const content =
|
||||
type === "directory"
|
||||
? yield* reader.list(absolute, { offset: input.offset, limit: input.limit })
|
||||
: yield* reader.read(absolute, resource, {
|
||||
offset: input.offset,
|
||||
limit: input.limit,
|
||||
})
|
||||
// After a successful read, discover nearby AGENTS.md walking up to the Location
|
||||
// root exclusive and inject them as durable synthetic instructions. For a
|
||||
// directory listing the walk starts at the directory itself (so its own AGENTS.md
|
||||
// is discovered); for a file it starts at the file's dirname. External reads are
|
||||
// skipped, and discovery failures never fail the read.
|
||||
yield* Effect.gen(function* () {
|
||||
if (target.externalDirectory !== undefined) return
|
||||
const resolved = yield* fs.resolve(target.absolute)
|
||||
const root = yield* fs.resolve(location.directory)
|
||||
// up() searches its stop directory, so the Location-root AGENTS.md (already
|
||||
// supplied by core initial instructions) is dropped by the dirname filter.
|
||||
const discovered = yield* fs.up({
|
||||
targets: [FILENAME],
|
||||
start: type === "directory" ? resolved : dirname(resolved),
|
||||
stop: root,
|
||||
})
|
||||
const candidates = (yield* Effect.forEach(discovered, fs.resolve)).filter(
|
||||
(file) => dirname(file) !== root,
|
||||
const type = yield* reader.inspect(absolute).pipe(
|
||||
Effect.catchReason("PlatformError", "NotFound", () => missing(input.path, target.canonical)),
|
||||
)
|
||||
if (candidates.length === 0) return
|
||||
yield* sessionInstructions.load({ sessionID: context.sessionID, paths: candidates })
|
||||
const content =
|
||||
type === "directory"
|
||||
? yield* reader.list(absolute, { offset: input.offset, limit: input.limit })
|
||||
: yield* reader.read(absolute, resource, {
|
||||
offset: input.offset,
|
||||
limit: input.limit,
|
||||
})
|
||||
// After a successful read, discover nearby AGENTS.md walking up to the Location
|
||||
// root exclusive and inject them as durable synthetic instructions. For a
|
||||
// directory listing the walk starts at the directory itself (so its own AGENTS.md
|
||||
// is discovered); for a file it starts at the file's dirname. External reads are
|
||||
// skipped, and discovery failures never fail the read.
|
||||
yield* Effect.gen(function* () {
|
||||
if (target.externalDirectory !== undefined) return
|
||||
const resolved = yield* fs.resolve(target.canonical)
|
||||
const root = yield* fs.resolve(location.directory)
|
||||
// up() searches its stop directory, so the Location-root AGENTS.md (already
|
||||
// supplied by core initial instructions) is dropped by the dirname filter.
|
||||
const discovered = yield* fs.up({
|
||||
targets: [FILENAME],
|
||||
start: type === "directory" ? resolved : dirname(resolved),
|
||||
stop: root,
|
||||
})
|
||||
const candidates = (yield* Effect.forEach(discovered, fs.resolve)).filter(
|
||||
(file) => dirname(file) !== root,
|
||||
)
|
||||
if (candidates.length === 0) return
|
||||
yield* sessionInstructions.load({ sessionID: context.sessionID, paths: candidates })
|
||||
}).pipe(
|
||||
Effect.catch(() => Effect.void),
|
||||
Effect.catchDefect(() => Effect.void),
|
||||
)
|
||||
if (content.type === "file" && content.encoding === "base64" && !SUPPORTED_MEDIA_MIMES.has(content.mime))
|
||||
return yield* Effect.fail(new ReadToolFileSystem.BinaryFileError({ resource }))
|
||||
return content
|
||||
}).pipe(
|
||||
Effect.catch(() => Effect.void),
|
||||
Effect.catchDefect(() => Effect.void),
|
||||
Effect.map((output) => ({
|
||||
output,
|
||||
content: toModelContent(input.path, input.offset, output),
|
||||
})),
|
||||
Effect.mapError((error) => {
|
||||
if (error instanceof ToolFailure) return error
|
||||
const message =
|
||||
error instanceof ReadToolFileSystem.BinaryFileError ||
|
||||
error instanceof ReadToolFileSystem.MediaIngestLimitError ||
|
||||
error instanceof ReadToolFileSystem.MalformedUtf8Error ||
|
||||
error instanceof ReadToolFileSystem.OffsetOutOfRangeError ||
|
||||
error instanceof ReadToolFileSystem.PathKindError
|
||||
? error.message
|
||||
: `Unable to read ${input.path}`
|
||||
return new ToolFailure({ message, error })
|
||||
}),
|
||||
)
|
||||
if (content.type === "file" && content.encoding === "base64" && !SUPPORTED_MEDIA_MIMES.has(content.mime))
|
||||
return yield* Effect.fail(new ReadToolFileSystem.BinaryFileError({ resource }))
|
||||
return content
|
||||
}).pipe(
|
||||
Effect.map((output) => ({
|
||||
output,
|
||||
content: toModelContent(input.path, input.offset, output),
|
||||
})),
|
||||
Effect.mapError((error) => {
|
||||
if (error instanceof ToolFailure) return error
|
||||
const message =
|
||||
error instanceof ReadToolFileSystem.BinaryFileError ||
|
||||
error instanceof ReadToolFileSystem.MediaIngestLimitError ||
|
||||
error instanceof ReadToolFileSystem.OffsetOutOfRangeError ||
|
||||
error instanceof ReadToolFileSystem.PathKindError
|
||||
? error.message
|
||||
: `Unable to read ${input.path}`
|
||||
return new ToolFailure({ message, error })
|
||||
}),
|
||||
)
|
||||
},
|
||||
}),
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
const missing = Effect.fn("ReadTool.missing")(function* (input: string, absolute: string) {
|
||||
const missing = Effect.fn("ReadTool.missing")(function* (input: string, canonical: string) {
|
||||
const base = basename(input).toLowerCase()
|
||||
const suggestions = yield* fs.readDirectory(dirname(absolute)).pipe(
|
||||
const suggestions = yield* fs.readDirectory(dirname(canonical)).pipe(
|
||||
Effect.map((entries) =>
|
||||
entries
|
||||
.filter((entry) => {
|
||||
|
||||
@@ -122,176 +122,174 @@ export const Plugin = {
|
||||
|
||||
yield* ctx.tool
|
||||
.transform((draft) =>
|
||||
draft.add({
|
||||
name,
|
||||
options: { codemode: false },
|
||||
description: description(),
|
||||
input: Input,
|
||||
output: Output,
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
const source = {
|
||||
type: "tool" as const,
|
||||
messageID: context.messageID,
|
||||
id: context.id,
|
||||
}
|
||||
const timeout = input.background === true ? (input.timeout ?? 0) : (input.timeout ?? DEFAULT_TIMEOUT_MS)
|
||||
let finalTimeout = timeout
|
||||
const info = yield* shell.create(
|
||||
{
|
||||
command: input.command,
|
||||
cwd: input.workdir,
|
||||
timeout,
|
||||
metadata: { sessionID: context.sessionID },
|
||||
},
|
||||
(invocation) =>
|
||||
Effect.gen(function* () {
|
||||
const target = yield* mutation.resolve({ path: invocation.cwd, kind: "directory" })
|
||||
const parsed = yield* ShellParse.scan(invocation.command, invocation.shell, target.absolute)
|
||||
const directories = yield* Effect.forEach(parsed.directories, (directory) =>
|
||||
mutation.resolve({ path: path.resolve(target.absolute, directory), kind: "directory" }),
|
||||
)
|
||||
invocation.cwd = target.absolute
|
||||
finalTimeout = invocation.timeout
|
||||
const external = [target, ...directories]
|
||||
.map((item) => item.externalDirectory)
|
||||
.filter((item) => item !== undefined)
|
||||
.filter(
|
||||
(item, index, items) => items.findIndex((other) => other.resource === item.resource) === index,
|
||||
draft.add(
|
||||
({
|
||||
name,
|
||||
options: { codemode: false },
|
||||
description: description(),
|
||||
input: Input,
|
||||
output: Output,
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
const source = {
|
||||
type: "tool" as const,
|
||||
messageID: context.messageID,
|
||||
id: context.id,
|
||||
}
|
||||
const timeout = input.background === true ? (input.timeout ?? 0) : (input.timeout ?? DEFAULT_TIMEOUT_MS)
|
||||
let finalTimeout = timeout
|
||||
const info = yield* shell.create(
|
||||
{
|
||||
command: input.command,
|
||||
cwd: input.workdir,
|
||||
timeout,
|
||||
metadata: { sessionID: context.sessionID },
|
||||
},
|
||||
(invocation) =>
|
||||
Effect.gen(function* () {
|
||||
const target = yield* mutation.resolve({ path: invocation.cwd, kind: "directory" })
|
||||
const parsed = yield* ShellParse.scan(invocation.command, invocation.shell, target.canonical)
|
||||
const directories = yield* Effect.forEach(parsed.directories, (directory) =>
|
||||
mutation.resolve({ path: path.resolve(target.canonical, directory), kind: "directory" }),
|
||||
)
|
||||
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,
|
||||
})
|
||||
if (parsed.commands.length > 0)
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: parsed.commands.map((command) => command.resource),
|
||||
save: parsed.commands.map((command) => command.save),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
const workdir = yield* fsUtil
|
||||
.stat(target.absolute)
|
||||
.pipe(
|
||||
invocation.cwd = target.canonical
|
||||
finalTimeout = invocation.timeout
|
||||
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,
|
||||
})
|
||||
if (parsed.commands.length > 0)
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: parsed.commands.map((command) => command.resource),
|
||||
save: parsed.commands.map((command) => command.save),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
const workdir = yield* fsUtil.stat(target.canonical).pipe(
|
||||
Effect.catchReason("PlatformError", "NotFound", () =>
|
||||
Effect.fail(new Error(`Working directory does not exist: ${target.absolute}`)),
|
||||
Effect.fail(new Error(`Working directory does not exist: ${target.canonical}`)),
|
||||
),
|
||||
)
|
||||
if (workdir.type !== "Directory")
|
||||
return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.absolute}`))
|
||||
}),
|
||||
)
|
||||
yield* context.progress({ shellID: info.id })
|
||||
if (workdir.type !== "Directory")
|
||||
return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.canonical}`))
|
||||
}),
|
||||
)
|
||||
yield* context.progress({ shellID: info.id })
|
||||
|
||||
const captureShell = Effect.fn("ShellTool.captureShell")(function* () {
|
||||
const latest = yield* shell.output(info.id, { cursor: Number.MAX_SAFE_INTEGER })
|
||||
const truncated = latest.size > MAX_CAPTURE_BYTES
|
||||
const page = yield* shell.output(info.id, {
|
||||
cursor: Math.max(0, latest.size - MAX_CAPTURE_BYTES),
|
||||
limit: MAX_CAPTURE_BYTES,
|
||||
const captureShell = Effect.fn("ShellTool.captureShell")(function* () {
|
||||
const latest = yield* shell.output(info.id, { cursor: Number.MAX_SAFE_INTEGER })
|
||||
const truncated = latest.size > MAX_CAPTURE_BYTES
|
||||
const page = yield* shell.output(info.id, {
|
||||
cursor: Math.max(0, latest.size - MAX_CAPTURE_BYTES),
|
||||
limit: MAX_CAPTURE_BYTES,
|
||||
})
|
||||
const notice = truncated ? `\n\n[output truncated; full output saved to: ${info.file}]` : ""
|
||||
return {
|
||||
output: `${page.output || "(no output)"}${notice}`,
|
||||
truncated,
|
||||
}
|
||||
})
|
||||
const notice = truncated ? `\n\n[output truncated; full output saved to: ${info.file}]` : ""
|
||||
return {
|
||||
output: `${page.output || "(no output)"}${notice}`,
|
||||
truncated,
|
||||
}
|
||||
})
|
||||
|
||||
const settleShell = Effect.fn("ShellTool.settleShell")(function* () {
|
||||
const final = yield* shell.wait(info.id)
|
||||
const capture = yield* captureShell()
|
||||
const settleShell = Effect.fn("ShellTool.settleShell")(function* () {
|
||||
const final = yield* shell.wait(info.id)
|
||||
const capture = yield* captureShell()
|
||||
|
||||
// `exit` is optionalKey in the Output schema; a present-but-undefined key
|
||||
// fails output encoding, so omit it when the process has no exit code.
|
||||
if (final.status === "timeout") {
|
||||
return {
|
||||
...(final.exit !== undefined ? { exit: final.exit } : {}),
|
||||
output: `${capture.output}\n\nCommand exceeded timeout of ${finalTimeout} ms. Retry with a larger timeout if the command is expected to take longer.`,
|
||||
truncated: capture.truncated,
|
||||
timeout: true,
|
||||
status: "completed" as const,
|
||||
}
|
||||
}
|
||||
|
||||
// `exit` is optionalKey in the Output schema; a present-but-undefined key
|
||||
// fails output encoding, so omit it when the process has no exit code.
|
||||
if (final.status === "timeout") {
|
||||
return {
|
||||
...(final.exit !== undefined ? { exit: final.exit } : {}),
|
||||
output: `${capture.output}\n\nCommand exceeded timeout of ${finalTimeout} ms. Retry with a larger timeout if the command is expected to take longer.`,
|
||||
output: capture.output,
|
||||
truncated: capture.truncated,
|
||||
timeout: true,
|
||||
status: "completed" as const,
|
||||
}
|
||||
})
|
||||
|
||||
const settled = yield* Deferred.make<Output>()
|
||||
const run = settleShell().pipe(
|
||||
Effect.tap((output) => Deferred.succeed(settled, output)),
|
||||
Effect.map((output) => output.output),
|
||||
Effect.onInterrupt(() => shell.remove(info.id).pipe(Effect.ignore)),
|
||||
)
|
||||
const job = yield* runtime.job.start({
|
||||
id: context.id,
|
||||
type: name,
|
||||
title: info.command,
|
||||
metadata: { sessionID: context.sessionID, shellID: info.id },
|
||||
run,
|
||||
})
|
||||
|
||||
if (input.background === true) {
|
||||
yield* runtime.job.background(job.id)
|
||||
yield* notifyWhenDone(context.sessionID, context.id, info.command)
|
||||
return {
|
||||
output: BACKGROUND_STARTED,
|
||||
shellID: info.id,
|
||||
truncated: false,
|
||||
status: "running" as const,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...(final.exit !== undefined ? { exit: final.exit } : {}),
|
||||
output: capture.output,
|
||||
truncated: capture.truncated,
|
||||
status: "completed" as const,
|
||||
const result = yield* runtime.job.block({ id: job.id, sessionID: context.sessionID }).pipe(
|
||||
Effect.onInterrupt(() => runtime.job.cancel(job.id).pipe(Effect.ignore)),
|
||||
)
|
||||
if (result?.type === "backgrounded") {
|
||||
yield* shell.timeout(info.id, 0)
|
||||
yield* notifyWhenDone(context.sessionID, context.id, info.command)
|
||||
return {
|
||||
output: BACKGROUND_STARTED,
|
||||
shellID: info.id,
|
||||
truncated: false,
|
||||
status: "running" as const,
|
||||
}
|
||||
}
|
||||
})
|
||||
if (result?.info.status === "error")
|
||||
return yield* Effect.fail(new Error(result.info.error ?? "Command failed"))
|
||||
if (result?.info.status === "cancelled") return yield* Effect.fail(new Error("Command cancelled"))
|
||||
|
||||
const settled = yield* Deferred.make<Output>()
|
||||
const run = settleShell().pipe(
|
||||
Effect.tap((output) => Deferred.succeed(settled, output)),
|
||||
Effect.map((output) => output.output),
|
||||
Effect.onInterrupt(() => shell.remove(info.id).pipe(Effect.ignore)),
|
||||
)
|
||||
const job = yield* runtime.job.start({
|
||||
id: context.id,
|
||||
type: name,
|
||||
title: info.command,
|
||||
metadata: { sessionID: context.sessionID, shellID: info.id },
|
||||
run,
|
||||
})
|
||||
|
||||
if (input.background === true) {
|
||||
yield* runtime.job.background(job.id)
|
||||
yield* notifyWhenDone(context.sessionID, context.id, info.command)
|
||||
return {
|
||||
output: BACKGROUND_STARTED,
|
||||
shellID: info.id,
|
||||
truncated: false,
|
||||
status: "running" as const,
|
||||
}
|
||||
}
|
||||
|
||||
const result = yield* runtime.job
|
||||
.block({ id: job.id, sessionID: context.sessionID })
|
||||
.pipe(Effect.onInterrupt(() => runtime.job.cancel(job.id).pipe(Effect.ignore)))
|
||||
if (result?.type === "backgrounded") {
|
||||
yield* shell.timeout(info.id, 0)
|
||||
yield* notifyWhenDone(context.sessionID, context.id, info.command)
|
||||
return {
|
||||
output: BACKGROUND_STARTED,
|
||||
shellID: info.id,
|
||||
truncated: false,
|
||||
status: "running" as const,
|
||||
}
|
||||
}
|
||||
if (result?.info.status === "error")
|
||||
return yield* Effect.fail(new Error(result.info.error ?? "Command failed"))
|
||||
if (result?.info.status === "cancelled") return yield* Effect.fail(new Error("Command cancelled"))
|
||||
|
||||
return yield* Deferred.await(settled)
|
||||
}).pipe(
|
||||
Effect.map((output) => {
|
||||
const content: Array<Content> = [{ type: "text", text: output.output }]
|
||||
const model = modelOutput(output)
|
||||
if (model) content.push({ type: "text", text: model })
|
||||
return {
|
||||
output,
|
||||
content,
|
||||
metadata: {
|
||||
truncated: output.truncated,
|
||||
...("exit" in output && output.exit !== undefined ? { exit: output.exit } : {}),
|
||||
...("shellID" in output && output.shellID !== undefined ? { shellID: output.shellID } : {}),
|
||||
...("timeout" in output && output.timeout !== undefined ? { timeout: output.timeout } : {}),
|
||||
},
|
||||
}
|
||||
}),
|
||||
Effect.mapError(
|
||||
(error) => new ToolFailure({ message: `Unable to execute command: ${input.command}`, error }),
|
||||
return yield* Deferred.await(settled)
|
||||
}).pipe(
|
||||
Effect.map((output) => {
|
||||
const content: Array<Content> = [{ type: "text", text: output.output }]
|
||||
const model = modelOutput(output)
|
||||
if (model) content.push({ type: "text", text: model })
|
||||
return {
|
||||
output,
|
||||
content,
|
||||
metadata: {
|
||||
truncated: output.truncated,
|
||||
...("exit" in output && output.exit !== undefined ? { exit: output.exit } : {}),
|
||||
...("shellID" in output && output.shellID !== undefined ? { shellID: output.shellID } : {}),
|
||||
...("timeout" in output && output.timeout !== undefined ? { timeout: output.timeout } : {}),
|
||||
},
|
||||
}
|
||||
}),
|
||||
Effect.mapError(
|
||||
(error) => new ToolFailure({ message: `Unable to execute command: ${input.command}`, error }),
|
||||
),
|
||||
),
|
||||
),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
|
||||
@@ -54,52 +54,59 @@ export const Plugin = {
|
||||
|
||||
yield* ctx.tool
|
||||
.transform((draft) =>
|
||||
draft.add({
|
||||
name,
|
||||
options: { codemode: false, permission: "edit" },
|
||||
description:
|
||||
"Writes a file to the local filesystem, overwriting if one exists.\n\nMissing parent directories are created automatically.\n\nUse this tool to create new files or overwrite existing files. For partial changes, use the edit tool instead.",
|
||||
input: Input,
|
||||
output: Output,
|
||||
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, kind: "file" })
|
||||
const external = target.externalDirectory
|
||||
if (external)
|
||||
yield* permission.assert({
|
||||
...LocationMutation.externalDirectoryPermission(external),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
const current = yield* Bom.readFile(fs, target.absolute).pipe(
|
||||
Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)),
|
||||
)
|
||||
const next = Bom.split(input.content)
|
||||
const preview = fileDiff(target.resource, current?.text ?? "", next.text, current ? "modified" : "added")
|
||||
yield* permission.assert({
|
||||
action: "edit",
|
||||
resources: [target.resource],
|
||||
save: ["*"],
|
||||
metadata: { files: [preview] },
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
const result = yield* files.writeTextPreservingBom({ target, content: input.content })
|
||||
const bom = (yield* Bom.readFile(fs, target.absolute)).bom
|
||||
if (yield* formatter.file(target.absolute)) yield* Bom.syncFile(fs, target.absolute, bom)
|
||||
return result
|
||||
}).pipe(
|
||||
Effect.map((output) => ({ output, content: toModelOutput(output) })),
|
||||
Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error })),
|
||||
),
|
||||
}),
|
||||
draft.add(
|
||||
({
|
||||
name,
|
||||
options: { codemode: false, permission: "edit" },
|
||||
description:
|
||||
"Writes a file to the local filesystem, overwriting if one exists.\n\nMissing parent directories are created automatically.\n\nUse this tool to create new files or overwrite existing files. For partial changes, use the edit tool instead.",
|
||||
input: Input,
|
||||
output: Output,
|
||||
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, kind: "file" })
|
||||
const external = target.externalDirectory
|
||||
if (external)
|
||||
yield* permission.assert({
|
||||
...LocationMutation.externalDirectoryPermission(external),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
const current = yield* Bom.readFile(fs, target.canonical).pipe(
|
||||
Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)),
|
||||
)
|
||||
const next = Bom.split(input.content)
|
||||
const preview = fileDiff(
|
||||
target.resource,
|
||||
current?.text ?? "",
|
||||
next.text,
|
||||
current ? "modified" : "added",
|
||||
)
|
||||
yield* permission.assert({
|
||||
action: "edit",
|
||||
resources: [target.resource],
|
||||
save: ["*"],
|
||||
metadata: { files: [preview] },
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
const result = yield* files.writeTextPreservingBom({ target, content: input.content })
|
||||
const bom = (yield* Bom.readFile(fs, target.canonical)).bom
|
||||
if (yield* formatter.file(target.canonical)) yield* Bom.syncFile(fs, target.canonical, bom)
|
||||
return result
|
||||
}).pipe(
|
||||
Effect.map((output) => ({ output, content: toModelOutput(output) })),
|
||||
Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error })),
|
||||
),
|
||||
}),
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
|
||||
@@ -34,6 +34,14 @@ export class MediaIngestLimitError extends Schema.TaggedErrorClass<MediaIngestLi
|
||||
}
|
||||
}
|
||||
|
||||
export class MalformedUtf8Error extends Schema.TaggedErrorClass<MalformedUtf8Error>()("ReadTool.MalformedUtf8Error", {
|
||||
resource: Schema.String,
|
||||
}) {
|
||||
override get message() {
|
||||
return `File is not valid UTF-8: ${this.resource}`
|
||||
}
|
||||
}
|
||||
|
||||
export class OffsetOutOfRangeError extends Schema.TaggedErrorClass<OffsetOutOfRangeError>()(
|
||||
"ReadTool.OffsetOutOfRangeError",
|
||||
{ offset: Schema.Number },
|
||||
@@ -53,7 +61,13 @@ export class PathKindError extends Schema.TaggedErrorClass<PathKindError>()("Rea
|
||||
}
|
||||
|
||||
export type InspectError = FSUtil.Error | PathKindError
|
||||
export type ReadError = FSUtil.Error | BinaryFileError | MediaIngestLimitError | OffsetOutOfRangeError | PathKindError
|
||||
export type ReadError =
|
||||
| FSUtil.Error
|
||||
| BinaryFileError
|
||||
| MediaIngestLimitError
|
||||
| MalformedUtf8Error
|
||||
| OffsetOutOfRangeError
|
||||
| PathKindError
|
||||
|
||||
export const PageInput = Schema.Struct({
|
||||
offset: Schema.optionalKey(NonNegativeInt),
|
||||
@@ -76,15 +90,9 @@ export class TextPage extends Schema.Class<TextPage>("ReadTool.TextPage")({
|
||||
next: Schema.optionalKey(PositiveInt),
|
||||
}) {}
|
||||
|
||||
export interface ListEntry extends Schema.Schema.Type<typeof ListEntry> {}
|
||||
export const ListEntry = Schema.Struct({
|
||||
path: RelativePath,
|
||||
type: Schema.Literals(["file", "directory", "symlink"]),
|
||||
}).annotate({ identifier: "ReadTool.ListEntry" })
|
||||
|
||||
export class ListPage extends Schema.Class<ListPage>("ReadTool.ListPage")({
|
||||
type: Schema.Literal("list-page"),
|
||||
entries: Schema.Array(ListEntry),
|
||||
entries: Schema.Array(FileSystem.Entry),
|
||||
truncated: Schema.Boolean,
|
||||
next: Schema.optionalKey(PositiveInt),
|
||||
}) {}
|
||||
@@ -101,6 +109,36 @@ export interface Interface {
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ReadToolFileSystem") {}
|
||||
|
||||
const extensions = new Set([
|
||||
".zip",
|
||||
".tar",
|
||||
".gz",
|
||||
".exe",
|
||||
".dll",
|
||||
".so",
|
||||
".class",
|
||||
".jar",
|
||||
".war",
|
||||
".7z",
|
||||
".doc",
|
||||
".docx",
|
||||
".xls",
|
||||
".xlsx",
|
||||
".ppt",
|
||||
".pptx",
|
||||
".odt",
|
||||
".ods",
|
||||
".odp",
|
||||
".bin",
|
||||
".dat",
|
||||
".obj",
|
||||
".o",
|
||||
".a",
|
||||
".lib",
|
||||
".wasm",
|
||||
".pyc",
|
||||
".pyo",
|
||||
])
|
||||
const startsWith = (bytes: Uint8Array, prefix: number[]) => prefix.every((value, index) => bytes[index] === value)
|
||||
const mediaMime = (bytes: Uint8Array) => {
|
||||
if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "image/png"
|
||||
@@ -110,7 +148,8 @@ const mediaMime = (bytes: Uint8Array) => {
|
||||
return "image/webp"
|
||||
if (startsWith(bytes, [0x25, 0x50, 0x44, 0x46, 0x2d])) return "application/pdf"
|
||||
}
|
||||
const binary = (bytes: Uint8Array) => {
|
||||
const binary = (resource: string, bytes: Uint8Array) => {
|
||||
if (extensions.has(path.extname(resource).toLowerCase())) return true
|
||||
if (bytes.length === 0) return false
|
||||
let nonPrintable = 0
|
||||
for (const byte of bytes) {
|
||||
@@ -119,9 +158,16 @@ const binary = (bytes: Uint8Array) => {
|
||||
}
|
||||
return nonPrintable / bytes.length > 0.3
|
||||
}
|
||||
const decodeUtf8 = (decoder: TextDecoder, bytes?: Uint8Array) => decoder.decode(bytes, { stream: bytes !== undefined })
|
||||
const decodeUtf8 = (resource: string, decoder: TextDecoder, bytes?: Uint8Array) =>
|
||||
Effect.try({
|
||||
try: () => decoder.decode(bytes, { stream: bytes !== undefined }),
|
||||
catch: (error) => {
|
||||
if (error instanceof TypeError) return new MalformedUtf8Error({ resource })
|
||||
throw error
|
||||
},
|
||||
})
|
||||
const decodeChunk = (resource: string, decoder: TextDecoder, bytes: Uint8Array) =>
|
||||
bytes.includes(0) ? Effect.fail(new BinaryFileError({ resource })) : Effect.succeed(decodeUtf8(decoder, bytes))
|
||||
bytes.includes(0) ? Effect.fail(new BinaryFileError({ resource })) : decodeUtf8(resource, decoder, bytes)
|
||||
|
||||
export const inspect = Effect.fn("ReadTool.inspect")(function* (fs: FSUtil.Interface, input: string) {
|
||||
const info = yield* fs.stat(input)
|
||||
@@ -172,17 +218,19 @@ export const read = Effect.fn("ReadTool.read")(function* (
|
||||
mime,
|
||||
}
|
||||
}
|
||||
if (extensions.has(path.extname(resource).toLowerCase()))
|
||||
return yield* Effect.fail(new BinaryFileError({ resource }))
|
||||
const paged = info.size > MAX_READ_BYTES || page.offset !== undefined || page.limit !== undefined
|
||||
if (!paged) {
|
||||
if (binary(first)) return yield* Effect.fail(new BinaryFileError({ resource }))
|
||||
const decoder = new TextDecoder()
|
||||
const text = [decodeUtf8(decoder, first)]
|
||||
if (binary(resource, first)) return yield* Effect.fail(new BinaryFileError({ resource }))
|
||||
const decoder = new TextDecoder("utf-8", { fatal: true })
|
||||
const text = [yield* decodeUtf8(resource, decoder, first)]
|
||||
while (true) {
|
||||
const chunk = yield* file.readAlloc(64 * 1024)
|
||||
if (Option.isNone(chunk)) break
|
||||
text.push(yield* decodeChunk(resource, decoder, chunk.value))
|
||||
}
|
||||
text.push(decodeUtf8(decoder))
|
||||
text.push(yield* decodeUtf8(resource, decoder))
|
||||
return {
|
||||
type: "file" as const,
|
||||
uri: pathToFileURL(real).href,
|
||||
@@ -195,7 +243,7 @@ export const read = Effect.fn("ReadTool.read")(function* (
|
||||
const offset = page.offset || 1
|
||||
const limit = Math.min(page.limit || MAX_READ_LINES, MAX_READ_LINES)
|
||||
const lines: string[] = []
|
||||
const decoder = new TextDecoder()
|
||||
const decoder = new TextDecoder("utf-8", { fatal: true })
|
||||
let pending = ""
|
||||
let discard = false
|
||||
let line = 1
|
||||
@@ -253,8 +301,8 @@ export const read = Effect.fn("ReadTool.read")(function* (
|
||||
const newline = chunk.indexOf(10, start)
|
||||
const end = newline === -1 ? chunk.length : newline + 1
|
||||
const segment = chunk.subarray(start, end)
|
||||
if (binary(segment)) return yield* Effect.fail(new BinaryFileError({ resource }))
|
||||
if (!consume(decodeUtf8(decoder, segment))) return false
|
||||
if (binary(resource, segment)) return yield* Effect.fail(new BinaryFileError({ resource }))
|
||||
if (!consume(yield* decodeUtf8(resource, decoder, segment))) return false
|
||||
start = end
|
||||
}
|
||||
return true
|
||||
@@ -266,7 +314,7 @@ export const read = Effect.fn("ReadTool.read")(function* (
|
||||
done = !(yield* consumeChunk(chunk.value))
|
||||
}
|
||||
if (!done) {
|
||||
const tail = decodeUtf8(decoder)
|
||||
const tail = yield* decodeUtf8(resource, decoder)
|
||||
if (!discard) pending += tail
|
||||
if (pending) append(pending.endsWith("\r") ? pending.slice(0, -1) : pending)
|
||||
}
|
||||
@@ -288,26 +336,26 @@ export const list = Effect.fn("ReadTool.list")(function* (fs: FSUtil.Interface,
|
||||
const items = yield* fs.readDirectoryEntries(real)
|
||||
const offset = page.offset || 1
|
||||
const limit = Math.min(page.limit || MAX_READ_LINES, MAX_READ_LINES)
|
||||
const visible = items
|
||||
.flatMap((item) =>
|
||||
item.type === "other"
|
||||
? []
|
||||
: [
|
||||
ListEntry.make({
|
||||
path: RelativePath.make(item.name + (item.type === "directory" ? path.sep : "")),
|
||||
type: item.type,
|
||||
}),
|
||||
],
|
||||
)
|
||||
.sort((a, b) =>
|
||||
a.type === "directory"
|
||||
? b.type === "directory"
|
||||
? a.path.localeCompare(b.path)
|
||||
: -1
|
||||
: b.type === "directory"
|
||||
? 1
|
||||
: a.path.localeCompare(b.path),
|
||||
)
|
||||
const entries = yield* Effect.forEach(
|
||||
items,
|
||||
(item) =>
|
||||
Effect.gen(function* () {
|
||||
const absolute = path.join(real, item.name)
|
||||
const target = yield* fs.realPath(absolute).pipe(Effect.catch(() => Effect.void))
|
||||
if (!target || !FSUtil.contains(real, target)) return
|
||||
const info = yield* fs.stat(target).pipe(Effect.catch(() => Effect.void))
|
||||
const type = info?.type === "Directory" ? "directory" : info?.type === "File" ? "file" : undefined
|
||||
if (!type) return
|
||||
return FileSystem.Entry.make({
|
||||
path: RelativePath.make(item.name + (type === "directory" ? path.sep : "")),
|
||||
type,
|
||||
})
|
||||
}),
|
||||
{ concurrency: 16 },
|
||||
)
|
||||
const visible = entries
|
||||
.filter((item): item is FileSystem.Entry => item !== undefined)
|
||||
.sort((a, b) => (a.type === b.type ? a.path.localeCompare(b.path) : a.type === "directory" ? -1 : 1))
|
||||
const selected = visible.slice(offset - 1, offset - 1 + limit)
|
||||
const truncated = offset - 1 + selected.length < visible.length
|
||||
return new ListPage({
|
||||
|
||||
@@ -119,16 +119,8 @@ function agents(info: typeof ConfigV1.Info.Type) {
|
||||
...Object.entries(info.agent ?? {}),
|
||||
...Object.entries(info.mode ?? {}).map(([name, agent]) => [name, { ...agent, mode: "primary" as const }] as const),
|
||||
]
|
||||
const result = Object.fromEntries(entries.flatMap(([name, agent]) => (agent ? [[name, migrateAgent(agent)]] : [])))
|
||||
const small = modelSelection(info.small_model)
|
||||
if (!small) return entries.length ? result : undefined
|
||||
return {
|
||||
...result,
|
||||
title: {
|
||||
model: small,
|
||||
...result.title,
|
||||
},
|
||||
}
|
||||
if (!entries.length) return undefined
|
||||
return Object.fromEntries(entries.flatMap(([name, agent]) => (agent ? [[name, migrateAgent(agent)]] : [])))
|
||||
}
|
||||
|
||||
export function migrateAgent(info: ConfigAgentV1.Info) {
|
||||
|
||||
@@ -126,7 +126,6 @@ describe("Agent", () => {
|
||||
|
||||
yield* agent.transform((editor) => editor.update(id, () => {}))
|
||||
const info = yield* agent.get(id)
|
||||
expect(info?.mode).toBe("primary")
|
||||
expect(info?.permissions.slice(0, Agent.Info.default(id).permissions.length)).toEqual(
|
||||
Agent.Info.default(id).permissions,
|
||||
)
|
||||
|
||||
@@ -512,20 +512,6 @@ describe("Config", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("migrates the v1 small model to the title agent", () =>
|
||||
Effect.sync(() => {
|
||||
expect(
|
||||
ConfigMigrateV1.migrate({
|
||||
small_model: "anthropic/claude-haiku-4-5",
|
||||
agent: { title: { prompt: "Custom title prompt" } },
|
||||
}).agents?.title,
|
||||
).toEqual({
|
||||
model: { providerID: "anthropic", model: "claude-haiku-4-5" },
|
||||
system: "Custom title prompt",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("migrates v1 provider lists to policies", () =>
|
||||
Effect.sync(() => {
|
||||
expect(
|
||||
|
||||
@@ -149,28 +149,6 @@ describe("ConfigNormalize", () => {
|
||||
expect(() => Schema.decodeUnknownSync(Info)(result.encoded)).not.toThrow()
|
||||
})
|
||||
|
||||
test("migrates the legacy small model to the title agent", () => {
|
||||
const result = normalized({
|
||||
small_model: "anthropic/claude-haiku-4-5",
|
||||
agent: { title: { prompt: "Custom title prompt" } },
|
||||
})
|
||||
expect(result.encoded.agents).toEqual({
|
||||
title: {
|
||||
model: { providerID: "anthropic", model: "claude-haiku-4-5" },
|
||||
system: "Custom title prompt",
|
||||
},
|
||||
})
|
||||
expect(result.diagnostics).toEqual([])
|
||||
})
|
||||
|
||||
test("omits an invalid legacy small model without exposing its value", () => {
|
||||
const secret = "do-not-log-this-value"
|
||||
const result = normalized({ small_model: secret })
|
||||
expect(result.encoded.agents).toBeUndefined()
|
||||
expect(result.diagnostics.map((item) => [item.kind, item.path])).toEqual([["unsupported", ["small_model"]]])
|
||||
expect(JSON.stringify(result.diagnostics)).not.toContain(secret)
|
||||
})
|
||||
|
||||
test("recovers malformed named entries and retains a valid legacy collision", () => {
|
||||
const result = normalized({
|
||||
command: { fallback: { template: "legacy" } },
|
||||
@@ -412,6 +390,7 @@ describe("ConfigNormalize", () => {
|
||||
const secret = "do-not-log-this-value"
|
||||
const result = normalized({
|
||||
logLevel: "DEBUG",
|
||||
small_model: secret,
|
||||
agent: { reviewer: { name: secret, prompt: "review" } },
|
||||
provider: {
|
||||
custom: {
|
||||
@@ -430,6 +409,7 @@ describe("ConfigNormalize", () => {
|
||||
})
|
||||
expect(result.diagnostics.filter((item) => item.kind === "unsupported").map((item) => item.path)).toEqual([
|
||||
["logLevel"],
|
||||
["small_model"],
|
||||
["agent", "reviewer", "name"],
|
||||
["provider", "custom", "id"],
|
||||
["provider", "custom", "whitelist"],
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import fs from "node:fs/promises"
|
||||
import { Effect } from "effect"
|
||||
import { ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/util/cross-spawn-spawner"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { execDefaults, Failed, makeFiles, makeMemoryDriver } from "../src/environment/index"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { environmentConformance } from "./lib/environment-conformance"
|
||||
|
||||
environmentConformance("memory environment", () => {
|
||||
const driver = makeMemoryDriver()
|
||||
return {
|
||||
files: makeFiles(driver),
|
||||
root: `/workspace-${crypto.randomUUID()}`,
|
||||
symlink: driver.symlink,
|
||||
}
|
||||
})
|
||||
|
||||
environmentConformance(
|
||||
"GNU exec environment",
|
||||
async () => {
|
||||
const spawner = await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
return yield* ChildProcessSpawner.ChildProcessSpawner
|
||||
}).pipe(Effect.provide(LayerNode.compile(CrossSpawnSpawner.node))),
|
||||
)
|
||||
const tmp = await tmpdir("opencode-environment-")
|
||||
return {
|
||||
files: execDefaults(spawner),
|
||||
root: tmp.path,
|
||||
symlink: (target: string, link: string) =>
|
||||
Effect.tryPromise({
|
||||
try: () => fs.symlink(target, link),
|
||||
catch: (cause) => new Failed({ path: link, cause }),
|
||||
}),
|
||||
dispose: () => tmp[Symbol.asyncDispose](),
|
||||
}
|
||||
},
|
||||
process.platform !== "linux",
|
||||
)
|
||||
@@ -43,7 +43,7 @@ describe("FileMutation", () => {
|
||||
|
||||
expect(yield* (yield* FileMutation.Service).write({ target, content: "after" })).toEqual({
|
||||
operation: "write",
|
||||
target: target.absolute,
|
||||
target: target.canonical,
|
||||
resource: "hello.txt",
|
||||
existed: true,
|
||||
})
|
||||
@@ -62,11 +62,11 @@ describe("FileMutation", () => {
|
||||
|
||||
expect(result).toEqual({
|
||||
operation: "write",
|
||||
target: target.absolute,
|
||||
target: target.canonical,
|
||||
resource: "src/nested/hello.txt",
|
||||
existed: false,
|
||||
})
|
||||
expect(yield* Effect.promise(() => fs.readFile(target.absolute, "utf8"))).toBe("hello")
|
||||
expect(yield* Effect.promise(() => fs.readFile(result.target, "utf8"))).toBe("hello")
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
@@ -84,7 +84,7 @@ describe("FileMutation", () => {
|
||||
yield* files.writeTextPreservingBom({ target: created, content: "\uFEFF\uFEFF\uFEFFcreated" })
|
||||
|
||||
expect(yield* Effect.promise(() => fs.readFile(preservedPath, "utf8"))).toBe("\uFEFFafter")
|
||||
expect(yield* Effect.promise(() => fs.readFile(created.absolute, "utf8"))).toBe("\uFEFFcreated")
|
||||
expect(yield* Effect.promise(() => fs.readFile(created.canonical, "utf8"))).toBe("\uFEFFcreated")
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
@@ -99,7 +99,7 @@ describe("FileMutation", () => {
|
||||
|
||||
expect(result).toEqual({
|
||||
operation: "write",
|
||||
target: target.absolute,
|
||||
target: target.canonical,
|
||||
resource: target.resource,
|
||||
existed: false,
|
||||
})
|
||||
@@ -109,7 +109,7 @@ describe("FileMutation", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("serializes concurrent writes to the same absolute target", () =>
|
||||
it.live("serializes concurrent writes to the same canonical target", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(directory, "shared.txt")
|
||||
@@ -152,7 +152,7 @@ describe("FileMutation", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("allows distinct absolute targets to proceed independently", () =>
|
||||
it.live("allows distinct canonical targets to proceed independently", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const firstStarted = yield* Deferred.make<void>()
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Failed, NotFound, WrongKind, type Files } from "../../src/environment/index"
|
||||
|
||||
export interface EnvironmentHarness {
|
||||
readonly files: Files
|
||||
readonly root: string
|
||||
readonly symlink?: (target: string, path: string) => Effect.Effect<void, Failed>
|
||||
readonly dispose?: () => Promise<void>
|
||||
}
|
||||
|
||||
export const environmentConformance = (
|
||||
name: string,
|
||||
makeHarness: () => EnvironmentHarness | Promise<EnvironmentHarness>,
|
||||
skip = false,
|
||||
) => {
|
||||
const check = (title: string, body: (harness: EnvironmentHarness) => Promise<void>) =>
|
||||
test(title, async () => {
|
||||
const harness = await makeHarness()
|
||||
try {
|
||||
await Effect.runPromise(harness.files.mkdir(harness.root))
|
||||
await body(harness)
|
||||
} finally {
|
||||
try {
|
||||
await Effect.runPromise(harness.files.remove(harness.root))
|
||||
} finally {
|
||||
await harness.dispose?.()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const bytes = (value: string) => new TextEncoder().encode(value)
|
||||
const text = (value: Uint8Array) => new TextDecoder().decode(value)
|
||||
const failure = <E>(effect: Effect.Effect<unknown, E>) => Effect.runPromise(Effect.flip(effect))
|
||||
|
||||
const suite = skip ? describe.skip : describe
|
||||
|
||||
suite(name, () => {
|
||||
check("writes, stats, and reads a file with its info", async ({ files, root }) => {
|
||||
const target = `${root}/hello.txt`
|
||||
await Effect.runPromise(files.write(target, bytes("hello")))
|
||||
const result = await Effect.runPromise(files.read(target))
|
||||
expect(text(result.bytes)).toBe("hello")
|
||||
expect(result.info.type).toBe("file")
|
||||
expect(result.info.size).toBe(5)
|
||||
expect(await Effect.runPromise(files.stat(target))).toEqual(result.info)
|
||||
})
|
||||
|
||||
check("reports missing paths", async ({ files, root }) => {
|
||||
const target = `${root}/missing`
|
||||
expect(await failure(files.read(target))).toBeInstanceOf(NotFound)
|
||||
expect(await failure(files.stat(target))).toBeInstanceOf(NotFound)
|
||||
expect(await failure(files.list(target))).toBeInstanceOf(NotFound)
|
||||
expect(await failure(files.move(target, `${root}/other`))).toBeInstanceOf(NotFound)
|
||||
})
|
||||
|
||||
check("reports the actual kind", async ({ files, root }) => {
|
||||
const directory = `${root}/directory`
|
||||
const file = `${root}/file`
|
||||
await Effect.runPromise(files.mkdir(directory))
|
||||
await Effect.runPromise(files.write(file, bytes("data")))
|
||||
const readError = await failure(files.read(directory))
|
||||
const listError = await failure(files.list(file))
|
||||
expect(readError).toBeInstanceOf(WrongKind)
|
||||
expect((readError as WrongKind).actual).toBe("directory")
|
||||
expect(listError).toBeInstanceOf(WrongKind)
|
||||
expect((listError as WrongKind).actual).toBe("file")
|
||||
})
|
||||
|
||||
check("write creates parent directories", async ({ files, root }) => {
|
||||
const target = `${root}/one/two/file`
|
||||
await Effect.runPromise(files.write(target, bytes("nested")))
|
||||
await Effect.runPromise(files.write(`${root}/empty`, new Uint8Array()))
|
||||
expect((await Effect.runPromise(files.stat(`${root}/one/two`))).type).toBe("directory")
|
||||
expect(await Effect.runPromise(files.stat(`${root}/empty`))).toMatchObject({ type: "file", size: 0 })
|
||||
expect(text((await Effect.runPromise(files.read(target))).bytes)).toBe("nested")
|
||||
})
|
||||
|
||||
check("reads byte ranges", async ({ files, root }) => {
|
||||
const target = `${root}/range`
|
||||
await Effect.runPromise(files.write(target, bytes("0123456789")))
|
||||
expect(text((await Effect.runPromise(files.read(target, { offset: 2, length: 4 }))).bytes)).toBe("2345")
|
||||
expect(text((await Effect.runPromise(files.read(target, { offset: 8, length: 8 }))).bytes)).toBe("89")
|
||||
expect(text((await Effect.runPromise(files.read(target, { offset: 20, length: 4 }))).bytes)).toBe("")
|
||||
})
|
||||
|
||||
check("lists immediate entries with their kinds", async ({ files, root }) => {
|
||||
await Effect.runPromise(files.write(`${root}/file name`, bytes("data")))
|
||||
await Effect.runPromise(files.mkdir(`${root}/directory`))
|
||||
await Effect.runPromise(files.write(`${root}/directory/nested`, bytes("nested")))
|
||||
const entries = await Effect.runPromise(files.list(root))
|
||||
expect(entries.toSorted((a, b) => a.name.localeCompare(b.name))).toEqual([
|
||||
{ name: "directory", type: "directory" },
|
||||
{ name: "file name", type: "file" },
|
||||
])
|
||||
})
|
||||
|
||||
check("reports symlinks without resolving them", async (harness) => {
|
||||
if (!harness.symlink) return
|
||||
await Effect.runPromise(harness.files.write(`${harness.root}/target`, bytes("target")))
|
||||
await Effect.runPromise(harness.files.write(`${harness.root}/target-dir/file`, bytes("through link")))
|
||||
await Effect.runPromise(harness.symlink("target", `${harness.root}/link`))
|
||||
await Effect.runPromise(harness.symlink("target-dir", `${harness.root}/link-dir`))
|
||||
expect((await Effect.runPromise(harness.files.stat(`${harness.root}/link`))).type).toBe("symlink")
|
||||
expect(await Effect.runPromise(harness.files.list(harness.root))).toContainEqual({
|
||||
name: "link",
|
||||
type: "symlink",
|
||||
})
|
||||
expect(text((await Effect.runPromise(harness.files.read(`${harness.root}/link-dir/file`))).bytes)).toBe(
|
||||
"through link",
|
||||
)
|
||||
const listError = await failure(harness.files.list(`${harness.root}/link-dir`))
|
||||
expect(listError).toBeInstanceOf(WrongKind)
|
||||
expect((listError as WrongKind).actual).toBe("symlink")
|
||||
})
|
||||
|
||||
check("follows symlinks when reading", async (harness) => {
|
||||
if (!harness.symlink) return
|
||||
await Effect.runPromise(harness.files.write(`${harness.root}/target`, bytes("target content")))
|
||||
await Effect.runPromise(harness.files.mkdir(`${harness.root}/directory`))
|
||||
await Effect.runPromise(harness.symlink("target", `${harness.root}/file-link`))
|
||||
await Effect.runPromise(harness.symlink("directory", `${harness.root}/directory-link`))
|
||||
await Effect.runPromise(harness.symlink("missing", `${harness.root}/dangling-link`))
|
||||
|
||||
const result = await Effect.runPromise(harness.files.read(`${harness.root}/file-link`))
|
||||
expect(text(result.bytes)).toBe("target content")
|
||||
expect(result.info.type).toBe("file")
|
||||
expect(result.info.size).toBe(bytes("target content").length)
|
||||
|
||||
const directoryError = await failure(harness.files.read(`${harness.root}/directory-link`))
|
||||
expect(directoryError).toBeInstanceOf(WrongKind)
|
||||
expect((directoryError as WrongKind).actual).toBe("directory")
|
||||
expect(await failure(harness.files.read(`${harness.root}/dangling-link`))).toBeInstanceOf(NotFound)
|
||||
})
|
||||
|
||||
check("moves files and removes trees idempotently", async ({ files, root }) => {
|
||||
const source = `${root}/source/file`
|
||||
const destination = `${root}/destination`
|
||||
await Effect.runPromise(files.write(source, bytes("moved")))
|
||||
await Effect.runPromise(files.move(source, destination))
|
||||
expect(text((await Effect.runPromise(files.read(destination))).bytes)).toBe("moved")
|
||||
expect(await failure(files.stat(source))).toBeInstanceOf(NotFound)
|
||||
await Effect.runPromise(files.remove(`${root}/source`))
|
||||
await Effect.runPromise(files.remove(`${root}/source`))
|
||||
expect(await failure(files.stat(`${root}/source`))).toBeInstanceOf(NotFound)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -37,7 +37,7 @@ describe("LocationMutation", () => {
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: "hello.txt" })
|
||||
|
||||
expect(target).toMatchObject({
|
||||
absolute: targetPath,
|
||||
canonical: yield* Effect.promise(() => fs.realpath(targetPath)),
|
||||
resource: "hello.txt",
|
||||
})
|
||||
expect(target.externalDirectory).toBeUndefined()
|
||||
@@ -50,8 +50,10 @@ describe("LocationMutation", () => {
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(directory, "src")))
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: path.join("src", "new.txt") })
|
||||
const root = yield* Effect.promise(() => fs.realpath(directory))
|
||||
|
||||
expect(target).toMatchObject({
|
||||
absolute: path.join(directory, "src", "new.txt"),
|
||||
canonical: path.join(root, "src", "new.txt"),
|
||||
resource: "src/new.txt",
|
||||
})
|
||||
}).pipe(provide(directory)),
|
||||
@@ -62,9 +64,9 @@ describe("LocationMutation", () => {
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: "../outside.txt" })
|
||||
const root = path.dirname(directory)
|
||||
const root = yield* Effect.promise(() => fs.realpath(path.dirname(directory)))
|
||||
expect(target).toMatchObject({
|
||||
absolute: path.join(root, "outside.txt"),
|
||||
canonical: path.join(root, "outside.txt"),
|
||||
resource: path.join(root, "outside.txt").replaceAll("\\", "/"),
|
||||
})
|
||||
expect(target.externalDirectory).toMatchObject({
|
||||
@@ -75,7 +77,7 @@ describe("LocationMutation", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("resolves a prospective target below an external symlink lexically", () =>
|
||||
it.live("authorizes a prospective target below an external symlink by its in-location path", () =>
|
||||
withTmp((directory) => {
|
||||
const outside = `${directory}-outside`
|
||||
return Effect.gen(function* () {
|
||||
@@ -86,7 +88,7 @@ describe("LocationMutation", () => {
|
||||
})
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: path.join("escape", "new.txt") })
|
||||
expect(target).toMatchObject({
|
||||
absolute: path.join(directory, "escape", "new.txt"),
|
||||
canonical: path.join(yield* Effect.promise(() => fs.realpath(outside)), "new.txt"),
|
||||
resource: "escape/new.txt",
|
||||
})
|
||||
expect(target.externalDirectory).toBeUndefined()
|
||||
@@ -105,7 +107,7 @@ describe("LocationMutation", () => {
|
||||
})
|
||||
|
||||
expect(yield* (yield* LocationMutation.Service).resolve({ path: "linked/new.txt" })).toMatchObject({
|
||||
absolute: path.join(directory, "linked", "new.txt"),
|
||||
canonical: path.join(yield* Effect.promise(() => fs.realpath(directory)), "actual", "new.txt"),
|
||||
resource: "linked/new.txt",
|
||||
})
|
||||
}).pipe(provide(directory)),
|
||||
@@ -118,7 +120,7 @@ describe("LocationMutation", () => {
|
||||
const targetPath = path.join(directory, "new.txt")
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
||||
expect(target).toMatchObject({
|
||||
absolute: targetPath,
|
||||
canonical: path.join(yield* Effect.promise(() => fs.realpath(directory)), "new.txt"),
|
||||
resource: "new.txt",
|
||||
})
|
||||
expect(target.externalDirectory).toBeUndefined()
|
||||
@@ -132,9 +134,9 @@ describe("LocationMutation", () => {
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(outside, "new.txt")
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
||||
const root = outside
|
||||
const root = yield* Effect.promise(() => fs.realpath(outside))
|
||||
expect(target).toMatchObject({
|
||||
absolute: path.join(root, "new.txt"),
|
||||
canonical: path.join(root, "new.txt"),
|
||||
resource: path.join(root, "new.txt").replaceAll("\\", "/"),
|
||||
})
|
||||
expect(target.externalDirectory).toMatchObject({
|
||||
@@ -153,23 +155,24 @@ describe("LocationMutation", () => {
|
||||
const targetPath = path.join(outside, "existing.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(targetPath, "existing"))
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
||||
expect(target).toMatchObject({ absolute: targetPath })
|
||||
expect(target.externalDirectory?.directory).toBe(outside)
|
||||
const root = yield* Effect.promise(() => fs.realpath(outside))
|
||||
expect(target).toMatchObject({ canonical: path.join(root, "existing.txt") })
|
||||
expect(target.externalDirectory?.directory).toBe(root)
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("authorizes prospective external descendants at their lexical parent", () =>
|
||||
it.live("anchors prospective external descendants at their stable existing directory", () =>
|
||||
withTmp((directory) =>
|
||||
withTmp((outside) =>
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(outside, "new", "nested", "file.txt")
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
||||
const parent = path.dirname(targetPath)
|
||||
const root = yield* Effect.promise(() => fs.realpath(outside))
|
||||
expect(target.externalDirectory).toMatchObject({
|
||||
directory: parent,
|
||||
resource: path.join(parent, "*").replaceAll("\\", "/"),
|
||||
directory: root,
|
||||
resource: path.join(root, "*").replaceAll("\\", "/"),
|
||||
})
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Effect, FileSystem } from "effect"
|
||||
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
|
||||
@@ -51,53 +50,22 @@ describe("ReadToolFileSystem", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reads malformed UTF-8 lossily and still rejects null-byte binary content", () =>
|
||||
it.effect("reports binary and malformed UTF-8 content as typed errors", () =>
|
||||
Effect.gen(function* () {
|
||||
const { fs, files, directory } = yield* fixture
|
||||
const binary = path.join(directory, "archive.dat")
|
||||
const malformed = path.join(directory, "malformed.txt")
|
||||
yield* files.writeFile(binary, Uint8Array.of(0, 1, 2, 3))
|
||||
yield* files.writeFile(malformed, Uint8Array.of(0x68, 0x69, 0x80))
|
||||
const malformedContent = new Uint8Array(64 * 1024 + 1).fill(97)
|
||||
malformedContent[64 * 1024] = 0x80
|
||||
yield* files.writeFile(malformed, malformedContent)
|
||||
|
||||
const binaryError = yield* ReadToolFileSystem.read(fs, binary, "archive.dat").pipe(Effect.flip)
|
||||
const malformedResult = yield* ReadToolFileSystem.read(fs, malformed, "malformed.txt")
|
||||
const malformedError = yield* ReadToolFileSystem.read(fs, malformed, "malformed.txt").pipe(Effect.flip)
|
||||
|
||||
expect(binaryError).toBeInstanceOf(ReadToolFileSystem.BinaryFileError)
|
||||
expect(binaryError.message).toBe("Cannot read binary file: archive.dat")
|
||||
expect(malformedResult).toMatchObject({ type: "file", content: "hi\uFFFD", encoding: "utf8" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reads text despite a binary-associated extension", () =>
|
||||
Effect.gen(function* () {
|
||||
const { fs, files, directory } = yield* fixture
|
||||
const file = path.join(directory, "notes.docx")
|
||||
yield* files.writeFileString(file, "plain text")
|
||||
|
||||
const result = yield* ReadToolFileSystem.read(fs, file, "notes.docx")
|
||||
|
||||
expect(result).toMatchObject({ type: "file", content: "plain text", encoding: "utf8" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lists unresolved symlinks, including broken and escaping links", () =>
|
||||
Effect.gen(function* () {
|
||||
if (process.platform === "win32") return
|
||||
const { fs: service, files, directory } = yield* fixture
|
||||
const outside = yield* files.makeTempDirectoryScoped()
|
||||
yield* files.makeDirectory(path.join(directory, "folder"))
|
||||
yield* files.writeFileString(path.join(directory, "file.txt"), "hello")
|
||||
yield* Effect.promise(() => fs.symlink(path.join(outside, "target.txt"), path.join(directory, "escape")))
|
||||
yield* Effect.promise(() => fs.symlink(path.join(directory, "missing.txt"), path.join(directory, "broken")))
|
||||
|
||||
const result = yield* ReadToolFileSystem.list(service, directory)
|
||||
|
||||
expect(result.entries.map((entry) => ({ ...entry, path: String(entry.path) }))).toEqual([
|
||||
{ path: `folder${path.sep}`, type: "directory" },
|
||||
{ path: "broken", type: "symlink" },
|
||||
{ path: "escape", type: "symlink" },
|
||||
{ path: "file.txt", type: "file" },
|
||||
])
|
||||
expect(malformedError).toBeInstanceOf(ReadToolFileSystem.MalformedUtf8Error)
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -148,13 +148,13 @@ 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 canonical = path.resolve(process.cwd(), input.path)
|
||||
const external = path.isAbsolute(input.path) && !FSUtil.contains(process.cwd(), canonical)
|
||||
const resource = external ? canonical.replaceAll("\\", "/") : path.relative(process.cwd(), canonical) || "."
|
||||
const directory = path.dirname(canonical)
|
||||
const externalResource = path.join(directory, "*").replaceAll("\\", "/")
|
||||
return Effect.succeed({
|
||||
absolute,
|
||||
canonical,
|
||||
resource,
|
||||
externalDirectory: external
|
||||
? {
|
||||
@@ -621,6 +621,10 @@ describe("ReadTool", () => {
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* Tool.Service
|
||||
for (const [error, message] of [
|
||||
[
|
||||
new ReadToolFileSystem.MalformedUtf8Error({ resource: "invalid.txt" }),
|
||||
"File is not valid UTF-8: invalid.txt",
|
||||
],
|
||||
[new ReadToolFileSystem.OffsetOutOfRangeError({ offset: 10 }), "Offset 10 is out of range"],
|
||||
[
|
||||
new ReadToolFileSystem.PathKindError({ resource: "socket", expected: "a file" }),
|
||||
@@ -717,19 +721,16 @@ describe("ReadTool", () => {
|
||||
const registry = yield* Tool.Service
|
||||
|
||||
const result = yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call-read-directory",
|
||||
name: "read",
|
||||
input: { path: "src", offset: 2, limit: 10 },
|
||||
},
|
||||
})
|
||||
expect(result).toMatchObject({
|
||||
status: "completed",
|
||||
output: { entries: listResult.entries, truncated: true, next: 4 },
|
||||
})
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call-read-directory",
|
||||
name: "read",
|
||||
input: { path: "src", offset: 2, limit: 10 },
|
||||
},
|
||||
})
|
||||
expect(result).toMatchObject({ status: "completed", output: { entries: listResult.entries, truncated: true, next: 4 } })
|
||||
if (result.status !== "completed") return
|
||||
expect(result.content).toEqual([
|
||||
{
|
||||
|
||||
@@ -90,7 +90,13 @@ const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) =
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Tool.node, Tool.node, LocationMutation.node, FileMutation.node, writeToolNode]),
|
||||
LayerNode.group([
|
||||
Tool.node,
|
||||
Tool.node,
|
||||
LocationMutation.node,
|
||||
FileMutation.node,
|
||||
writeToolNode,
|
||||
]),
|
||||
[
|
||||
[FSUtil.node, filesystem],
|
||||
[Location.node, activeLocation],
|
||||
@@ -224,10 +230,7 @@ describe("WriteTool", () => {
|
||||
const deduplicated = path.join(tmp.path, "deduplicated.txt")
|
||||
formatFile = (target) =>
|
||||
Effect.promise(async () => {
|
||||
await fs.writeFile(
|
||||
target,
|
||||
`\uFEFF\uFEFF\uFEFF${(await fs.readFile(target, "utf8")).replace(/^\uFEFF+/, "")}`,
|
||||
)
|
||||
await fs.writeFile(target, `\uFEFF\uFEFF\uFEFF${(await fs.readFile(target, "utf8")).replace(/^\uFEFF+/, "")}`)
|
||||
return true
|
||||
})
|
||||
return Effect.promise(() =>
|
||||
@@ -320,22 +323,24 @@ describe("WriteTool", () => {
|
||||
).pipe(
|
||||
Effect.andThen((settled) =>
|
||||
Effect.gen(function* () {
|
||||
const absoluteTarget = target
|
||||
const canonicalTarget = path.join(yield* Effect.promise(() => fs.realpath(outside.path)), "external.txt")
|
||||
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
|
||||
expect(assertions[0]).toMatchObject({
|
||||
resources: [path.join(outside.path, "*").replaceAll("\\", "/")],
|
||||
resources: [
|
||||
path.join(yield* Effect.promise(() => fs.realpath(outside.path)), "*").replaceAll("\\", "/"),
|
||||
],
|
||||
})
|
||||
expect(assertions[1]).toMatchObject({ resources: [absoluteTarget.replaceAll("\\", "/")], save: ["*"] })
|
||||
expect(assertions[1]).toMatchObject({ resources: [canonicalTarget.replaceAll("\\", "/")], save: ["*"] })
|
||||
expect(settled).toMatchObject({
|
||||
status: "completed",
|
||||
output: {
|
||||
target: absoluteTarget,
|
||||
resource: absoluteTarget.replaceAll("\\", "/"),
|
||||
target: canonicalTarget,
|
||||
resource: canonicalTarget.replaceAll("\\", "/"),
|
||||
existed: false,
|
||||
},
|
||||
})
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("external")
|
||||
expect(writes).toEqual([absoluteTarget])
|
||||
expect(writes).toEqual([canonicalTarget])
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -363,10 +368,12 @@ describe("WriteTool", () => {
|
||||
),
|
||||
Effect.andThen(
|
||||
Effect.gen(function* () {
|
||||
const canonicalRepo = yield* Effect.promise(() => fs.realpath(repo))
|
||||
const canonicalNested = yield* Effect.promise(() => fs.realpath(nested))
|
||||
expect(assertions[0]).toMatchObject({
|
||||
action: "external_directory",
|
||||
resources: [path.join(nested, "*").replaceAll("\\", "/")],
|
||||
save: [path.join(repo, "*").replaceAll("\\", "/")],
|
||||
resources: [path.join(canonicalNested, "*").replaceAll("\\", "/")],
|
||||
save: [path.join(canonicalRepo, "*").replaceAll("\\", "/")],
|
||||
})
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { isSafeExternalUrl } from "./external-url"
|
||||
|
||||
describe("external URL policy", () => {
|
||||
test.each(["https://opencode.ai", "http://127.0.0.1:4096", "mailto:hello@opencode.ai"])("allows %s", (url) =>
|
||||
expect(isSafeExternalUrl(url)).toBe(true),
|
||||
)
|
||||
|
||||
test.each(["file:///tmp/test", "javascript:alert(1)", "data:text/html,test", "vscode://file/tmp/test", "nope"])(
|
||||
"rejects %s",
|
||||
(url) => expect(isSafeExternalUrl(url)).toBe(false),
|
||||
)
|
||||
|
||||
test("rejects non-string values", () => {
|
||||
expect(isSafeExternalUrl(null)).toBe(false)
|
||||
expect(isSafeExternalUrl({ toString: () => "https://opencode.ai" })).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +0,0 @@
|
||||
const protocols = new Set(["http:", "https:", "mailto:"])
|
||||
|
||||
export function isSafeExternalUrl(value: unknown) {
|
||||
if (typeof value !== "string" || !URL.canParse(value)) return false
|
||||
return protocols.has(new URL(value).protocol)
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
|
||||
import type { FatalRendererError, ServerReadyData, TitlebarTheme } from "../preload/types"
|
||||
import { runDesktopMenuAction } from "./desktop-menu-actions"
|
||||
import { setForceFocus } from "./debug"
|
||||
import { isSafeExternalUrl } from "./external-url"
|
||||
import { assertAttachmentBudget, createPickedFileAuthorizations } from "./attachment-picker"
|
||||
import { getStore, removeStoreFileIfEmpty } from "./store"
|
||||
import { getPinchZoomEnabled, getWindowID, setPinchZoomEnabled, setTitlebar, updateTitlebar } from "./windows"
|
||||
@@ -179,7 +178,6 @@ export function registerIpcHandlers(deps: Deps) {
|
||||
)
|
||||
|
||||
ipcMain.on("open-link", (_event: IpcMainEvent, url: string) => {
|
||||
if (!isSafeExternalUrl(url)) return
|
||||
void shell.openExternal(url)
|
||||
})
|
||||
|
||||
|
||||
@@ -220,11 +220,6 @@ export function createMainWindow(id: string = randomUUID()) {
|
||||
state.manage(win)
|
||||
registerWindow(win, id)
|
||||
wireFullscreen(win)
|
||||
win.webContents.on("will-frame-navigate", (event) => {
|
||||
if (isRendererUrl(event.url)) return
|
||||
event.preventDefault()
|
||||
})
|
||||
win.webContents.setWindowOpenHandler(() => ({ action: "deny" }))
|
||||
loadWindow(win, "index.html")
|
||||
wireZoom(win)
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ export const Info = Schema.Struct({
|
||||
id,
|
||||
name: Name.make(id),
|
||||
request: { settings: {}, headers: {}, body: {} },
|
||||
mode: "primary",
|
||||
mode: "all",
|
||||
hidden: false,
|
||||
permissions: [
|
||||
{ action: "*", resource: "*", effect: "allow" },
|
||||
|
||||
@@ -15,7 +15,6 @@ import type {
|
||||
ModelInfo,
|
||||
PermissionSavedInfo,
|
||||
PermissionRequest,
|
||||
PermissionReplyInput,
|
||||
Project,
|
||||
ProviderInfo,
|
||||
ReferenceInfo,
|
||||
@@ -32,7 +31,6 @@ import type {
|
||||
OpenCodeEvent,
|
||||
WebSearchProvider,
|
||||
} from "@opencode-ai/client"
|
||||
import { isPermissionNotFoundError } from "@opencode-ai/client"
|
||||
import type { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { createSimpleContext } from "./helper"
|
||||
@@ -178,17 +176,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
)
|
||||
}
|
||||
|
||||
function removePermission(sessionID: string, requestID: string) {
|
||||
const requests = store.session.permission[sessionID]
|
||||
if (!requests?.some((request) => request.id === requestID)) return
|
||||
setStore(
|
||||
"session",
|
||||
"permission",
|
||||
sessionID,
|
||||
requests.filter((request) => request.id !== requestID),
|
||||
)
|
||||
}
|
||||
|
||||
const message = {
|
||||
update(sessionID: string, fn: (messages: SessionMessageInfo[], index: Map<string, number>) => void) {
|
||||
setStore(
|
||||
@@ -853,7 +840,14 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
])
|
||||
break
|
||||
case "permission.replied":
|
||||
removePermission(event.data.sessionID, event.data.requestID)
|
||||
setStore(
|
||||
"session",
|
||||
"permission",
|
||||
event.data.sessionID,
|
||||
(store.session.permission[event.data.sessionID] ?? []).filter(
|
||||
(request) => request.id !== event.data.requestID,
|
||||
),
|
||||
)
|
||||
break
|
||||
case "form.created":
|
||||
if (store.session.form[event.data.form.sessionID]?.some((form) => form.id === event.data.form.id)) break
|
||||
@@ -1042,12 +1036,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
invalidate(sessionID: string) {
|
||||
sync.invalidate(`session.permission:${sessionID}`)
|
||||
},
|
||||
async reply(input: PermissionReplyInput) {
|
||||
await client.api.permission.reply(input).catch((error: unknown) => {
|
||||
if (!isPermissionNotFoundError(error)) throw error
|
||||
})
|
||||
removePermission(input.sessionID, input.requestID)
|
||||
},
|
||||
},
|
||||
form: {
|
||||
list(sessionID: string, ref?: LocationRef) {
|
||||
|
||||
@@ -227,7 +227,7 @@ export function Session() {
|
||||
permissions().forEach((request) => {
|
||||
if (autoApproved.has(request.id)) return
|
||||
autoApproved.add(request.id)
|
||||
void data.session.permission
|
||||
void client.api.permission
|
||||
.reply({
|
||||
sessionID: request.sessionID,
|
||||
reply: "once",
|
||||
|
||||
@@ -3,7 +3,8 @@ import { createMemo, For, Match, Show, Switch } from "solid-js"
|
||||
import { Portal, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
|
||||
import type { TextareaRenderable } from "@opentui/core"
|
||||
import { useTheme, useThemes } from "../../context/theme"
|
||||
import type { PermissionReply, PermissionRequest } from "@opencode-ai/client"
|
||||
import type { PermissionRequest } from "@opencode-ai/client"
|
||||
import { useClient } from "../../context/client"
|
||||
import { SplitBorder } from "../../ui/border"
|
||||
import { useData } from "../../context/data"
|
||||
import { filetype } from "../../util/filetype"
|
||||
@@ -14,7 +15,6 @@ import { Keymap } from "../../context/keymap"
|
||||
import { usePathFormatter } from "../../context/path-format"
|
||||
import { SimulationSemantics } from "../../simulation/semantics"
|
||||
import { PatchDiff } from "../../component/patch-diff"
|
||||
import { useToast } from "../../ui/toast"
|
||||
|
||||
type PermissionStage = "permission" | "always" | "reject"
|
||||
|
||||
@@ -110,8 +110,8 @@ function EditBody(props: { file?: string; diff?: string; patch?: string }) {
|
||||
}
|
||||
|
||||
export function PermissionPrompt(props: { request: PermissionRequest; directory?: string }) {
|
||||
const client = useClient()
|
||||
const data = useData()
|
||||
const toast = useToast()
|
||||
const [store, setStore] = createStore({
|
||||
stage: "permission" as PermissionStage,
|
||||
})
|
||||
@@ -132,12 +132,6 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
|
||||
|
||||
const theme = useTheme()
|
||||
|
||||
function reply(value: PermissionReply, message?: string) {
|
||||
void data.session.permission
|
||||
.reply({ sessionID: props.request.sessionID, requestID: props.request.id, reply: value, message })
|
||||
.catch((error: unknown) => toast.error(error))
|
||||
}
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={store.stage === "always"}>
|
||||
@@ -157,7 +151,11 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
|
||||
onSelect={(option) => {
|
||||
setStore("stage", "permission")
|
||||
if (option === "cancel") return
|
||||
reply("always")
|
||||
void client.api.permission.reply({
|
||||
sessionID: props.request.sessionID,
|
||||
reply: "always",
|
||||
requestID: props.request.id,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</Match>
|
||||
@@ -166,7 +164,12 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
|
||||
action={props.request.action}
|
||||
instance={props.request.id}
|
||||
onConfirm={(message) => {
|
||||
reply("reject", message || undefined)
|
||||
void client.api.permission.reply({
|
||||
sessionID: props.request.sessionID,
|
||||
reply: "reject",
|
||||
requestID: props.request.id,
|
||||
message: message || undefined,
|
||||
})
|
||||
}}
|
||||
onCancel={() => {
|
||||
setStore("stage", "permission")
|
||||
@@ -262,10 +265,18 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
|
||||
setStore("stage", "reject")
|
||||
return
|
||||
}
|
||||
reply("reject")
|
||||
void client.api.permission.reply({
|
||||
sessionID: props.request.sessionID,
|
||||
reply: "reject",
|
||||
requestID: props.request.id,
|
||||
})
|
||||
return
|
||||
}
|
||||
reply("once")
|
||||
void client.api.permission.reply({
|
||||
sessionID: props.request.sessionID,
|
||||
reply: "once",
|
||||
requestID: props.request.id,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -2067,64 +2067,6 @@ test("reconciles active session permissions when the event stream reconnects", a
|
||||
}
|
||||
})
|
||||
|
||||
test("dismisses a permission that expired before its reply", async () => {
|
||||
const events = createEventStream()
|
||||
const request = { id: "per_stale", sessionID: "ses_active", action: "read", resources: ["old.txt"] }
|
||||
let replies = 0
|
||||
const calls = createFetch((url, init) => {
|
||||
if (url.pathname === "/api/session/ses_active/permission/per_stale/reply" && init.method === "POST") {
|
||||
replies++
|
||||
return json(
|
||||
{
|
||||
_tag: "PermissionNotFoundError",
|
||||
requestID: request.id,
|
||||
message: `Permission request not found: ${request.id}`,
|
||||
},
|
||||
{ status: 404 },
|
||||
)
|
||||
}
|
||||
}, events)
|
||||
let data!: ReturnType<typeof useData>
|
||||
|
||||
function Probe() {
|
||||
data = useData()
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<TestTuiContexts>
|
||||
<ClientProvider api={createApi(calls.fetch)}>
|
||||
<ProjectProvider>
|
||||
<DataProvider>
|
||||
<Probe />
|
||||
</DataProvider>
|
||||
</ProjectProvider>
|
||||
</ClientProvider>
|
||||
</TestTuiContexts>
|
||||
))
|
||||
|
||||
try {
|
||||
emitEvent(events, {
|
||||
id: "evt_permission_asked_stale",
|
||||
created: 0,
|
||||
type: "permission.asked",
|
||||
data: request,
|
||||
})
|
||||
await wait(() => data.session.permission.list(request.sessionID)?.length === 1)
|
||||
|
||||
await data.session.permission.reply({
|
||||
sessionID: request.sessionID,
|
||||
requestID: request.id,
|
||||
reply: "once",
|
||||
})
|
||||
|
||||
expect(replies).toBe(1)
|
||||
expect(data.session.permission.list(request.sessionID)).toEqual([])
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("adds, dismisses, and refreshes form requests", async () => {
|
||||
const events = createEventStream()
|
||||
const calls = createFetch((url) => {
|
||||
|
||||
@@ -46,9 +46,9 @@ An agent's `mode` controls where it can run:
|
||||
|
||||
| Mode | Behavior |
|
||||
| --- | --- |
|
||||
| `primary` | Can be selected as the main agent for a session. It cannot be launched as a subagent. This is the default for a custom agent when `mode` is omitted. |
|
||||
| `primary` | Can be selected as the main agent for a session. It cannot be launched as a subagent. |
|
||||
| `subagent` | Can run in a child session through the `subagent` tool, but cannot be selected as the main agent. |
|
||||
| `all` | Can be used either way. |
|
||||
| `all` | Can be used either way. This is the default for a custom agent when `mode` is omitted. |
|
||||
|
||||
In the TUI, press <kbd>Tab</kbd> and <kbd>Shift</kbd>+<kbd>Tab</kbd> to cycle
|
||||
through visible primary and `all` agents, or use `/agents` to choose one.
|
||||
|
||||
@@ -419,8 +419,6 @@ The V1 provider filters do not have one-to-one native V2 config fields, but thei
|
||||
|
||||
- `enabled_providers` becomes an internal deny-by-default provider policy followed by allows for the listed providers.
|
||||
- `disabled_providers` becomes internal deny policies for the listed providers.
|
||||
- `small_model` becomes the `model` selection for the built-in `title` agent. Native V2 configuration should use
|
||||
`agents.title.model` instead.
|
||||
|
||||
You may keep these fields in V1 syntax. OpenCode normalizes them without warning.
|
||||
|
||||
@@ -432,6 +430,7 @@ they are not mistaken for active configuration:
|
||||
- `logLevel`: use `OPENCODE_LOG_LEVEL` when starting OpenCode.
|
||||
- `server`: use the V2 service and explicit server options; the server API is an intentional breaking change.
|
||||
- `layout`: remove it; V1 already treated it as deprecated and always used stretch layout.
|
||||
- `small_model`: V2 selects models for internal maintenance agents without a separate top-level field.
|
||||
- Top-level `subagent_depth`: use `experimental.subagent_depth` instead.
|
||||
- `compaction.tail_turns` and `compaction.prune`: V2 uses `compaction.keep.tokens` and checkpoint-based compaction instead.
|
||||
- Agent `name` inside V1 JSON configuration.
|
||||
|
||||
Reference in New Issue
Block a user