Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
3 changes: 1 addition & 2 deletions packages/core/src/flag/flag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,7 @@ export namespace Flag {
OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT")
export const OPENCODE_DISABLE_CLAUDE_CODE_SKILLS =
OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_SKILLS")
export const OPENCODE_DISABLE_EXTERNAL_SKILLS =
OPENCODE_DISABLE_CLAUDE_CODE_SKILLS || truthy("OPENCODE_DISABLE_EXTERNAL_SKILLS")
export const OPENCODE_DISABLE_EXTERNAL_SKILLS = truthy("OPENCODE_DISABLE_EXTERNAL_SKILLS")
export declare const OPENCODE_DISABLE_PROJECT_CONFIG: boolean
export const OPENCODE_FAKE_VCS = process.env["OPENCODE_FAKE_VCS"]
export declare const OPENCODE_CLIENT: string
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/global.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const data = path.join(xdgData!, app)
const cache = path.join(xdgCache!, app)
const config = path.join(xdgConfig!, app)
const state = path.join(xdgState!, app)
const tmp = path.join(os.tmpdir(), app)

const paths = {
get home() {
Expand All @@ -22,6 +23,7 @@ const paths = {
cache,
config,
state,
tmp,
}

export const Path = paths
Expand All @@ -34,6 +36,7 @@ await Promise.all([
fs.mkdir(Path.state, { recursive: true }),
fs.mkdir(Path.log, { recursive: true }),
fs.mkdir(Path.bin, { recursive: true }),
fs.mkdir(Path.tmp, { recursive: true }),
])

export class Service extends Context.Service<Service, Interface>()("@opencode/Global") {}
Expand All @@ -44,6 +47,7 @@ export interface Interface {
readonly cache: string
readonly config: string
readonly state: string
readonly tmp: string
readonly bin: string
readonly log: string
}
Expand All @@ -57,6 +61,7 @@ export const layer = Layer.effect(
cache: Path.cache,
config: Path.config,
state: Path.state,
tmp: Path.tmp,
bin: Path.bin,
log: Path.log,
})
Expand Down
13 changes: 12 additions & 1 deletion packages/core/src/npm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ const resolveEntryPoint = (name: string, dir: string): EntryPoint => {
}
}

const packageName = (pkg: string) => {
const scoped = pkg.match(/^(@[^/]+\/[^@]+)/)?.[1]
if (scoped) return scoped
return pkg.match(/^([^@]+)/)?.[1] ?? pkg
}

interface ArboristNode {
name: string
path: string
Expand Down Expand Up @@ -121,6 +127,7 @@ export const layer = Layer.effect(

const add = Effect.fn("Npm.add")(function* (pkg: string) {
const dir = directory(pkg)
const name = packageName(pkg)

// Only validate cached installs when the lockfile is present — bare cache
// dirs without package-lock.json mean reify never finished, so skip
Expand Down Expand Up @@ -153,7 +160,11 @@ export const layer = Layer.effect(

const tree = yield* reify({ dir, add: [pkg] })
const first = tree.edgesOut.values().next().value?.to
if (!first) return yield* new InstallFailedError({ add: [pkg], dir })
if (!first) {
const result = resolveEntryPoint(name, path.join(dir, "node_modules", name))
if (Option.isSome(result.entrypoint)) return result
return yield* new InstallFailedError({ add: [pkg], dir })
}
return resolveEntryPoint(first.name, first.path)
}, Effect.scoped)

Expand Down
1 change: 1 addition & 0 deletions packages/core/test/fixture/effect-flock-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const testGlobal = Layer.succeed(
cache: os.tmpdir(),
config: os.tmpdir(),
state: os.tmpdir(),
tmp: os.tmpdir(),
bin: os.tmpdir(),
log: os.tmpdir(),
}),
Expand Down
50 changes: 50 additions & 0 deletions packages/core/test/npm.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect, test } from "bun:test"
import { NodeFileSystem } from "@effect/platform-node"
import { Effect, Layer, Option } from "effect"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Global } from "@opencode-ai/core/global"
import { Npm } from "@opencode-ai/core/npm"
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
import { tmpdir } from "./fixture/tmpdir"

const win = process.platform === "win32"
Expand All @@ -15,6 +20,28 @@ const writePackage = (dir: string, pkg: Record<string, unknown>) =>
}),
)

const npmLayer = (cache: string) =>
Npm.layer.pipe(
Layer.provide(EffectFlock.layer),
Layer.provide(AppFileSystem.layer),
Layer.provide(
Layer.succeed(
Global.Service,
Global.Service.of({
home: cache,
data: path.join(cache, "data"),
cache,
config: path.join(cache, "config"),
state: path.join(cache, "state"),
tmp: path.join(cache, "tmp"),
bin: path.join(cache, "bin"),
log: path.join(cache, "log"),
}),
),
),
Layer.provide(NodeFileSystem.layer),
)

describe("Npm.sanitize", () => {
test("keeps normal scoped package specs unchanged", () => {
expect(Npm.sanitize("@opencode/acme")).toBe("@opencode/acme")
Expand All @@ -29,6 +56,29 @@ describe("Npm.sanitize", () => {
})
})

describe("Npm.add", () => {
test("reifies when package cache directory exists without the package installed", async () => {
await using tmp = await tmpdir()
await fs.mkdir(path.join(tmp.path, "fixture-provider"))
await writePackage(path.join(tmp.path, "fixture-provider"), {
name: "fixture-provider",
main: "index.js",
})
await Bun.write(path.join(tmp.path, "fixture-provider", "index.js"), "export const fixture = true\n")

const spec = `fixture-provider@file:${path.join(tmp.path, "fixture-provider")}`
await fs.mkdir(path.join(tmp.path, "cache", "packages", Npm.sanitize(spec)), { recursive: true })

const effect = Effect.gen(function* () {
const npm = yield* Npm.Service
return yield* npm.add(spec)
}).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "cache"))))
const entry = await Effect.runPromise(effect)

expect(Option.isSome(entry.entrypoint)).toBe(true)
})
})

describe("Npm.install", () => {
test("respects omit from project .npmrc", async () => {
await using tmp = await tmpdir()
Expand Down
1 change: 1 addition & 0 deletions packages/core/test/util/effect-flock.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ const testGlobal = Layer.succeed(
cache: os.tmpdir(),
config: os.tmpdir(),
state: os.tmpdir(),
tmp: os.tmpdir(),
bin: os.tmpdir(),
log: os.tmpdir(),
}),
Expand Down
30 changes: 18 additions & 12 deletions packages/opencode/src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import { Plugin } from "@/plugin"
import { Effect, Context, Layer } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { Global } from "@opencode-ai/core/global"
import path from "path"

export namespace Agent {
export const Info = z
Expand Down Expand Up @@ -244,20 +246,24 @@ export namespace Agent {
item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {}))
}

// Ensure Truncate.GLOB is allowed unless explicitly configured
// Ensure runtime-owned helper dirs are allowed unless explicitly configured.
for (const name in agents) {
const agent = agents[name]
const explicit = agent.permission.some((r) => {
if (r.permission !== "external_directory") return false
if (r.action !== "deny") return false
return r.pattern === Truncate.GLOB
})
if (explicit) continue

agents[name].permission = Permission.merge(
agents[name].permission,
Permission.fromConfig({ external_directory: { [Truncate.GLOB]: "allow" } }),
)
const allow: Record<string, "allow"> = {}
for (const pattern of [Truncate.GLOB, path.join(Global.Path.tmp, "*")]) {
const explicit = agent.permission.some((r) => {
if (r.permission !== "external_directory") return false
if (r.action !== "deny") return false
return r.pattern === pattern
})
if (!explicit) allow[pattern] = "allow"
}
if (Object.keys(allow).length > 0) {
agents[name].permission = Permission.merge(
agents[name].permission,
Permission.fromConfig({ external_directory: allow }),
)
}
}

const get = Effect.fnUntraced(function* (agent: string) {
Expand Down
13 changes: 8 additions & 5 deletions packages/opencode/src/format/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,11 +126,14 @@ export namespace Format {
const dir = yield* InstanceState.directory
const code = yield* spawner
.spawn(
ChildProcess.make(replaced[0]!, replaced.slice(1), {
cwd: dir,
env: item.environment,
extendEnv: true,
}),
ChildProcess.make(replaced[0]!, replaced.slice(1), {
cwd: dir,
env: item.environment,
extendEnv: true,
stdin: "ignore",
stdout: "ignore",
stderr: "ignore",
}),
)
.pipe(
Effect.flatMap((handle) => handle.exitCode),
Expand Down
Loading
Loading