diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index a73472ebe9..00dc18621d 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -41,5 +41,6 @@ export const migrations = ( import("./migration/20260622170816_reset_v2_session_state"), import("./migration/20260622202450_simplify_session_input"), import("./migration/20260813162312_shocking_karnak"), + import("./migration/20260820000001_add_session_directories"), ]) ).map((module) => module.default) satisfies DatabaseMigration.Migration[] diff --git a/packages/core/src/database/migration/20260820000001_add_session_directories.ts b/packages/core/src/database/migration/20260820000001_add_session_directories.ts new file mode 100644 index 0000000000..0cfc28aee4 --- /dev/null +++ b/packages/core/src/database/migration/20260820000001_add_session_directories.ts @@ -0,0 +1,11 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260820000001_add_session_directories", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(`ALTER TABLE session ADD COLUMN directories TEXT;`) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/location-mutation.ts b/packages/core/src/location-mutation.ts index 5f410b95b1..957a6fd6d1 100644 --- a/packages/core/src/location-mutation.ts +++ b/packages/core/src/location-mutation.ts @@ -117,15 +117,23 @@ const layer = Layer.effect( } }) + const isLexicallyInternal = (abs: string) => + FSUtil.contains(location.directory, abs) || + (location.directories?.some((d) => FSUtil.contains(d, abs)) ?? false) + const resolve = Effect.fn("LocationMutation.resolve")(function* (input: ResolveInput) { const relative = !path.isAbsolute(input.path) const absolute = path.resolve(location.directory, input.path) - const lexicallyInternal = FSUtil.contains(location.directory, absolute) + const lexicallyInternal = isLexicallyInternal(absolute) if (relative && !lexicallyInternal) return yield* new PathError({ path: input.path, reason: "relative_escape" }) const resolved = yield* resolvePath(absolute) if (lexicallyInternal && !FSUtil.contains(locationRoot, resolved.canonical)) { - return yield* new PathError({ path: input.path, reason: "location_escape" }) + // Multi-root: also allow canonical inside any workspace directory + const canonicalInternal = + FSUtil.contains(locationRoot, resolved.canonical) || + (location.directories?.some((d) => FSUtil.contains(d, resolved.canonical)) ?? false) + if (!canonicalInternal) return yield* new PathError({ path: input.path, reason: "location_escape" }) } const external = !lexicallyInternal diff --git a/packages/core/src/location.ts b/packages/core/src/location.ts index 8228b8599e..515e4c41b4 100644 --- a/packages/core/src/location.ts +++ b/packages/core/src/location.ts @@ -9,6 +9,7 @@ export * as Location from "./location" export { Info, Ref, response } export interface Interface extends Info { + readonly directories?: readonly string[] readonly vcs?: Project.Vcs } @@ -24,6 +25,7 @@ const layer = (ref: Ref) => const resolved = yield* project.resolve(ref.directory) return Service.of({ directory: ref.directory, + directories: ref.directories, workspaceID: ref.workspaceID, project: { id: resolved.id, directory: resolved.directory }, vcs: resolved.vcs, diff --git a/packages/core/src/session/sql.ts b/packages/core/src/session/sql.ts index 264a1d2cca..1fa7ee6aaa 100644 --- a/packages/core/src/session/sql.ts +++ b/packages/core/src/session/sql.ts @@ -31,6 +31,7 @@ export const SessionTable = sqliteTable( parent_id: text().$type(), slug: text().notNull(), directory: DatabasePath.directoryColumn().notNull(), + directories: text({ mode: "json" }).$type(), path: DatabasePath.pathColumn(), title: text().notNull(), version: text().notNull(), diff --git a/packages/core/src/system-context/builtins.ts b/packages/core/src/system-context/builtins.ts index b8b50577cc..0bf8bb04f6 100644 --- a/packages/core/src/system-context/builtins.ts +++ b/packages/core/src/system-context/builtins.ts @@ -13,9 +13,17 @@ const builtIns = Layer.effectDiscard( Effect.gen(function* () { const location = yield* Location.Service const registry = yield* SystemContextRegistry.Service + const workspaceFolders = [ + location.directory, + ...(location.directories ?? []).filter((d) => d !== location.directory), + ] + const workspaceEnv = + workspaceFolders.length > 1 + ? ` Working directory: ${location.directory}\n Workspace folders:\n${workspaceFolders.map((d) => ` - ${d}${d === location.directory ? " (primary)" : ""}`).join("\n")}` + : ` Working directory: ${location.directory}` const environment = [ "", - ` Working directory: ${location.directory}`, + workspaceEnv, ` Workspace root folder: ${location.project.directory}`, ` Is directory a git repo: ${location.vcs?.type === "git" ? "yes" : "no"}`, ` Platform: ${process.platform}`, diff --git a/packages/core/test/fixture/location.ts b/packages/core/test/fixture/location.ts index 40d8ed9dc3..f93ee4ac5c 100644 --- a/packages/core/test/fixture/location.ts +++ b/packages/core/test/fixture/location.ts @@ -7,6 +7,7 @@ import { tmpdir } from "./tmpdir" export function location(ref: Location.Ref, input: { projectDirectory?: AbsolutePath; vcs?: Project.Vcs } = {}) { return { directory: ref.directory, + directories: ref.directories, workspaceID: ref.workspaceID, project: { id: Project.ID.global, directory: input.projectDirectory ?? ref.directory }, vcs: input.vcs, diff --git a/packages/core/test/location-mutation.test.ts b/packages/core/test/location-mutation.test.ts index b09be42daa..c2b53d0a9b 100644 --- a/packages/core/test/location-mutation.test.ts +++ b/packages/core/test/location-mutation.test.ts @@ -21,6 +21,25 @@ function provide(directory: string) { ) } +function provideMultiRoot(directory: string, directories: string[]) { + return Effect.provide( + LayerNode.compile(LocationMutation.node, [ + [ + Location.node, + Layer.succeed( + Location.Service, + Location.Service.of( + location({ + directory: AbsolutePath.make(directory), + directories: directories.map((d) => AbsolutePath.make(d)), + }), + ), + ), + ], + ]), + ) +} + function withTmp(f: (directory: string) => Effect.Effect) { return Effect.acquireRelease( Effect.promise(() => tmpdir()), @@ -175,4 +194,33 @@ describe("LocationMutation", () => { path: "README.md", }) }) + + it.live("accepts an absolute path inside a secondary workspace directory without external authorization", () => + withTmp((primary) => + withTmp((secondary) => + Effect.gen(function* () { + const targetPath = path.join(secondary, "lib.ts") + yield* Effect.promise(() => fs.writeFile(targetPath, "export const x = 1")) + const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath }) + expect(target.externalDirectory).toBeUndefined() + }).pipe(provideMultiRoot(primary, [primary, secondary])), + ), + ), + ) + + it.live("rejects an absolute path outside all workspace directories", () => + withTmp((primary) => + withTmp((secondary) => + withTmp((outside) => + Effect.gen(function* () { + const targetPath = path.join(outside, "rogue.txt") + yield* Effect.promise(() => fs.writeFile(targetPath, "rogue")) + const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath }) + // Should require external authorization — externalDirectory is set + expect(target.externalDirectory).toBeDefined() + }).pipe(provideMultiRoot(primary, [primary, secondary])), + ), + ), + ), + ) }) diff --git a/packages/core/test/system-context/builtins.test.ts b/packages/core/test/system-context/builtins.test.ts index 26b9175223..3fd7be8975 100644 --- a/packages/core/test/system-context/builtins.test.ts +++ b/packages/core/test/system-context/builtins.test.ts @@ -15,6 +15,7 @@ import { testEffect } from "../lib/effect" const directory = AbsolutePath.make(FSUtil.resolve("/repo/packages/core")) const projectDirectory = AbsolutePath.make(FSUtil.resolve("/repo")) +const secondaryDirectory = AbsolutePath.make(FSUtil.resolve("/repo/packages/schema")) const instructionFile = FSUtil.resolve("/repo/AGENTS.md") const timestamp = Date.parse("2026-06-03T12:00:00.000Z") const localDate = (time: number) => new Date(time).toDateString() @@ -27,6 +28,25 @@ const locationLayer = Layer.succeed( ), ), ) +const multiRootLocationLayer = Layer.succeed( + Location.Service, + Location.Service.of( + location( + { directory, directories: [directory, secondaryDirectory] }, + { projectDirectory, vcs: { type: "git", store: AbsolutePath.make(FSUtil.resolve("/repo/.git")) } }, + ), + ), +) +// directories array with primary NOT at index 0 +const multiRootReversedLocationLayer = Layer.succeed( + Location.Service, + Location.Service.of( + location( + { directory, directories: [secondaryDirectory, directory] }, + { projectDirectory, vcs: { type: "git", store: AbsolutePath.make(FSUtil.resolve("/repo/.git")) } }, + ), + ), +) const builtInsNode = LayerNode.group([SystemContextBuiltIns.node, SystemContextRegistry.node]) const it = testEffect( AppNodeBuilder.build(builtInsNode, [ @@ -34,6 +54,18 @@ const it = testEffect( [Global.node, Global.layerWith({ config: "/global" })], ]), ) +const itMultiRoot = testEffect( + AppNodeBuilder.build(builtInsNode, [ + [Location.node, multiRootLocationLayer], + [Global.node, Global.layerWith({ config: "/global" })], + ]), +) +const itMultiRootReversed = testEffect( + AppNodeBuilder.build(builtInsNode, [ + [Location.node, multiRootReversedLocationLayer], + [Global.node, Global.layerWith({ config: "/global" })], + ]), +) const instructionFS = Layer.effect( FSUtil.Service, FSUtil.Service.pipe( @@ -126,4 +158,54 @@ describe("SystemContextBuiltIns", () => { ) }), ) + + itMultiRoot.effect("lists workspace folders when multiple directories are configured", () => + Effect.gen(function* () { + yield* TestClock.setTime(timestamp) + const context = yield* SystemContextRegistry.Service + const initialized = yield* SystemContext.initialize(yield* context.load()) + + expect(initialized.baseline).toBe( + [ + "Here is some useful information about the environment you are running in:", + "", + ` Working directory: ${directory}`, + ` Workspace folders:`, + ` - ${directory} (primary)`, + ` - ${secondaryDirectory}`, + ` Workspace root folder: ${projectDirectory}`, + " Is directory a git repo: yes", + ` Platform: ${process.platform}`, + "", + "", + `Today's date: ${localDate(timestamp)}`, + ].join("\n"), + ) + }), + ) + + itMultiRootReversed.effect("marks the correct folder as primary even when directories[0] is not the primary", () => + Effect.gen(function* () { + yield* TestClock.setTime(timestamp) + const context = yield* SystemContextRegistry.Service + const initialized = yield* SystemContext.initialize(yield* context.load()) + + expect(initialized.baseline).toBe( + [ + "Here is some useful information about the environment you are running in:", + "", + ` Working directory: ${directory}`, + ` Workspace folders:`, + ` - ${directory} (primary)`, + ` - ${secondaryDirectory}`, + ` Workspace root folder: ${projectDirectory}`, + " Is directory a git repo: yes", + ` Platform: ${process.platform}`, + "", + "", + `Today's date: ${localDate(timestamp)}`, + ].join("\n"), + ) + }), + ) }) diff --git a/packages/opencode/src/project/instance-context.ts b/packages/opencode/src/project/instance-context.ts index 18ea39e16e..0535f863d6 100644 --- a/packages/opencode/src/project/instance-context.ts +++ b/packages/opencode/src/project/instance-context.ts @@ -4,6 +4,7 @@ import type * as Project from "./project" export interface InstanceContext { directory: string + directories?: string[] worktree: string project: Project.Info } @@ -17,6 +18,7 @@ export const context = LocalContext.create("instance") */ export function containsPath(filepath: string, ctx: InstanceContext): boolean { if (FSUtil.contains(ctx.directory, filepath)) return true + if (ctx.directories?.some((d) => FSUtil.contains(d, filepath))) return true // Non-git projects set worktree to "/" which would match ANY absolute path. // Skip worktree check in this case to preserve external_directory permissions. if (ctx.worktree === "/") return false diff --git a/packages/opencode/src/project/instance-store.ts b/packages/opencode/src/project/instance-store.ts index 720549ddaf..6279c7dabc 100644 --- a/packages/opencode/src/project/instance-store.ts +++ b/packages/opencode/src/project/instance-store.ts @@ -13,6 +13,7 @@ import * as Project from "./project" export interface LoadInput { directory: string + directories?: string[] worktree?: string project?: Project.Info } @@ -48,12 +49,14 @@ const layer: Layer.Layer ({ directory: input.directory, + directories: input.directories, worktree: result.sandbox, project: result.project, })), diff --git a/packages/opencode/src/session/instruction.ts b/packages/opencode/src/session/instruction.ts index 7f593550d4..feaf46ac72 100644 --- a/packages/opencode/src/session/instruction.ts +++ b/packages/opencode/src/session/instruction.ts @@ -119,16 +119,29 @@ const layer: Layer.Layer< } } - // The first project-level match wins so we don't stack AGENTS.md/CLAUDE.md from every ancestor. + // Multi-root: run findUp from each workspace directory, dedup by resolved path. + // Primary first, then remaining in workspace order; each walks to its own boundary. if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) { + const allDirs: string[] = ctx.directories?.length + ? ctx.directories + : [ctx.directory] + // Preserve order but ensure primary is first + const ordered = allDirs[0] === ctx.directory ? allDirs : [ctx.directory, ...allDirs.filter((d) => d !== ctx.directory)] for (const file of instructionFiles) { - const matches = yield* fs - .findUp(file, ctx.directory, ctx.worktree) - .pipe(Effect.catch(() => Effect.succeed([]))) - if (matches.length > 0) { - matches.forEach((item) => paths.add(path.resolve(item))) - break + let foundAny = false + for (const dir of ordered) { + // Primary uses worktree as boundary; secondary directories use themselves + // as boundary (don't walk past the workspace root into unrelated parents). + const stop = dir === ctx.directory ? ctx.worktree : dir + const matches = yield* fs + .findUp(file, dir, stop) + .pipe(Effect.catch(() => Effect.succeed([]))) + if (matches.length > 0) { + matches.forEach((item) => paths.add(path.resolve(item))) + foundAny = true + } } + if (foundAny) break } } diff --git a/packages/opencode/test/fixture/fixture.ts b/packages/opencode/test/fixture/fixture.ts index 53e3142cdf..f8456eb580 100644 --- a/packages/opencode/test/fixture/fixture.ts +++ b/packages/opencode/test/fixture/fixture.ts @@ -166,9 +166,9 @@ export function tmpdirScoped(options?: { } export const provideInstance = - (directory: string) => + (directory: string, directories?: string[]) => (self: Effect.Effect): Effect.Effect => - InstanceStore.Service.use((store) => store.provide({ directory }, self)) + InstanceStore.Service.use((store) => store.provide({ directory, directories }, self)) export const provideInstanceEffect = (directory: string) => diff --git a/packages/opencode/test/session/instruction.test.ts b/packages/opencode/test/session/instruction.test.ts index cdbc6e66d7..48c1a11576 100644 --- a/packages/opencode/test/session/instruction.test.ts +++ b/packages/opencode/test/session/instruction.test.ts @@ -262,3 +262,45 @@ describe("Instruction.systemPaths global config", () => { }), ) }) + +describe("Instruction.systemPaths multi-root", () => { + it.live("discovers AGENTS.md in a secondary workspace directory", () => + Effect.gen(function* () { + const primary = yield* tmpWithFiles({}) + const secondary = yield* tmpWithFiles({ "AGENTS.md": "# Secondary Instructions" }) + + yield* Effect.gen(function* () { + const svc = yield* Instruction.Service + const paths = yield* svc.systemPaths() + expect(paths.has(path.join(secondary, "AGENTS.md"))).toBe(true) + }).pipe( + provideInstance(primary, [primary, secondary]), + provideInstruction({ home: primary, config: primary }), + ) + }), + ) + + it.live("does not walk past a secondary directory into unrelated parent directories", () => + Effect.gen(function* () { + // Create two independent git-initialized project directories. + // Place an AGENTS.md above the secondary's git root — it should NOT be discovered + // because the secondary's findUp stops at the secondary root itself. + const primary = yield* tmpdirScoped({ git: true }) + const secondary = yield* tmpdirScoped({ git: true }) + const fs = yield* FileSystem.FileSystem + // Put AGENTS.md above the secondary's git root + const aboveSecondary = path.dirname(secondary) + yield* fs.writeFileString(path.join(aboveSecondary, "AGENTS.md"), "# Should NOT be found") + + yield* Effect.gen(function* () { + const svc = yield* Instruction.Service + const paths = yield* svc.systemPaths() + // The AGENTS.md above the secondary's git root should NOT be found + expect(paths.has(path.join(aboveSecondary, "AGENTS.md"))).toBe(false) + }).pipe( + provideInstance(primary, [primary, secondary]), + provideInstruction({ home: "/nonexistent-global", config: "/nonexistent-global" }), + ) + }), + ) +}) diff --git a/packages/schema/src/location.ts b/packages/schema/src/location.ts index c01ce36372..461c620b89 100644 --- a/packages/schema/src/location.ts +++ b/packages/schema/src/location.ts @@ -8,6 +8,7 @@ import { WorkspaceID } from "./workspace-id" export interface Ref extends Schema.Schema.Type {} export const Ref = Schema.Struct({ directory: AbsolutePath, + directories: optional(Schema.Array(AbsolutePath)), workspaceID: optional(WorkspaceID), }).annotate({ identifier: "Location.Ref" })