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
20 changes: 13 additions & 7 deletions packages/opencode/src/project/instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,23 @@ const context = LocalContext.create<InstanceContext>("instance")
const cache = new Map<string, Promise<InstanceContext>>()
const project = makeRuntime(Project.Service, Project.defaultLayer)

const FORBIDDEN_ROOTS = new Set([
"/etc", "/proc", "/sys", "/dev", "/boot", "/root", "/var",
"/private/etc", "/private/var",
])
const FORBIDDEN_PREFIXES = [
"/etc",
"/proc",
"/sys",
"/dev",
"/boot",
"/root",
"/private/etc",
] as const

function assertSafeDirectory(directory: string): void {
if (directory === pathParse(directory).root) {
const resolved = AppFileSystem.resolve(directory)
if (resolved === pathParse(resolved).root) {
throw new Error("Access denied: filesystem root is not a valid project directory")
}
for (const forbidden of FORBIDDEN_ROOTS) {
if (directory === forbidden || AppFileSystem.contains(forbidden, directory)) {
for (const prefix of FORBIDDEN_PREFIXES) {
if (resolved === prefix || resolved.startsWith(`${prefix}/`)) {
throw new Error("Access denied: target is a protected system directory")
}
}
Expand Down
6 changes: 3 additions & 3 deletions packages/opencode/test/actor/spawn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1023,7 +1023,7 @@ describe("Actor.spawn return-format injection (F21)", () => {

yield* Deferred.await(result.outcome)

const msgs = yield* session.messages({ sessionID: result.sessionID })
const msgs = yield* session.messages({ sessionID: result.sessionID, agentID: "*" })
const subAgentUser = msgs.find((m) => m.info.role === "user" && m.info.agentID === result.actorID)
expect(subAgentUser).toBeDefined()
const text = subAgentUser?.parts.find((p) => p.type === "text")?.text ?? ""
Expand Down Expand Up @@ -1060,7 +1060,7 @@ describe("Actor.spawn return-format injection (F21)", () => {

yield* Deferred.await(result.outcome)

const msgs = yield* session.messages({ sessionID: result.sessionID })
const msgs = yield* session.messages({ sessionID: result.sessionID, agentID: "*" })
const subAgentUser = msgs.find((m) => m.info.role === "user" && m.info.agentID === result.actorID)
const text = subAgentUser?.parts.find((p) => p.type === "text")?.text ?? ""
expect(text).not.toContain("Return format (required)")
Expand Down Expand Up @@ -1095,7 +1095,7 @@ describe("Actor.spawn return-format injection (F21)", () => {

yield* Deferred.await(result.outcome)

const msgs = yield* session.messages({ sessionID: result.sessionID })
const msgs = yield* session.messages({ sessionID: result.sessionID, agentID: "*" })
const subAgentUser = msgs.find((m) => m.info.role === "user" && m.info.agentID === result.actorID)
const text = subAgentUser?.parts.find((p) => p.type === "text")?.text ?? ""
expect(text).not.toContain("Return format (required)")
Expand Down
54 changes: 27 additions & 27 deletions packages/opencode/test/config/tui.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { afterEach, beforeEach, expect, test } from "bun:test"
import path from "path"
import fs from "fs/promises"
import { tmpdir } from "../fixture/fixture"
import { tmpdir, withTmpdirOutsideGit } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { TuiConfig } from "../../src/cli/cmd/tui/config/tui"
import { Config } from "../../src/config"
Expand Down Expand Up @@ -38,7 +38,7 @@ afterEach(async () => {
})

test("keeps server and tui plugin merge semantics aligned", async () => {
await using tmp = await tmpdir({
await using tmp = await tmpdir({ outsideGit: true,
init: async (dir) => {
const local = path.join(dir, ".mimocode")
await fs.mkdir(local, { recursive: true })
Expand Down Expand Up @@ -109,7 +109,7 @@ test("keeps server and tui plugin merge semantics aligned", async () => {
})

test("loads tui config with the same precedence order as server config paths", async () => {
await using tmp = await tmpdir({
await using tmp = await tmpdir({ outsideGit: true,
init: async (dir) => {
await Bun.write(path.join(Global.Path.config, "tui.json"), JSON.stringify({ theme: "global" }, null, 2))
await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ theme: "project" }, null, 2))
Expand All @@ -127,7 +127,7 @@ test("loads tui config with the same precedence order as server config paths", a
})

test("migrates tui-specific keys from mimocode.json when tui.json does not exist", async () => {
await using tmp = await tmpdir({
await using tmp = await tmpdir({ outsideGit: true,
init: async (dir) => {
await Bun.write(
path.join(dir, "mimocode.json"),
Expand Down Expand Up @@ -162,7 +162,7 @@ test("migrates tui-specific keys from mimocode.json when tui.json does not exist
})

test("migrates project legacy tui keys even when global tui.json already exists", async () => {
await using tmp = await tmpdir({
await using tmp = await tmpdir({ outsideGit: true,
init: async (dir) => {
await Bun.write(path.join(Global.Path.config, "tui.json"), JSON.stringify({ theme: "global" }, null, 2))
await Bun.write(
Expand Down Expand Up @@ -190,7 +190,7 @@ test("migrates project legacy tui keys even when global tui.json already exists"
})

test("drops unknown legacy tui keys during migration", async () => {
await using tmp = await tmpdir({
await using tmp = await tmpdir({ outsideGit: true,
init: async (dir) => {
await Bun.write(
path.join(dir, "mimocode.json"),
Expand All @@ -217,7 +217,7 @@ test("drops unknown legacy tui keys during migration", async () => {
})

test("skips migration when mimocode.jsonc is syntactically invalid", async () => {
await using tmp = await tmpdir({
await using tmp = await tmpdir({ outsideGit: true,
init: async (dir) => {
await Bun.write(
path.join(dir, "mimocode.jsonc"),
Expand All @@ -241,7 +241,7 @@ test("skips migration when mimocode.jsonc is syntactically invalid", async () =>
})

test("skips migration when tui.json already exists", async () => {
await using tmp = await tmpdir({
await using tmp = await tmpdir({ outsideGit: true,
init: async (dir) => {
await Bun.write(path.join(dir, "mimocode.json"), JSON.stringify({ theme: "legacy" }, null, 2))
await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ diff_style: "stacked" }, null, 2))
Expand All @@ -259,7 +259,7 @@ test("skips migration when tui.json already exists", async () => {

// Skip: root bypasses file permissions, so chmod 0o444 is ineffective
test.skip("continues loading tui config when legacy source cannot be stripped", async () => {
await using tmp = await tmpdir({
await using tmp = await tmpdir({ outsideGit: true,
init: async (dir) => {
await Bun.write(path.join(dir, "mimocode.json"), JSON.stringify({ theme: "readonly-theme" }, null, 2))
},
Expand All @@ -281,7 +281,7 @@ test.skip("continues loading tui config when legacy source cannot be stripped",
})

test("migration backup preserves JSONC comments", async () => {
await using tmp = await tmpdir({
await using tmp = await tmpdir({ outsideGit: true,
init: async (dir) => {
await Bun.write(
path.join(dir, "mimocode.jsonc"),
Expand All @@ -306,7 +306,7 @@ test("migration backup preserves JSONC comments", async () => {
})

test("migrates legacy tui keys across multiple mimocode.json levels", async () => {
await using tmp = await tmpdir({
await using tmp = await tmpdir({ outsideGit: true,
init: async (dir) => {
const nested = path.join(dir, "apps", "client")
await fs.mkdir(nested, { recursive: true })
Expand All @@ -321,7 +321,7 @@ test("migrates legacy tui keys across multiple mimocode.json levels", async () =
})

test("flattens nested tui key inside tui.json", async () => {
await using tmp = await tmpdir({
await using tmp = await tmpdir({ outsideGit: true,
init: async (dir) => {
await Bun.write(
path.join(dir, "tui.json"),
Expand All @@ -341,7 +341,7 @@ test("flattens nested tui key inside tui.json", async () => {
})

test("top-level keys in tui.json take precedence over nested tui key", async () => {
await using tmp = await tmpdir({
await using tmp = await tmpdir({ outsideGit: true,
init: async (dir) => {
await Bun.write(
path.join(dir, "tui.json"),
Expand All @@ -359,7 +359,7 @@ test("top-level keys in tui.json take precedence over nested tui key", async ()
})

test("project config takes precedence over MIMOCODE_TUI_CONFIG (matches MIMOCODE_CONFIG)", async () => {
await using tmp = await tmpdir({
await using tmp = await tmpdir({ outsideGit: true,
init: async (dir) => {
await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ theme: "project", diff_style: "auto" }))
const custom = path.join(dir, "custom-tui.json")
Expand All @@ -376,7 +376,7 @@ test("project config takes precedence over MIMOCODE_TUI_CONFIG (matches MIMOCODE
})

test("merges keybind overrides across precedence layers", async () => {
await using tmp = await tmpdir({
await using tmp = await tmpdir({ outsideGit: true,
init: async (dir) => {
await Bun.write(path.join(Global.Path.config, "tui.json"), JSON.stringify({ keybinds: { app_exit: "ctrl+q" } }))
await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ keybinds: { theme_list: "ctrl+k" } }))
Expand All @@ -388,14 +388,14 @@ test("merges keybind overrides across precedence layers", async () => {
})

wintest("defaults Ctrl+Z to input undo on Windows", async () => {
await using tmp = await tmpdir()
await using tmp = await tmpdir({ outsideGit: true })
const config = await getTuiConfig(tmp.path)
expect(config.keybinds?.terminal_suspend).toBe("none")
expect(config.keybinds?.input_undo).toBe("ctrl+z,ctrl+-,super+z")
})

wintest("keeps explicit input undo overrides on Windows", async () => {
await using tmp = await tmpdir({
await using tmp = await tmpdir({ outsideGit: true,
init: async (dir) => {
await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ keybinds: { input_undo: "ctrl+y" } }))
},
Expand All @@ -406,7 +406,7 @@ wintest("keeps explicit input undo overrides on Windows", async () => {
})

wintest("ignores terminal suspend bindings on Windows", async () => {
await using tmp = await tmpdir({
await using tmp = await tmpdir({ outsideGit: true,
init: async (dir) => {
await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ keybinds: { terminal_suspend: "alt+z" } }))
},
Expand All @@ -418,7 +418,7 @@ wintest("ignores terminal suspend bindings on Windows", async () => {
})

test("MIMOCODE_TUI_CONFIG provides settings when no project config exists", async () => {
await using tmp = await tmpdir({
await using tmp = await tmpdir({ outsideGit: true,
init: async (dir) => {
const custom = path.join(dir, "custom-tui.json")
await Bun.write(custom, JSON.stringify({ theme: "from-env", diff_style: "stacked" }))
Expand All @@ -431,7 +431,7 @@ test("MIMOCODE_TUI_CONFIG provides settings when no project config exists", asyn
})

test("does not derive tui path from MIMOCODE_CONFIG", async () => {
await using tmp = await tmpdir({
await using tmp = await tmpdir({ outsideGit: true,
init: async (dir) => {
const customDir = path.join(dir, "custom")
await fs.mkdir(customDir, { recursive: true })
Expand All @@ -448,7 +448,7 @@ test("applies env and file substitutions in tui.json", async () => {
const original = process.env.TUI_THEME_TEST
process.env.TUI_THEME_TEST = "env-theme"
try {
await using tmp = await tmpdir({
await using tmp = await tmpdir({ outsideGit: true,
init: async (dir) => {
await Bun.write(path.join(dir, "keybind.txt"), "ctrl+q")
await Bun.write(
Expand All @@ -470,7 +470,7 @@ test("applies env and file substitutions in tui.json", async () => {
})

test("applies file substitutions when first identical token is in a commented line", async () => {
await using tmp = await tmpdir({
await using tmp = await tmpdir({ outsideGit: true,
init: async (dir) => {
await Bun.write(path.join(dir, "theme.txt"), "resolved-theme")
await Bun.write(
Expand All @@ -487,7 +487,7 @@ test("applies file substitutions when first identical token is in a commented li
})

test("loads .mimocode/tui.json", async () => {
await using tmp = await tmpdir({
await using tmp = await tmpdir({ outsideGit: true,
init: async (dir) => {
await fs.mkdir(path.join(dir, ".mimocode"), { recursive: true })
await Bun.write(path.join(dir, ".mimocode", "tui.json"), JSON.stringify({ diff_style: "stacked" }, null, 2))
Expand All @@ -498,7 +498,7 @@ test("loads .mimocode/tui.json", async () => {
})

test("supports tuple plugin specs with options in tui.json", async () => {
await using tmp = await tmpdir({
await using tmp = await tmpdir({ outsideGit: true,
init: async (dir) => {
await Bun.write(
path.join(dir, "tui.json"),
Expand All @@ -521,7 +521,7 @@ test("supports tuple plugin specs with options in tui.json", async () => {
})

test("deduplicates tuple plugin specs by name with higher precedence winning", async () => {
await using tmp = await tmpdir({
await using tmp = await tmpdir({ outsideGit: true,
init: async (dir) => {
await Bun.write(
path.join(Global.Path.config, "tui.json"),
Expand Down Expand Up @@ -561,7 +561,7 @@ test("deduplicates tuple plugin specs by name with higher precedence winning", a
})

test("tracks global and local plugin metadata in merged tui config", async () => {
await using tmp = await tmpdir({
await using tmp = await tmpdir({ outsideGit: true,
init: async (dir) => {
await Bun.write(
path.join(Global.Path.config, "tui.json"),
Expand Down Expand Up @@ -595,7 +595,7 @@ test("tracks global and local plugin metadata in merged tui config", async () =>
})

test("merges plugin_enabled flags across config layers", async () => {
await using tmp = await tmpdir({
await using tmp = await tmpdir({ outsideGit: true,
init: async (dir) => {
await Bun.write(
path.join(Global.Path.config, "tui.json"),
Expand Down
10 changes: 1 addition & 9 deletions packages/opencode/test/file/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import fs from "fs/promises"
import { File } from "../../src/file"
import { Instance } from "../../src/project/instance"
import { Filesystem } from "../../src/util"
import { provideInstance, tmpdir } from "../fixture/fixture"
import { provideInstance, tmpdir, withTmpdirOutsideGit } from "../fixture/fixture"

afterEach(async () => {
await Instance.disposeAll()
Expand All @@ -15,14 +15,6 @@ afterEach(async () => {
// Tests that verify path traversal rejection need tmpdirs outside any git repo.
// Otherwise project detection finds the parent .git, sets worktree to the repo
// root, and containsPath allows "../" paths that stay within the worktree.
function withTmpdirOutsideGit<T>(fn: () => Promise<T>): Promise<T> {
const prev = process.env["MIMOCODE_TEST_TMPDIR_ROOT"]
delete process.env["MIMOCODE_TEST_TMPDIR_ROOT"]
return fn().finally(() => {
if (prev !== undefined) process.env["MIMOCODE_TEST_TMPDIR_ROOT"] = prev
else delete process.env["MIMOCODE_TEST_TMPDIR_ROOT"]
})
}

const init = () => run(File.Service.use((svc) => svc.init()))
const run = <A, E>(eff: Effect.Effect<A, E, File.Service>) =>
Expand Down
10 changes: 1 addition & 9 deletions packages/opencode/test/file/path-traversal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import fs from "fs/promises"
import { Filesystem } from "../../src/util"
import { File } from "../../src/file"
import { Instance } from "../../src/project/instance"
import { provideInstance, tmpdir } from "../fixture/fixture"
import { provideInstance, tmpdir, withTmpdirOutsideGit } from "../fixture/fixture"

const run = <A, E>(eff: Effect.Effect<A, E, File.Service>) =>
Effect.runPromise(provideInstance(Instance.directory)(eff.pipe(Effect.provide(File.defaultLayer))))
Expand Down Expand Up @@ -49,14 +49,6 @@ describe("Filesystem.contains", () => {
// These traversal tests need tmpdirs outside any git repo so project detection
// sets worktree="/" (the non-git sentinel). Otherwise containsPath falls through
// to the worktree check and allows paths within the parent repo.
function withTmpdirOutsideGit<T>(fn: () => Promise<T>): Promise<T> {
const prev = process.env["MIMOCODE_TEST_TMPDIR_ROOT"]
delete process.env["MIMOCODE_TEST_TMPDIR_ROOT"]
return fn().finally(() => {
if (prev !== undefined) process.env["MIMOCODE_TEST_TMPDIR_ROOT"] = prev
else delete process.env["MIMOCODE_TEST_TMPDIR_ROOT"]
})
}

describe("File.read path traversal protection", () => {
test("rejects ../ traversal attempting to read /etc/passwd", () =>
Expand Down
10 changes: 1 addition & 9 deletions packages/opencode/test/file/ripgrep.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,14 @@ import { Effect } from "effect"
import * as Stream from "effect/Stream"
import fs from "fs/promises"
import path from "path"
import { tmpdir } from "../fixture/fixture"
import { tmpdir, withTmpdirOutsideGit } from "../fixture/fixture"
import { Ripgrep } from "../../src/file/ripgrep"

const run = <A>(effect: Effect.Effect<A, unknown, Ripgrep.Service>) =>
effect.pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromise)

// Ripgrep respects parent .gitignore. When tmpdirs are under the repo,
// patterns like `.mimocode/` in root .gitignore affect test results.
function withTmpdirOutsideGit<T>(fn: () => Promise<T>): Promise<T> {
const prev = process.env["MIMOCODE_TEST_TMPDIR_ROOT"]
delete process.env["MIMOCODE_TEST_TMPDIR_ROOT"]
return fn().finally(() => {
if (prev !== undefined) process.env["MIMOCODE_TEST_TMPDIR_ROOT"] = prev
else delete process.env["MIMOCODE_TEST_TMPDIR_ROOT"]
})
}

describe("file.ripgrep", () => {
test("defaults to include hidden", () =>
Expand Down
7 changes: 7 additions & 0 deletions packages/opencode/test/fixture/fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,11 +66,14 @@ async function stop(dir: string) {

type TmpDirOptions<T> = {
git?: boolean
outsideGit?: boolean
config?: Partial<Config.Info>
init?: (dir: string) => Promise<T>
dispose?: (dir: string) => Promise<T>
}
export async function tmpdir<T>(options?: TmpDirOptions<T>) {
const prevRoot = options?.outsideGit ? process.env["MIMOCODE_TEST_TMPDIR_ROOT"] : undefined
if (options?.outsideGit) process.env["MIMOCODE_TEST_TMPDIR_ROOT"] = outsideGitTmpRoot()
const dirpath = sanitizePath(
path.join(process.env["MIMOCODE_TEST_TMPDIR_ROOT"] ?? os.tmpdir(), "mimocode-test-" + Math.random().toString(36).slice(2)),
)
Expand Down Expand Up @@ -101,6 +104,10 @@ export async function tmpdir<T>(options?: TmpDirOptions<T>) {
} finally {
if (options?.git) await stop(realpath).catch(() => undefined)
await cleanupTmpdir(realpath)
if (options?.outsideGit) {
if (prevRoot !== undefined) process.env["MIMOCODE_TEST_TMPDIR_ROOT"] = prevRoot
else delete process.env["MIMOCODE_TEST_TMPDIR_ROOT"]
}
}
},
path: realpath,
Expand Down
Loading
Loading