Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion packages/cli/src/server-process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,11 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
),
)
const global = yield* Global.Service
if (options.mode === "service") yield* Effect.sync(() => process.chdir(global.home))
// A sandbox may deny $HOME; the service then keeps the cwd it was launched from.
if (options.mode === "service")
yield* Effect.try(() => process.chdir(global.home)).pipe(
Effect.catch((cause) => Effect.logWarning("failed to change service directory to home", { cause })),
)
return yield* Effect.scoped(
Effect.gen(function* () {
const foreground = options.mode === "default"
Expand Down
20 changes: 14 additions & 6 deletions packages/core/src/config/plugin/instruction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,16 +61,24 @@ export const Plugin = define({
yield* updates.pipe(Stream.runForEach(publish), Effect.forkScoped({ startImmediately: true }))
}

// A missing file may be mid-save and is transient; a denied file (e.g. outside a sandbox grant) stays absent.
const read = Effect.fn("ConfigInstructionPlugin.read")(function* (filepath: string) {
const content = yield* fs.readFileStringSafe(filepath)
if (content !== undefined) return new InstructionDiscovery.File({ path: filepath, content })
yield* Effect.logDebug("instruction file skipped", { path: filepath, reason: "unavailable" })
return yield* fs.readFileString(filepath).pipe(
Effect.map((content) => new InstructionDiscovery.File({ path: filepath, content })),
Effect.catchIf(
(error) => error.reason._tag === "NotFound" || FSUtil.isAccessDenied(error),
(error) =>
Effect.logDebug("instruction file skipped", { path: filepath, reason: error.reason._tag }).pipe(
Effect.as(error.reason._tag === "NotFound" ? undefined : ("denied" as const)),
),
),
)
})

const globalSource = Effect.fn("ConfigInstructionPlugin.globalSource")(function* () {
if (!discovery.global || !(yield* fs.isFile(globalFile))) return []
const file = yield* read(globalFile)
return file ? [file] : []
return file instanceof InstructionDiscovery.File ? [file] : []
})

const projectSource = Effect.fn("ConfigInstructionPlugin.projectSource")(function* () {
Expand All @@ -82,7 +90,7 @@ export const Plugin = define({
const discovered = new Set(walked.filter((file) => discovery.global || file !== globalFile))
const files = yield* Effect.forEach(discovered, read, { concurrency: "unbounded" })
if (files.some((file) => file === undefined)) return Instructions.unavailable
return files.filter((file): file is InstructionDiscovery.File => file !== undefined)
return files.filter((file) => file instanceof InstructionDiscovery.File)
})

// Relative patterns search upward from the location to the project root. Without
Expand Down Expand Up @@ -115,7 +123,7 @@ export const Plugin = define({
if (watched.has(resolved)) continue
watched.add(resolved)
const file = yield* read(resolved)
if (file) files.push(file)
if (file instanceof InstructionDiscovery.File) files.push(file)
}
}
for (const [key] of Array.from(watches)) if (!watched.has(key)) yield* FiberMap.remove(watches, key)
Expand Down
11 changes: 3 additions & 8 deletions packages/core/src/filesystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,17 +123,12 @@ const baseLayer = Layer.effect(
const root = location.workspaceID
? location.directory
: yield* fs.realPath(location.directory).pipe(
// Linux realpath succeeds on an unreadable directory, so probe readability explicitly.
Effect.tap((real) => fs.access(real, { readable: true })),
Effect.catch((cause): Effect.Effect<never, DirectoryNotFoundError | DirectoryAccessDeniedError> => {
if (cause.reason._tag === "NotFound")
return Effect.fail(new DirectoryNotFoundError({ directory: location.directory, cause }))
// macOS privacy denials arrive as Unknown with an EPERM cause.
if (
cause.reason._tag === "PermissionDenied" ||
(cause.reason._tag === "Unknown" &&
cause.reason.cause instanceof Error &&
"code" in cause.reason.cause &&
cause.reason.cause.code === "EPERM")
)
if (FSUtil.isAccessDenied(cause))
return Effect.fail(new DirectoryAccessDeniedError({ directory: location.directory, cause }))
return Effect.die(cause)
}),
Expand Down
36 changes: 36 additions & 0 deletions packages/core/test/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { tmpdir } from "../fixture/tmpdir"
import { testEffect } from "../lib/effect"

const it = testEffect(Layer.empty)
const itWithPermissions = process.platform === "win32" || process.getuid?.() === 0 ? it.live.skip : it.live
const selection = Schema.decodeUnknownSync(ConfigModel.Selection)
const decodeInfo = Schema.decodeUnknownSync(Info, { errors: "all", onExcessProperty: "ignore" })
const encodeInfo = Schema.encodeSync(Info)
Expand Down Expand Up @@ -212,6 +213,41 @@ describe("Config", () => {
),
)

// Filesystem sandboxes deny reads outside the project. Simulate a denied discovery root with a
// symlink into an unsearchable directory, which makes realpath fail with EACCES.
itWithPermissions(
"treats unreadable discovery roots as absent",
() =>
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) => {
const global = path.join(tmp.path, "global")
const home = path.join(global, "home")
const project = path.join(home, "project")
const locked = path.join(tmp.path, "locked")
return Effect.promise(async () => {
await fs.mkdir(path.join(locked, "agents"), { recursive: true })
await fs.mkdir(project, { recursive: true })
await fs.writeFile(path.join(project, "opencode.json"), JSON.stringify({ shell: "project" }))
await fs.symlink(path.join(locked, "agents"), path.join(home, ".agents"))
await fs.symlink(path.join(locked, "agents"), path.join(project, ".opencode"))
await fs.chmod(locked, 0o000)
}).pipe(
Effect.andThen(Effect.addFinalizer(() => Effect.promise(() => fs.chmod(locked, 0o755)))),
Effect.andThen(
Effect.gen(function* () {
const config = yield* Config.Service
const entries = yield* config.entries()
expect(entries.flatMap((entry) => (entry.type === "document" ? [entry.info.shell] : []))).toEqual([
"project",
])
}).pipe(Effect.provide(testLayer(project, global))),
),
Effect.scoped,
)
}),
),
)

it.live("discovers the global config directory once when the project walk reaches it", () =>
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) => {
Expand Down
45 changes: 43 additions & 2 deletions packages/core/test/instruction-discovery.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect } from "bun:test"
import { Deferred, Effect, Fiber, Layer, PubSub, Stream } from "effect"
import { Deferred, Effect, Fiber, Layer, PlatformError, PubSub, Stream } from "effect"
import { HttpClient, HttpClientResponse } from "effect/http"
import fs from "fs/promises"
import path from "path"
Expand Down Expand Up @@ -76,6 +76,15 @@ const start = Effect.fnUntraced(function* (ctx = host()) {

const configured = (instructions: string[]) => new Document({ type: "document", info: new Info({ instructions }) })

const readFailure = (tag: "NotFound" | "Unknown", code: string, file: string) =>
PlatformError.systemError({
_tag: tag,
module: "FileSystem",
method: "readFile",
pathOrDescriptor: file,
cause: Object.assign(new Error(code), { code }),
})

const configUpdated = () => ({ id: Event.ID.create(), created: Date.now(), type: "config.updated" as const, data: {} })

const respond = (handler: (url: string) => Response) =>
Expand Down Expand Up @@ -607,7 +616,7 @@ describe("ConfigInstructionPlugin.Plugin", () => {
FSUtil.Service.of({
...fs,
up: () => Effect.succeed([discovered]),
readFileStringSafe: () => Effect.undefined,
readFileString: (file) => Effect.fail(readFailure("NotFound", "ENOENT", file)),
}),
),
),
Expand All @@ -633,6 +642,38 @@ describe("ConfigInstructionPlugin.Plugin", () => {
)
})

it.effect("skips discovered files a sandbox denies and keeps the rest available", () => {
const denied = path.resolve("/repo/AGENTS.md")
const readable = path.resolve("/repo/packages/AGENTS.md")
const sandboxedFS = Layer.effect(
FSUtil.Service,
FSUtil.Service.pipe(
Effect.map((fs) =>
FSUtil.Service.of({
...fs,
up: () => Effect.succeed([readable, denied]),
readFileString: (file) =>
file === denied ? Effect.fail(readFailure("Unknown", "EPERM", file)) : Effect.succeed("package"),
}),
),
),
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
return Effect.gen(function* () {
const discovery = yield* start()
expect((yield* readInitial(yield* discovery.load())).text).toBe(`Instructions from: ${readable}\npackage`)
}).pipe(
Effect.provide(
instructionLayer({
filesystemLayer: sandboxedFS,
locationServiceLayer: Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("/repo/packages") })),
),
}),
),
)
})

it.effect("canonicalizes boundaries and honors project opt-out", () =>
Effect.gen(function* () {
const observed: { values: FSUtil.UpOptions[] } = { values: [] }
Expand Down
50 changes: 50 additions & 0 deletions packages/core/test/util/fs-util.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import path from "path"
import { describe, expect } from "bun:test"
import { Effect, FileSystem, Layer, PlatformError } from "effect"
import { FSUtil } from "@opencode/util/fs-util"
import { testEffect } from "../lib/effect"

const it = testEffect(Layer.empty)

const failure = (tag: "PermissionDenied" | "Unknown", code: string, method: string, path: string) =>
PlatformError.systemError({
_tag: tag,
module: "FileSystem",
method,
pathOrDescriptor: path,
cause: Object.assign(new Error(code), { code }),
})

const denied = (tag: "PermissionDenied" | "Unknown", code: string) =>
FSUtil.layer.pipe(
Layer.provide(
FileSystem.layerNoop({
realPath: (path) => Effect.fail(failure(tag, code, "realPath", path)),
readFileString: (path) => Effect.fail(failure(tag, code, "readFile", path)),
}),
),
)

describe("FSUtil", () => {
for (const input of [
{ tag: "PermissionDenied", code: "EACCES" },
// macOS sandbox and privacy denials arrive as Unknown with an EPERM cause.
{ tag: "Unknown", code: "EPERM" },
] as const) {
it.effect(`treats ${input.code} as an unresolvable, unreadable path`, () =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
expect(yield* fs.resolve("/denied/.agents")).toBe(path.resolve("/denied/.agents"))
expect(yield* fs.readFileStringSafe("/denied/AGENTS.md")).toBeUndefined()
}).pipe(Effect.provide(denied(input.tag, input.code))),
)
}

it.effect("keeps other realPath failures fatal", () =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const exit = yield* fs.resolve("/broken").pipe(Effect.exit)
expect(exit._tag).toBe("Failure")
}).pipe(Effect.provide(denied("Unknown", "EIO"))),
)
})
29 changes: 23 additions & 6 deletions packages/util/src/fs-util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,11 @@ export namespace FSUtil {
})

const readFileStringSafe = Effect.fn("FileSystem.readFileStringSafe")(function* (path: string) {
return yield* fs.readFileString(path).pipe(
Effect.catchReason("PlatformError", "NotFound", () => Effect.undefined),
Effect.catchReason("PlatformError", "PermissionDenied", () => Effect.undefined),
)
return yield* fs
.readFileString(path)
.pipe(
Effect.catchIf((error) => error.reason._tag === "NotFound" || isAccessDenied(error), () => Effect.undefined),
)
})

const isDir = Effect.fn("FileSystem.isDir")(function* (path: string) {
Expand Down Expand Up @@ -104,8 +105,12 @@ export namespace FSUtil {

const resolve = Effect.fn("FileSystem.resolve")(function* (input: string) {
const resolved = path.resolve(windowsPath(input))
// Sandboxes deny reads outside granted paths; canonicalize what we can see.
return yield* fs.realPath(resolved).pipe(
Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(resolved)),
Effect.catchIf(
(error) => error.reason._tag === "NotFound" || isAccessDenied(error),
() => Effect.succeed(resolved),
),
Effect.orDie,
)
})
Expand Down Expand Up @@ -230,6 +235,18 @@ export namespace FSUtil {

export const node = makeGlobalNode({ service: Service, layer: layer, deps: [filesystem] })

/** Permission denial, including macOS sandbox and privacy denials that surface as Unknown with an EPERM cause. */
export function isAccessDenied(error: PlatformError) {
if (error.reason._tag === "PermissionDenied") return true
const cause = error.reason.cause
return (
error.reason._tag === "Unknown" &&
cause instanceof globalThis.Error &&
"code" in cause &&
cause.code === "EPERM"
)
}

// Pure helpers that don't need Effect (path manipulation, sync operations)
export function mimeType(p: string): string {
return lookup(p) || "application/octet-stream"
Expand Down Expand Up @@ -259,7 +276,7 @@ export namespace FSUtil {
try {
return normalizePath(realpathSync(resolved))
} catch (e: any) {
if (e?.code === "ENOENT" || e?.code === "ENOTDIR") return normalizePath(resolved)
if (["ENOENT", "ENOTDIR", "EPERM", "EACCES"].includes(e?.code)) return normalizePath(resolved)
throw e
}
}
Expand Down
Loading