diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index a5876ac347..450ec53f8f 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -26,9 +26,7 @@ import * as Option from "effect/Option" import * as OtelTracer from "@effect/opentelemetry/Tracer" import { zod } from "@opencode-ai/core/effect-zod" import { withStatics, type DeepMutable } from "@opencode-ai/core/schema" - -type ReferenceEntry = NonNullable[string] -type ResolvedReference = { kind: "git"; repository: string; branch?: string } | { kind: "local"; path: string } +import { Reference } from "@/reference/reference" export const Info = Schema.Struct({ name: Schema.String, @@ -303,69 +301,72 @@ export const layer = Layer.effect( item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {})) } - function referencePath(value: string) { - if (value.startsWith("~/")) return path.join(Global.Path.home, value.slice(2)) - return path.isAbsolute(value) - ? value - : path.resolve(ctx.worktree === "/" ? ctx.directory : ctx.worktree, value) - } - - function resolveReference(reference: ReferenceEntry): ResolvedReference { - if (typeof reference === "string") { - if (reference.startsWith(".") || reference.startsWith("/") || reference.startsWith("~")) { - return { kind: "local", path: referencePath(reference) } - } - return { kind: "git", repository: reference } - } - if ("path" in reference) return { kind: "local", path: referencePath(reference.path) } - return { kind: "git", repository: reference.repository, branch: reference.branch } - } - - function referencePrompt(name: string, reference: ResolvedReference) { + function referencePrompt(reference: Reference.Resolved) { if (reference.kind === "local") { return [ - PROMPT_SCOUT, - `You are Scout reference @${name}. This reference points to a local directory outside or alongside the current workspace.`, + `You are configured reference @${reference.name}, a read-only research agent for external reference material.`, `Local directory: ${reference.path}`, - `When invoked, inspect this directory as the primary reference source. Prefer repo_overview with path ${JSON.stringify(reference.path)} before broader searches. Do not edit files.`, + `Inspect this directory as the primary reference source. Prefer repo_overview with path ${JSON.stringify(reference.path)} before broader searches. Do not edit files.`, + `Return exact absolute file paths for findings whenever possible.`, + ].join("\n\n") + } + + if (reference.kind === "invalid") { + return [ + `You are configured reference @${reference.name}, but this reference is not usable yet.`, + `Configured repository: ${reference.repository}`, + `Problem: ${reference.message}`, + `Explain this configuration problem if invoked. Do not edit files or attempt fallback clones.`, ].join("\n\n") } return [ - PROMPT_SCOUT, - `You are Scout reference @${name}. This reference points to a git repository.`, + `You are configured reference @${reference.name}, a read-only research agent for external reference material.`, `Repository: ${reference.repository}`, ...(reference.branch ? [`Branch/ref: ${reference.branch}`] : []), - `When invoked, clone or refresh this repository with repo_clone, then inspect the cached repository as the primary reference source. Do not edit files.`, + `Cached directory: ${reference.path}`, + `OpenCode materializes this configured repository before use. Do not call repo_clone for this reference.`, + `Inspect the cached directory as the primary reference source. Prefer repo_overview with path ${JSON.stringify(reference.path)} before broader searches, then use Glob, Grep, and Read inside that directory. Do not edit files.`, + `Return exact absolute file paths for findings whenever possible.`, ].join("\n\n") } + function referenceDescription(reference: Reference.Resolved) { + if (reference.kind === "local") return `Scout reference for local directory ${reference.path}` + if (reference.kind === "git") return `Scout reference for repository ${reference.repository}` + return `Invalid Scout reference for repository ${reference.repository}` + } + if (Flag.OPENCODE_EXPERIMENTAL_SCOUT) { - for (const [name, reference] of Object.entries(cfg.reference ?? {})) { - if (agents[name]) continue - const resolved = resolveReference(reference) - const localPath = resolved.kind === "local" ? resolved.path : undefined - agents[name] = { - name, - description: - resolved.kind === "local" - ? `Scout reference for local directory ${resolved.path}` - : `Scout reference for repository ${resolved.repository}`, + const resolvedReferences = Reference.resolveAll({ + references: cfg.reference ?? {}, + directory: ctx.directory, + worktree: ctx.worktree, + }) + for (const resolved of resolvedReferences) { + if (agents[resolved.name]) continue + const localPath = resolved.kind === "invalid" ? undefined : resolved.path + agents[resolved.name] = { + name: resolved.name, + description: referenceDescription(resolved), permission: Permission.merge( agents.scout.permission, Permission.fromConfig( - localPath - ? { - external_directory: { - [localPath]: "allow", - [path.join(localPath, "*")]: "allow", - }, - } - : {}, + { + repo_clone: "deny", + ...(localPath + ? { + external_directory: { + [localPath]: "allow", + [path.join(localPath, "*")]: "allow", + }, + } + : {}), + }, ), ), - prompt: referencePrompt(name, resolved), - options: { reference }, + prompt: referencePrompt(resolved), + options: { reference: cfg.reference?.[resolved.name], resolved }, mode: "subagent", native: false, } diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index 76ed26d302..a955cb86dc 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -43,6 +43,7 @@ import { Format } from "@/format" import { InstanceLayer } from "@/project/instance-layer" import { Project } from "@/project/project" import { Vcs } from "@/project/vcs" +import { Reference } from "@/reference/reference" import { Workspace } from "@/control-plane/workspace" import { Worktree } from "@/worktree" import { Pty } from "@/pty" @@ -96,6 +97,7 @@ export const AppLayer = Layer.mergeAll( Format.defaultLayer, Project.defaultLayer, Vcs.defaultLayer, + Reference.defaultLayer, Workspace.defaultLayer, Worktree.appLayer, Pty.defaultLayer, diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index fb3e1bb32d..6103a9efb4 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -12,6 +12,7 @@ import { ShareNext } from "@/share/share-next" import { Effect, Layer } from "effect" import { Config } from "@/config/config" import { Service } from "./bootstrap-service" +import { Reference } from "@/reference/reference" export { Service } from "./bootstrap-service" export type { Interface } from "./bootstrap-service" @@ -29,6 +30,7 @@ export const layer = Layer.effect( const lsp = yield* LSP.Service const plugin = yield* Plugin.Service const project = yield* Project.Service + const reference = yield* Reference.Service const shareNext = yield* ShareNext.Service const snapshot = yield* Snapshot.Service const vcs = yield* Vcs.Service @@ -43,7 +45,7 @@ export const layer = Layer.effect( // Each service self-manages its own slow work via Effect.forkScoped against // its per-instance state scope. We just await materialization here. yield* Effect.forEach( - [lsp, shareNext, format, file, fileWatcher, vcs, snapshot, project], + [reference, lsp, shareNext, format, file, fileWatcher, vcs, snapshot, project], (s) => s.init().pipe(Effect.catchCause((cause) => Effect.logWarning("init failed", { cause }))), { concurrency: "unbounded", discard: true }, ).pipe(Effect.withSpan("InstanceBootstrap.init")) @@ -63,6 +65,7 @@ export const defaultLayer: Layer.Layer = layer.pipe( LSP.defaultLayer, Plugin.defaultLayer, Project.defaultLayer, + Reference.defaultLayer, ShareNext.defaultLayer, Snapshot.defaultLayer, Vcs.defaultLayer, diff --git a/packages/opencode/src/reference/reference.ts b/packages/opencode/src/reference/reference.ts new file mode 100644 index 0000000000..b62da96f27 --- /dev/null +++ b/packages/opencode/src/reference/reference.ts @@ -0,0 +1,228 @@ +import path from "path" +import { Effect, Context, Layer, Scope } from "effect" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Flag } from "@opencode-ai/core/flag/flag" +import { Global } from "@opencode-ai/core/global" +import { Config } from "@/config/config" +import { InstanceState } from "@/effect/instance-state" +import { Git } from "@/git" +import { parseRepositoryReference, repositoryCachePath, type Reference as RepositoryReference } from "@/util/repository" +import { RepositoryCache } from "./repository-cache" + +type ReferenceEntry = NonNullable[string] + +export type Resolved = + | { + name: string + kind: "local" + path: string + } + | { + name: string + kind: "git" + repository: string + reference: RepositoryReference + path: string + branch?: string + } + | { + name: string + kind: "invalid" + repository: string + message: string + } + +type State = { + references: Resolved[] + materializeAll: Effect.Effect + materializeByPath: { path: string; run: Effect.Effect }[] +} + +export interface Interface { + readonly init: () => Effect.Effect + readonly list: () => Effect.Effect + readonly get: (name: string) => Effect.Effect + readonly ensure: (target?: string) => Effect.Effect + readonly contains: (target?: string) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/Reference") {} + +export function referencePath(input: { directory: string; worktree: string; value: string }) { + if (input.value.startsWith("~/")) return path.join(Global.Path.home, input.value.slice(2)) + return path.isAbsolute(input.value) + ? input.value + : path.resolve(input.worktree === "/" ? input.directory : input.worktree, input.value) +} + +function resolveGit( + input: { name: string; repository: string } | { name: string; repository: string; branch: string | undefined }, +): Resolved { + const parsed = parseRepositoryReference(input.repository) + if (!parsed || parsed.protocol === "file:") { + return { + name: input.name, + kind: "invalid", + repository: input.repository, + message: "Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand", + } + } + return { + name: input.name, + kind: "git", + repository: input.repository, + reference: parsed, + path: repositoryCachePath(parsed), + ...("branch" in input ? { branch: input.branch } : {}), + } +} + +function branchLabel(branch: string | undefined) { + return branch ?? "default branch" +} + +function normalizedTarget(target?: string) { + if (!target) return + return process.platform === "win32" ? AppFileSystem.normalizePath(target) : target +} + +function containsReferencePath(referencePath: string, target: string) { + return AppFileSystem.contains(normalizedTarget(referencePath) ?? referencePath, target) +} + +export function resolve(input: { name: string; reference: ReferenceEntry; directory: string; worktree: string }): Resolved { + if (typeof input.reference === "string") { + if (input.reference.startsWith(".") || input.reference.startsWith("/") || input.reference.startsWith("~")) { + return { name: input.name, kind: "local", path: referencePath({ ...input, value: input.reference }) } + } + return resolveGit({ name: input.name, repository: input.reference }) + } + + if ("path" in input.reference) { + return { name: input.name, kind: "local", path: referencePath({ ...input, value: input.reference.path }) } + } + + return resolveGit({ name: input.name, repository: input.reference.repository, branch: input.reference.branch }) +} + +export function resolveAll(input: { + references: NonNullable + directory: string + worktree: string +}) { + const seen = new Map() + return Object.entries(input.references).map(([name, reference]) => { + const resolved = resolve({ name, reference, directory: input.directory, worktree: input.worktree }) + if (resolved.kind !== "git") return resolved + + const existing = seen.get(resolved.path) + if (!existing) { + seen.set(resolved.path, { name, branch: resolved.branch }) + return resolved + } + if (existing.branch === resolved.branch) return resolved + + return { + name, + kind: "invalid" as const, + repository: resolved.repository, + message: `Reference conflicts with @${existing.name}: both use ${resolved.path}, but @${existing.name} requests ${branchLabel(existing.branch)} and @${name} requests ${branchLabel(resolved.branch)}`, + } + }) +} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const config = yield* Config.Service + const fs = yield* AppFileSystem.Service + const git = yield* Git.Service + const scope = yield* Scope.Scope + + const state = yield* InstanceState.make( + Effect.fn("Reference.state")(function* (ctx) { + const cfg = yield* config.get() + const references = resolveAll({ references: cfg.reference ?? {}, directory: ctx.directory, worktree: ctx.worktree }) + const seenPath = new Set() + const gitReferences = references.filter((reference): reference is Extract => { + if (reference.kind !== "git") return false + if (seenPath.has(reference.path)) return false + seenPath.add(reference.path) + return true + }) + const materializeByPath = yield* Effect.forEach( + gitReferences, + Effect.fnUntraced(function* (reference) { + const run = yield* Effect.cached( + RepositoryCache.ensure( + { reference: reference.reference, branch: reference.branch, refresh: true }, + { fs, git }, + ).pipe( + Effect.asVoid, + Effect.catchCause((cause) => + Effect.logWarning("failed to materialize reference repository", { name: reference.name, cause }), + ), + ), + ) + return { path: reference.path, run } + }), + { concurrency: "unbounded" }, + ) + + const materializeAll = yield* Effect.cached( + Flag.OPENCODE_EXPERIMENTAL_SCOUT + ? Effect.gen(function* () { + yield* Effect.forEach( + materializeByPath, + Effect.fnUntraced(function* (item) { + yield* item.run + }), + { concurrency: 4, discard: true }, + ) + }) + : Effect.void, + ) + + return { references, materializeAll, materializeByPath } + }), + ) + + return Service.of({ + init: Effect.fn("Reference.init")(function* () { + if (!Flag.OPENCODE_EXPERIMENTAL_SCOUT) return + yield* InstanceState.useEffect(state, (s) => s.materializeAll).pipe(Effect.forkIn(scope), Effect.asVoid) + }), + list: Effect.fn("Reference.list")(function* () { + return yield* InstanceState.use(state, (s) => s.references) + }), + get: Effect.fn("Reference.get")(function* (name: string) { + return yield* InstanceState.use(state, (s) => s.references.find((reference) => reference.name === name)) + }), + ensure: Effect.fn("Reference.ensure")(function* (target?: string) { + if (!Flag.OPENCODE_EXPERIMENTAL_SCOUT) return + const full = normalizedTarget(target) + if (!full) return yield* InstanceState.useEffect(state, (s) => s.materializeAll) + return yield* InstanceState.useEffect( + state, + (s) => s.materializeByPath.find((item) => containsReferencePath(item.path, full))?.run ?? Effect.void, + ) + }), + contains: Effect.fn("Reference.contains")(function* (target?: string) { + if (!Flag.OPENCODE_EXPERIMENTAL_SCOUT) return false + const full = normalizedTarget(target) + if (!full) return false + return yield* InstanceState.use(state, (s) => + s.references.some((reference) => reference.kind === "git" && containsReferencePath(reference.path, full)), + ) + }), + }) + }), +) + +export const defaultLayer = layer.pipe( + Layer.provide(Config.defaultLayer), + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(Git.defaultLayer), +) + +export * as Reference from "./reference" diff --git a/packages/opencode/src/reference/repository-cache.ts b/packages/opencode/src/reference/repository-cache.ts new file mode 100644 index 0000000000..521f64369e --- /dev/null +++ b/packages/opencode/src/reference/repository-cache.ts @@ -0,0 +1,155 @@ +import path from "path" +import { Effect } from "effect" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Flock } from "@opencode-ai/core/util/flock" +import { Git } from "@/git" +import { + repositoryCachePath, + sameRepositoryReference, + parseRepositoryReference, + validateRepositoryBranch, + type Reference as RepositoryReference, +} from "@/util/repository" + +export type Result = { + repository: string + host: string + remote: string + localPath: string + status: "cached" | "cloned" | "refreshed" + head?: string + branch?: string +} + +function statusForRepository(input: { reuse: boolean; refresh?: boolean; branchMatches?: boolean }) { + if (!input.reuse) return "cloned" as const + if (input.branchMatches === false) return "refreshed" as const + if (input.refresh) return "refreshed" as const + return "cached" as const +} + +function resetTarget(input: { + requestedBranch?: string + remoteHead: { code: number; stdout: string } + branch: { code: number; stdout: string } +}) { + if (input.requestedBranch) return `origin/${input.requestedBranch}` + if (input.remoteHead.code === 0 && input.remoteHead.stdout) { + return input.remoteHead.stdout.replace(/^refs\/remotes\//, "") + } + if (input.branch.code === 0 && input.branch.stdout) { + return `origin/${input.branch.stdout}` + } + return "HEAD" +} + +export const ensure = Effect.fn("RepositoryCache.ensure")(function* ( + input: { + reference: RepositoryReference + refresh?: boolean + branch?: string + }, + services: { + fs: AppFileSystem.Interface + git: Git.Interface + }, +) { + if (input.branch) validateRepositoryBranch(input.branch) + + const repository = input.reference.label + const remote = input.reference.remote + const localPath = repositoryCachePath(input.reference) + const cloneTarget = parseRepositoryReference(remote) ?? input.reference + + return yield* Effect.acquireUseRelease( + Effect.promise((signal) => Flock.acquire(`repo-clone:${localPath}`, { signal })), + () => + Effect.gen(function* () { + yield* services.fs.ensureDir(path.dirname(localPath)).pipe(Effect.orDie) + + const exists = yield* services.fs.existsSafe(localPath) + const hasGitDir = yield* services.fs.existsSafe(path.join(localPath, ".git")) + const origin = hasGitDir + ? yield* services.git.run(["config", "--get", "remote.origin.url"], { cwd: localPath }) + : undefined + const originReference = origin?.exitCode === 0 ? parseRepositoryReference(origin.text().trim()) : undefined + const reuse = hasGitDir && Boolean(originReference && sameRepositoryReference(originReference, cloneTarget)) + if (exists && !reuse) { + yield* services.fs.remove(localPath, { recursive: true }).pipe(Effect.orDie) + } + + const currentBranch = hasGitDir ? yield* services.git.branch(localPath) : undefined + const status = statusForRepository({ + reuse, + refresh: input.refresh, + branchMatches: input.branch ? currentBranch === input.branch : undefined, + }) + + if (status === "cloned") { + const clone = yield* services.git.run( + [ + "clone", + "--depth", + "100", + ...(input.branch ? ["--branch", input.branch] : []), + "--", + remote, + localPath, + ], + { cwd: path.dirname(localPath) }, + ) + if (clone.exitCode !== 0) { + throw new Error(clone.stderr.toString().trim() || clone.text().trim() || `Failed to clone ${repository}`) + } + } + + if (status === "refreshed") { + const fetch = yield* services.git.run(["fetch", "--all", "--prune"], { cwd: localPath }) + if (fetch.exitCode !== 0) { + throw new Error(fetch.stderr.toString().trim() || fetch.text().trim() || `Failed to refresh ${repository}`) + } + + if (input.branch) { + const checkout = yield* services.git.run(["checkout", "-B", input.branch, `origin/${input.branch}`], { + cwd: localPath, + }) + if (checkout.exitCode !== 0) { + throw new Error( + checkout.stderr.toString().trim() || checkout.text().trim() || `Failed to checkout ${input.branch}`, + ) + } + } + + const remoteHead = yield* services.git.run(["symbolic-ref", "refs/remotes/origin/HEAD"], { cwd: localPath }) + const branch = yield* services.git.run(["symbolic-ref", "--quiet", "--short", "HEAD"], { cwd: localPath }) + const target = resetTarget({ + requestedBranch: input.branch, + remoteHead: { code: remoteHead.exitCode, stdout: remoteHead.text().trim() }, + branch: { code: branch.exitCode, stdout: branch.text().trim() }, + }) + + const reset = yield* services.git.run(["reset", "--hard", target], { cwd: localPath }) + if (reset.exitCode !== 0) { + throw new Error(reset.stderr.toString().trim() || reset.text().trim() || `Failed to reset ${repository}`) + } + } + + const head = yield* services.git.run(["rev-parse", "HEAD"], { cwd: localPath }) + const branch = yield* services.git.branch(localPath) + const headText = head.exitCode === 0 ? head.text().trim() : undefined + + return { + repository, + host: input.reference.host, + remote, + localPath, + status, + head: headText, + branch, + } satisfies Result + }), + (lock) => Effect.promise(() => lock.release()).pipe(Effect.ignore), + ) +}) + +export * as RepositoryCache from "./repository-cache" diff --git a/packages/opencode/src/tool/glob.ts b/packages/opencode/src/tool/glob.ts index 0c97b9cdf7..ce58331ea3 100644 --- a/packages/opencode/src/tool/glob.ts +++ b/packages/opencode/src/tool/glob.ts @@ -7,6 +7,7 @@ import { Ripgrep } from "../file/ripgrep" import { assertExternalDirectoryEffect } from "./external-directory" import DESCRIPTION from "./glob.txt" import * as Tool from "./tool" +import { Reference } from "@/reference/reference" export const Parameters = Schema.Struct({ pattern: Schema.String.annotate({ description: "The glob pattern to match files against" }), @@ -20,6 +21,7 @@ export const GlobTool = Tool.define( Effect.gen(function* () { const rg = yield* Ripgrep.Service const fs = yield* AppFileSystem.Service + const reference = yield* Reference.Service return { description: DESCRIPTION, @@ -39,11 +41,15 @@ export const GlobTool = Tool.define( let search = params.path ?? ins.directory search = path.isAbsolute(search) ? search : path.resolve(ins.directory, search) + yield* reference.ensure(search) const info = yield* fs.stat(search).pipe(Effect.catch(() => Effect.succeed(undefined))) if (info?.type === "File") { throw new Error(`glob path must be a directory: ${search}`) } - yield* assertExternalDirectoryEffect(ctx, search, { kind: "directory" }) + yield* assertExternalDirectoryEffect(ctx, search, { + bypass: yield* reference.contains(search), + kind: "directory", + }) const limit = 100 let truncated = false diff --git a/packages/opencode/src/tool/grep.ts b/packages/opencode/src/tool/grep.ts index fb3e70cad2..4e89198dff 100644 --- a/packages/opencode/src/tool/grep.ts +++ b/packages/opencode/src/tool/grep.ts @@ -7,6 +7,7 @@ import { Ripgrep } from "../file/ripgrep" import { assertExternalDirectoryEffect } from "./external-directory" import DESCRIPTION from "./grep.txt" import * as Tool from "./tool" +import { Reference } from "@/reference/reference" const MAX_LINE_LENGTH = 2000 @@ -25,6 +26,7 @@ export const GrepTool = Tool.define( Effect.gen(function* () { const fs = yield* AppFileSystem.Service const rg = yield* Ripgrep.Service + const reference = yield* Reference.Service return { description: DESCRIPTION, @@ -57,10 +59,12 @@ export const GrepTool = Tool.define( ? (params.path ?? ins.directory) : path.join(ins.directory, params.path ?? "."), ) + yield* reference.ensure(search) const info = yield* fs.stat(search).pipe(Effect.catch(() => Effect.succeed(undefined))) const cwd = info?.type === "Directory" ? search : path.dirname(search) const file = info?.type === "Directory" ? undefined : [path.relative(cwd, search)] yield* assertExternalDirectoryEffect(ctx, search, { + bypass: yield* reference.contains(search), kind: info?.type === "Directory" ? "directory" : "file", }) diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index 7ade166c5f..ad3c33e742 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -11,6 +11,7 @@ import { InstanceState } from "@/effect/instance-state" import { assertExternalDirectoryEffect } from "./external-directory" import { Instruction } from "../session/instruction" import { isPdfAttachment, sniffAttachmentMime } from "@/util/media" +import { Reference } from "@/reference/reference" const DEFAULT_READ_LIMIT = 2000 const MAX_LINE_LENGTH = 2000 @@ -41,6 +42,7 @@ export const ReadTool = Tool.define( const fs = yield* AppFileSystem.Service const instruction = yield* Instruction.Service const lsp = yield* LSP.Service + const reference = yield* Reference.Service const scope = yield* Scope.Scope const miss = Effect.fn("ReadTool.miss")(function* (filepath: string) { @@ -162,6 +164,7 @@ export const ReadTool = Tool.define( if (process.platform === "win32") { filepath = AppFileSystem.normalizePath(filepath) } + yield* reference.ensure(filepath) const title = path.relative(instance.worktree, filepath) const stat = yield* fs.stat(filepath).pipe( @@ -172,7 +175,7 @@ export const ReadTool = Tool.define( ) yield* assertExternalDirectoryEffect(ctx, filepath, { - bypass: Boolean(ctx.extra?.["bypassCwdCheck"]), + bypass: Boolean(ctx.extra?.["bypassCwdCheck"]) || (yield* reference.contains(filepath)), kind: stat?.type === "Directory" ? "directory" : "file", }) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 2a5a64033d..68251c342c 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -50,6 +50,7 @@ import { Agent } from "../agent/agent" import { Git } from "@/git" import { Skill } from "../skill" import { Permission } from "@/permission" +import { Reference } from "@/reference/reference" const log = Log.create({ service: "tool.registry" }) @@ -91,6 +92,7 @@ export const layer: Layer.Layer< | Session.Service | Provider.Service | Git.Service + | Reference.Service | LSP.Service | Instruction.Service | AppFileSystem.Service @@ -361,6 +363,7 @@ export const defaultLayer = Layer.suspend(() => Layer.provide(Session.defaultLayer), Layer.provide(Provider.defaultLayer), Layer.provide(Git.defaultLayer), + Layer.provide(Reference.defaultLayer), Layer.provide(LSP.defaultLayer), Layer.provide(Instruction.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), diff --git a/packages/opencode/src/tool/repo_clone.ts b/packages/opencode/src/tool/repo_clone.ts index 969a3e66dd..2b5e41844e 100644 --- a/packages/opencode/src/tool/repo_clone.ts +++ b/packages/opencode/src/tool/repo_clone.ts @@ -1,11 +1,10 @@ -import path from "path" import { Effect, Schema } from "effect" import { AppFileSystem } from "@opencode-ai/core/filesystem" -import { Flock } from "@opencode-ai/core/util/flock" import { Git } from "@/git" import DESCRIPTION from "./repo_clone.txt" import * as Tool from "./tool" -import { parseRepositoryReference, repositoryCachePath, sameRepositoryReference } from "@/util/repository" +import { parseRemoteRepositoryReference, repositoryCachePath, validateRepositoryBranch } from "@/util/repository" +import { RepositoryCache } from "@/reference/repository-cache" export const Parameters = Schema.Struct({ repository: Schema.String.annotate({ @@ -29,36 +28,6 @@ type Metadata = { branch?: string } -function statusForRepository(input: { reuse: boolean; refresh?: boolean; branchMatches?: boolean }) { - if (!input.reuse) return "cloned" as const - if (input.branchMatches === false) return "refreshed" as const - if (input.refresh) return "refreshed" as const - return "cached" as const -} - -function resetTarget(input: { - requestedBranch?: string - remoteHead: { code: number; stdout: string } - branch: { code: number; stdout: string } -}) { - if (input.requestedBranch) return `origin/${input.requestedBranch}` - if (input.remoteHead.code === 0 && input.remoteHead.stdout) { - return input.remoteHead.stdout.replace(/^refs\/remotes\//, "") - } - if (input.branch.code === 0 && input.branch.stdout) { - return `origin/${input.branch.stdout}` - } - return "HEAD" -} - -function validateBranch(branch: string) { - if (!/^[A-Za-z0-9/_.-]+$/.test(branch) || branch.startsWith("-") || branch.includes("..")) { - throw new Error( - "Branch must contain only alphanumeric characters, /, _, ., and -, and cannot start with - or contain ..", - ) - } -} - export const RepoCloneTool = Tool.define( "repo_clone", Effect.gen(function* () { @@ -70,16 +39,12 @@ export const RepoCloneTool = Tool.define, ctx: Tool.Context) => Effect.gen(function* () { - const reference = parseRepositoryReference(params.repository) - if (!reference) - throw new Error("Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand") - if (reference.protocol === "file:") throw new Error("Local file repositories are not supported") - if (params.branch) validateBranch(params.branch) + const reference = parseRemoteRepositoryReference(params.repository) + if (params.branch) validateRepositoryBranch(params.branch) const repository = reference.label const remote = reference.remote const localPath = repositoryCachePath(reference) - const cloneTarget = parseRepositoryReference(remote) ?? reference yield* ctx.ask({ permission: "repo_clone", @@ -94,115 +59,21 @@ export const RepoCloneTool = Tool.define Flock.acquire(`repo-clone:${localPath}`, { signal })), - () => - Effect.gen(function* () { - yield* fs.ensureDir(path.dirname(localPath)).pipe(Effect.orDie) - - const exists = yield* fs.existsSafe(localPath) - const hasGitDir = yield* fs.existsSafe(path.join(localPath, ".git")) - const origin = hasGitDir - ? yield* git.run(["config", "--get", "remote.origin.url"], { cwd: localPath }) - : undefined - const originReference = - origin?.exitCode === 0 ? parseRepositoryReference(origin.text().trim()) : undefined - const reuse = - hasGitDir && Boolean(originReference && sameRepositoryReference(originReference, cloneTarget)) - if (exists && !reuse) { - yield* fs.remove(localPath, { recursive: true }).pipe(Effect.orDie) - } - - const currentBranch = hasGitDir ? yield* git.branch(localPath) : undefined - const status = statusForRepository({ - reuse, - refresh: params.refresh, - branchMatches: params.branch ? currentBranch === params.branch : undefined, - }) - - if (status === "cloned") { - const clone = yield* git.run( - [ - "clone", - "--depth", - "100", - ...(params.branch ? ["--branch", params.branch] : []), - "--", - remote, - localPath, - ], - { cwd: path.dirname(localPath) }, - ) - if (clone.exitCode !== 0) { - throw new Error( - clone.stderr.toString().trim() || clone.text().trim() || `Failed to clone ${repository}`, - ) - } - } - - if (status === "refreshed") { - const fetch = yield* git.run(["fetch", "--all", "--prune"], { cwd: localPath }) - if (fetch.exitCode !== 0) { - throw new Error( - fetch.stderr.toString().trim() || fetch.text().trim() || `Failed to refresh ${repository}`, - ) - } - - if (params.branch) { - const checkout = yield* git.run(["checkout", "-B", params.branch, `origin/${params.branch}`], { - cwd: localPath, - }) - if (checkout.exitCode !== 0) { - throw new Error( - checkout.stderr.toString().trim() || - checkout.text().trim() || - `Failed to checkout ${params.branch}`, - ) - } - } - - const remoteHead = yield* git.run(["symbolic-ref", "refs/remotes/origin/HEAD"], { cwd: localPath }) - const branch = yield* git.run(["symbolic-ref", "--quiet", "--short", "HEAD"], { cwd: localPath }) - const target = resetTarget({ - requestedBranch: params.branch, - remoteHead: { code: remoteHead.exitCode, stdout: remoteHead.text().trim() }, - branch: { code: branch.exitCode, stdout: branch.text().trim() }, - }) - - const reset = yield* git.run(["reset", "--hard", target], { cwd: localPath }) - if (reset.exitCode !== 0) { - throw new Error( - reset.stderr.toString().trim() || reset.text().trim() || `Failed to reset ${repository}`, - ) - } - } - - const head = yield* git.run(["rev-parse", "HEAD"], { cwd: localPath }) - const branch = yield* git.branch(localPath) - const headText = head.exitCode === 0 ? head.text().trim() : undefined - - return { - title: repository, - metadata: { - repository, - host: reference.host, - remote, - localPath, - status, - head: headText, - branch, - }, - output: [ - `Repository ready: ${repository}`, - `Status: ${status}`, - `Local path: ${localPath}`, - ...(branch ? [`Branch: ${branch}`] : []), - ...(headText ? [`HEAD: ${headText}`] : []), - ].join("\n"), - } - }), - (lock) => Effect.promise(() => lock.release()).pipe(Effect.ignore), + const result = yield* RepositoryCache.ensure( + { reference, refresh: params.refresh, branch: params.branch }, + { fs, git }, ) + return { + title: repository, + metadata: result, + output: [ + `Repository ready: ${repository}`, + `Status: ${result.status}`, + `Local path: ${localPath}`, + ...(result.branch ? [`Branch: ${result.branch}`] : []), + ...(result.head ? [`HEAD: ${result.head}`] : []), + ].join("\n"), + } }).pipe(Effect.orDie), } satisfies Tool.DefWithoutID }), diff --git a/packages/opencode/src/util/repository.ts b/packages/opencode/src/util/repository.ts index c7298494f5..71c001255f 100644 --- a/packages/opencode/src/util/repository.ts +++ b/packages/opencode/src/util/repository.ts @@ -125,6 +125,21 @@ export function parseRepositoryReference(input: string) { } } +export function parseRemoteRepositoryReference(input: string) { + const reference = parseRepositoryReference(input) + if (!reference) throw new Error("Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand") + if (reference.protocol === "file:") throw new Error("Local file repositories are not supported") + return reference +} + +export function validateRepositoryBranch(branch: string) { + if (!/^[A-Za-z0-9/_.-]+$/.test(branch) || branch.startsWith("-") || branch.includes("..")) { + throw new Error( + "Branch must contain only alphanumeric characters, /, _, ., and -, and cannot start with - or contain ..", + ) + } +} + export function parseGitHubRemote(input: string) { const cleaned = normalize(input) if (!cleaned.includes("://") && !cleaned.match(/^(?:[^@/\s]+@)?github\.com:/)) return null diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index 7f29860cfe..d2d571bbbe 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -147,6 +147,10 @@ test("reference config creates scout-backed subagents", async () => { config: { reference: { effect: "github.com/effect/effect-smol", + effectDev: { + repository: "https://github.com/effect/effect-smol", + branch: "dev", + }, effectFull: { repository: "Effect-TS/effect", branch: "main", @@ -162,6 +166,7 @@ test("reference config creates scout-backed subagents", async () => { directory: tmp.path, fn: async () => { const effect = await load(tmp.path, (svc) => svc.get("effect")) + const effectDev = await load(tmp.path, (svc) => svc.get("effectDev")) const effectFull = await load(tmp.path, (svc) => svc.get("effectFull")) const local = await load(tmp.path, (svc) => svc.get("localdocs")) const localFull = await load(tmp.path, (svc) => svc.get("localdocsFull")) @@ -169,13 +174,19 @@ test("reference config creates scout-backed subagents", async () => { expect(effect).toBeDefined() expect(effect?.mode).toBe("subagent") expect(effect?.prompt).toContain("Repository: github.com/effect/effect-smol") - expect(evalPerm(effect, "repo_clone")).toBe("allow") + expect(effect?.prompt).toContain(`Cached directory: ${path.join(Global.Path.repos, "github.com", "effect", "effect-smol")}`) + expect(effect?.prompt).toContain("Do not call repo_clone") + expect(evalPerm(effect, "repo_clone")).toBe("deny") + + expect(effectDev).toBeDefined() + expect(effectDev?.prompt).toContain("Problem: Reference conflicts with @effect") + expect(effectDev?.prompt).not.toContain("Cached directory:") expect(effectFull).toBeDefined() expect(effectFull?.mode).toBe("subagent") expect(effectFull?.prompt).toContain("Repository: Effect-TS/effect") expect(effectFull?.prompt).toContain("Branch/ref: main") - expect(evalPerm(effectFull, "repo_clone")).toBe("allow") + expect(evalPerm(effectFull, "repo_clone")).toBe("deny") expect(local).toBeDefined() expect(local?.mode).toBe("subagent") diff --git a/packages/opencode/test/reference/reference.test.ts b/packages/opencode/test/reference/reference.test.ts new file mode 100644 index 0000000000..bd3f08e462 --- /dev/null +++ b/packages/opencode/test/reference/reference.test.ts @@ -0,0 +1,249 @@ +import { afterEach, describe, expect } from "bun:test" +import path from "path" +import { Effect, Layer } from "effect" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { Flag } from "@opencode-ai/core/flag/flag" +import { Global } from "@opencode-ai/core/global" +import { Git } from "../../src/git" +import { Reference } from "../../src/reference/reference" +import { disposeAllInstances, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +afterEach(async () => { + await disposeAllInstances() +}) + +const it = testEffect( + Layer.mergeAll( + AppFileSystem.defaultLayer, + CrossSpawnSpawner.defaultLayer, + Git.defaultLayer, + Reference.defaultLayer, + ), +) + +const experimentalScout = (self: Effect.Effect) => + Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Flag.OPENCODE_EXPERIMENTAL_SCOUT + Flag.OPENCODE_EXPERIMENTAL_SCOUT = true + return previous + }), + () => self, + (previous) => + Effect.sync(() => { + Flag.OPENCODE_EXPERIMENTAL_SCOUT = previous + }), + ) + +const githubBase = (url: string, self: Effect.Effect) => + Effect.acquireUseRelease( + Effect.sync(() => { + const previous = process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL + process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = url + return previous + }), + () => self, + (previous) => + Effect.sync(() => { + if (previous) process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = previous + else delete process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL + }), + ) + +const git = Effect.fn("ReferenceTest.git")(function* (cwd: string, args: string[]) { + return yield* Effect.promise(async () => { + const proc = Bun.spawn(["git", ...args], { + cwd, + stdout: "pipe", + stderr: "pipe", + }) + const [stdout, stderr, code] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]) + if (code !== 0) throw new Error(stderr.trim() || stdout.trim() || `git ${args.join(" ")} failed`) + return stdout.trim() + }) +}) + +const waitForContent = ( + fs: AppFileSystem.Interface, + file: string, + content: string, + attempts = 50, +): Effect.Effect => + Effect.gen(function* () { + if ((yield* fs.readFileStringSafe(file)) === content) return + if (attempts <= 0) throw new Error(`timed out waiting for ${file}`) + yield* Effect.sleep("100 millis") + yield* waitForContent(fs, file, content, attempts - 1) + }) + +describe("reference", () => { + it.live("resolves local and git references", () => + Effect.gen(function* () { + const root = path.resolve("opencode-reference-root") + const local = Reference.resolve({ + name: "docs", + reference: { path: "../docs" }, + directory: path.join(root, "packages", "app"), + worktree: root, + }) + const repo = Reference.resolve({ + name: "effect", + reference: { repository: "Effect-TS/effect", branch: "main" }, + directory: path.join(root, "packages", "app"), + worktree: root, + }) + + expect(local.kind).toBe("local") + if (local.kind === "local") expect(local.path).toBe(path.resolve(root, "../docs")) + expect(repo.kind).toBe("git") + if (repo.kind === "git") { + expect(repo.repository).toBe("Effect-TS/effect") + expect(repo.branch).toBe("main") + expect(repo.path).toBe(path.join(Global.Path.repos, "github.com", "Effect-TS", "effect")) + } + }), + ) + + it.live("marks same-cache references with different branches invalid", () => + Effect.gen(function* () { + const root = path.resolve("opencode-reference-root") + const references = Reference.resolveAll({ + directory: root, + worktree: root, + references: { + main: { repository: "owner/repo", branch: "main" }, + dev: { repository: "github.com/owner/repo", branch: "dev" }, + alsoMain: { repository: "https://github.com/owner/repo", branch: "main" }, + }, + }) + + expect(references.map((reference) => reference.kind)).toEqual(["git", "invalid", "git"]) + expect(references[1]?.kind).toBe("invalid") + if (references[1]?.kind === "invalid") { + expect(references[1].message).toContain("conflicts with @main") + expect(references[1].message).toContain("@dev requests dev") + } + }), + ) + + it.live("materializes configured git references during init", () => + experimentalScout( + provideTmpdirInstance( + (_dir) => + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const cache = path.join(Global.Path.repos, "github.com", "opencode-reference-test", "repo") + yield* fs.remove(cache, { recursive: true }).pipe(Effect.ignore) + yield* Effect.addFinalizer(() => fs.remove(cache, { recursive: true }).pipe(Effect.ignore)) + + const source = yield* tmpdirScoped({ git: true }) + const remoteRoot = yield* tmpdirScoped() + const remoteDir = path.join(remoteRoot, "opencode-reference-test") + const remoteRepo = path.join(remoteDir, "repo.git") + + yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "configured\n")) + yield* git(source, ["add", "."]) + yield* git(source, ["commit", "-m", "add readme"]) + yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie) + yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo]) + + const reference = yield* Reference.Service + yield* githubBase( + `file://${remoteRoot}/`, + Effect.gen(function* () { + yield* reference.init() + yield* waitForContent(fs, path.join(cache, "README.md"), "configured\n") + }), + ) + + expect(yield* fs.existsSafe(path.join(cache, ".git"))).toBe(true) + expect(yield* fs.readFileString(path.join(cache, "README.md"))).toBe("configured\n") + + const resolved = yield* reference.get("docs") + expect(resolved?.kind).toBe("git") + if (resolved?.kind === "git") expect(resolved.path).toBe(cache) + }), + { + config: { + reference: { + docs: "opencode-reference-test/repo", + }, + }, + }, + ), + ), + ) + + it.live("refreshes configured git references on new instance init", () => + experimentalScout( + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const cache = path.join(Global.Path.repos, "github.com", "opencode-reference-refresh", "repo") + yield* fs.remove(cache, { recursive: true }).pipe(Effect.ignore) + yield* Effect.addFinalizer(() => fs.remove(cache, { recursive: true }).pipe(Effect.ignore)) + + const source = yield* tmpdirScoped({ git: true }) + const remoteRoot = yield* tmpdirScoped() + const remoteDir = path.join(remoteRoot, "opencode-reference-refresh") + const remoteRepo = path.join(remoteDir, "repo.git") + + yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "v1\n")) + yield* git(source, ["add", "."]) + yield* git(source, ["commit", "-m", "add readme"]) + yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie) + yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo]) + + yield* githubBase( + `file://${remoteRoot}/`, + provideTmpdirInstance( + (_dir) => + Effect.gen(function* () { + const reference = yield* Reference.Service + yield* reference.init() + yield* waitForContent(fs, path.join(cache, "README.md"), "v1\n") + }), + { + config: { + reference: { + docs: "opencode-reference-refresh/repo", + }, + }, + }, + ), + ) + + const branch = yield* git(source, ["branch", "--show-current"]) + yield* git(source, ["remote", "add", "origin", remoteRepo]) + yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "v2\n")) + yield* git(source, ["add", "."]) + yield* git(source, ["commit", "-m", "update readme"]) + yield* git(source, ["push", "origin", `${branch}:${branch}`]) + + yield* githubBase( + `file://${remoteRoot}/`, + provideTmpdirInstance( + (_dir) => + Effect.gen(function* () { + const reference = yield* Reference.Service + yield* reference.init() + yield* waitForContent(fs, path.join(cache, "README.md"), "v2\n") + }), + { + config: { + reference: { + docs: "opencode-reference-refresh/repo", + }, + }, + }, + ), + ) + }), + ), + ) +}) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 821b900754..98a69fce96 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -46,6 +46,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import * as Database from "../../src/storage/db" import { Ripgrep } from "../../src/file/ripgrep" import { Format } from "../../src/format" +import { Reference } from "../../src/reference/reference" import { provideTmpdirInstance, provideTmpdirServer } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { reply, TestLLMServer } from "../lib/llm-server" @@ -181,6 +182,7 @@ function makeHttp() { Layer.provide(FetchHttpClient.layer), Layer.provide(CrossSpawnSpawner.defaultLayer), Layer.provide(Git.defaultLayer), + Layer.provide(Reference.defaultLayer), Layer.provide(Ripgrep.defaultLayer), Layer.provide(Format.defaultLayer), Layer.provideMerge(todo), diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index 727b75cfee..251a4acf3f 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -57,6 +57,7 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Ripgrep } from "../../src/file/ripgrep" import { Format } from "../../src/format" +import { Reference } from "../../src/reference/reference" void Log.init({ print: false }) @@ -131,6 +132,7 @@ function makeHttp() { Layer.provide(FetchHttpClient.layer), Layer.provide(CrossSpawnSpawner.defaultLayer), Layer.provide(Git.defaultLayer), + Layer.provide(Reference.defaultLayer), Layer.provide(Ripgrep.defaultLayer), Layer.provide(Format.defaultLayer), Layer.provideMerge(todo), diff --git a/packages/opencode/test/tool/glob.test.ts b/packages/opencode/test/tool/glob.test.ts index 9ff1f8a99a..45dc0b36a9 100644 --- a/packages/opencode/test/tool/glob.test.ts +++ b/packages/opencode/test/tool/glob.test.ts @@ -10,6 +10,7 @@ import { Truncate } from "@/tool/truncate" import { Agent } from "../../src/agent/agent" import { TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" +import { Reference } from "@/reference/reference" const it = testEffect( Layer.mergeAll( @@ -18,6 +19,7 @@ const it = testEffect( Ripgrep.defaultLayer, Truncate.defaultLayer, Agent.defaultLayer, + Reference.defaultLayer, ), ) diff --git a/packages/opencode/test/tool/grep.test.ts b/packages/opencode/test/tool/grep.test.ts index dfb74b8bed..53f5d9a19c 100644 --- a/packages/opencode/test/tool/grep.test.ts +++ b/packages/opencode/test/tool/grep.test.ts @@ -10,6 +10,7 @@ import { Agent } from "../../src/agent/agent" import { Ripgrep } from "../../src/file/ripgrep" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { testEffect } from "../lib/effect" +import { Reference } from "@/reference/reference" const it = testEffect( Layer.mergeAll( @@ -18,6 +19,7 @@ const it = testEffect( Ripgrep.defaultLayer, Truncate.defaultLayer, Agent.defaultLayer, + Reference.defaultLayer, ), ) diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index 9e2cc04895..11bb1513f3 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -4,6 +4,8 @@ import path from "path" import { Agent } from "../../src/agent/agent" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Flag } from "@opencode-ai/core/flag/flag" +import { Global } from "@opencode-ai/core/global" import { LSP } from "@/lsp/lsp" import { Permission } from "../../src/permission" import { Instance } from "../../src/project/instance" @@ -15,6 +17,7 @@ import { Tool } from "@/tool/tool" import { Filesystem } from "@/util/filesystem" import { disposeAllInstances, provideInstance, TestInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" +import { Reference } from "@/reference/reference" const FIXTURES_DIR = path.join(import.meta.dir, "fixtures") @@ -40,6 +43,7 @@ const it = testEffect( CrossSpawnSpawner.defaultLayer, Instruction.defaultLayer, LSP.defaultLayer, + Reference.defaultLayer, Truncate.defaultLayer, ), ) @@ -81,6 +85,49 @@ const fail = Effect.fn("ReadToolTest.fail")(function* ( const full = (p: string) => (process.platform === "win32" ? Filesystem.normalizePath(p) : p) const glob = (p: string) => process.platform === "win32" ? Filesystem.normalizePathPattern(p) : p.replaceAll("\\", "/") +const experimentalScout = (self: Effect.Effect) => + Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Flag.OPENCODE_EXPERIMENTAL_SCOUT + Flag.OPENCODE_EXPERIMENTAL_SCOUT = true + return previous + }), + () => self, + (previous) => + Effect.sync(() => { + Flag.OPENCODE_EXPERIMENTAL_SCOUT = previous + }), + ) +const githubBase = (url: string, self: Effect.Effect) => + Effect.acquireUseRelease( + Effect.sync(() => { + const previous = process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL + process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = url + return previous + }), + () => self, + (previous) => + Effect.sync(() => { + if (previous) process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = previous + else delete process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL + }), + ) +const git = Effect.fn("ReadToolTest.git")(function* (cwd: string, args: string[]) { + return yield* Effect.promise(async () => { + const proc = Bun.spawn(["git", ...args], { + cwd, + stdout: "pipe", + stderr: "pipe", + }) + const [stdout, stderr, code] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]) + if (code !== 0) throw new Error(stderr.trim() || stdout.trim() || `git ${args.join(" ")} failed`) + return stdout.trim() + }) +}) const put = Effect.fn("ReadToolTest.put")(function* (p: string, content: string | Buffer | Uint8Array) { const fs = yield* AppFileSystem.Service yield* fs.writeWithDirs(p, content) @@ -212,6 +259,46 @@ describe("tool.read external_directory permission", () => { expect(ext).toBeUndefined() }), ) + + it.live("does not ask for external_directory permission when reading configured references", () => + experimentalScout( + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const cache = path.join(Global.Path.repos, "github.com", "opencode-read-reference", "repo") + yield* fs.remove(cache, { recursive: true }).pipe(Effect.ignore) + yield* Effect.addFinalizer(() => fs.remove(cache, { recursive: true }).pipe(Effect.ignore)) + + const source = yield* tmpdirScoped({ git: true }) + const remoteRoot = yield* tmpdirScoped() + const remoteDir = path.join(remoteRoot, "opencode-read-reference") + const remoteRepo = path.join(remoteDir, "repo.git") + yield* put(path.join(source, "notes.md"), "reference notes") + yield* git(source, ["add", "."]) + yield* git(source, ["commit", "-m", "add notes"]) + yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie) + yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo]) + + const dir = yield* tmpdirScoped({ + git: true, + config: { + reference: { + docs: "opencode-read-reference/repo", + }, + }, + }) + + const { items, next } = asks() + const result = yield* githubBase( + `file://${remoteRoot}/`, + exec(dir, { filePath: path.join(cache, "notes.md") }, next), + ) + const ext = items.find((item) => item.permission === "external_directory") + + expect(result.output).toContain("reference notes") + expect(ext).toBeUndefined() + }), + ), + ) }) describe("tool.read env file permissions", () => { diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index e42d4d59b3..dc66c308ac 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -25,6 +25,7 @@ import { Format } from "@/format" import { Ripgrep } from "@/file/ripgrep" import * as Truncate from "@/tool/truncate" import { InstanceState } from "@/effect/instance-state" +import { Reference } from "@/reference/reference" const node = CrossSpawnSpawner.defaultLayer const originalExperimentalScout = Flag.OPENCODE_EXPERIMENTAL_SCOUT @@ -42,6 +43,7 @@ const registryLayer = ToolRegistry.layer.pipe( Layer.provide(Session.defaultLayer), Layer.provide(Provider.defaultLayer), Layer.provide(Git.defaultLayer), + Layer.provide(Reference.defaultLayer), Layer.provide(LSP.defaultLayer), Layer.provide(Instruction.defaultLayer), Layer.provide(AppFileSystem.defaultLayer),