Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 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
24 changes: 24 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(state, "tmp")

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

export const Path = paths

Flock.setGlobal({ state })

async function ensurePrivateDirectory(dir: string) {
try {
const stat = await fs.lstat(dir)
if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error(`${dir} is not a directory`)
} catch (error: any) {
if (error?.code !== "ENOENT") throw error
await fs.mkdir(dir, { recursive: true, mode: 0o700 })
}

const stat = await fs.lstat(dir)
if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error(`${dir} is not a private directory`)

if (process.platform !== "win32") {
const uid = process.getuid?.()
if (uid !== undefined && stat.uid !== uid) throw new Error(`${dir} is not owned by the current user`)
if ((stat.mode & 0o077) !== 0) await fs.chmod(dir, 0o700)
}
}

await Promise.all([
fs.mkdir(Path.data, { recursive: true }),
fs.mkdir(Path.config, { recursive: true }),
fs.mkdir(Path.state, { recursive: true }),
fs.mkdir(Path.log, { recursive: true }),
fs.mkdir(Path.bin, { recursive: true }),
])
await ensurePrivateDirectory(Path.tmp)

export class Service extends Context.Service<Service, Interface>()("@opencode/Global") {}

Expand All @@ -44,6 +66,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 +80,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
1 change: 1 addition & 0 deletions packages/core/test/global.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ describe("shared Global runtime namespace", () => {
expect(paths.cache).toBe(path.join(root.cache, "pawwork"))
expect(paths.config).toBe(path.join(root.config, "pawwork"))
expect(paths.state).toBe(path.join(root.state, "pawwork"))
expect(paths.tmp).toBe(path.join(root.state, "pawwork", "tmp"))
})

test("accepts PawWork variant namespaces", () => {
Expand Down
51 changes: 51 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,30 @@ 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)
expect(Option.getOrThrow(entry.entrypoint)).toContain(path.join("fixture-provider", "index.js"))
})
})

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