Skip to content
Open
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
103 changes: 103 additions & 0 deletions packages/opencode/src/cli/cmd/workspace.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import type { Argv } from "yargs"
import { cmd } from "./cmd"
import { bootstrap } from "../bootstrap"
import { Workspace } from "../../workspace"
import { UI } from "../ui"

export const WorkspaceCommand = cmd({
command: "workspace <action>",
describe: "Manage workspace directories for multi-directory access",
builder: (yargs: Argv) => {
return yargs
.command(
"add <directory>",
"Add a directory to the workspace",
(yargs) => {
return yargs.positional("directory", {
describe: "Directory path to add (absolute or relative to worktree)",
type: "string",
demandOption: true,
})
},
async (args) => {
await bootstrap(process.cwd(), async () => {
const workspace = await Workspace.addDirectory(args.directory as string)
UI.println(UI.Style.TEXT_SUCCESS_BOLD + `✓ Added ${args.directory} to workspace`)
UI.println(UI.Style.TEXT_DIM + ` Workspace now includes ${workspace.directories.length} directories`)
})
},
)
.command(
"remove <directory>",
"Remove a directory from the workspace",
(yargs) => {
return yargs.positional("directory", {
describe: "Directory path to remove",
type: "string",
demandOption: true,
})
},
async (args) => {
await bootstrap(process.cwd(), async () => {
const workspace = await Workspace.removeDirectory(args.directory as string)
UI.println(UI.Style.TEXT_SUCCESS_BOLD + `✓ Removed ${args.directory} from workspace`)
if (workspace) {
UI.println(UI.Style.TEXT_DIM + ` Workspace now includes ${workspace.directories.length} directories`)
} else {
UI.println(UI.Style.TEXT_DIM + ` Workspace is now empty`)
}
})
},
)
.command(
"list",
"List all workspace directories",
() => {},
async () => {
await bootstrap(process.cwd(), async () => {
const directories = await Workspace.list()
const allowed = await Workspace.getAllowedDirectories()

if (allowed.length === 0) {
UI.println(UI.Style.TEXT_WARNING + "No workspace directories configured")
return
}

UI.println(UI.Style.TEXT_INFO_BOLD + "Workspace directories:")
UI.println()

// Show default directories
UI.println(UI.Style.TEXT_DIM + "Default (always allowed):")
for (const dir of allowed) {
if (!directories.includes(dir)) {
UI.println(UI.Style.TEXT_NORMAL + ` ${dir}`)
}
}

// Show configured directories
if (directories.length > 0) {
UI.println()
UI.println(UI.Style.TEXT_DIM + "Configured:")
for (const dir of directories) {
UI.println(UI.Style.TEXT_NORMAL + ` ${dir}`)
}
}
})
},
)
.command(
"clear",
"Clear all workspace directories (keeps defaults)",
() => {},
async () => {
await bootstrap(process.cwd(), async () => {
await Workspace.clear()
UI.println(UI.Style.TEXT_SUCCESS_BOLD + "✓ Cleared workspace configuration")
UI.println(UI.Style.TEXT_DIM + " Only default directories (cwd and worktree) are now allowed")
})
},
)
.demandCommand(1, "You must specify an action: add, remove, list, or clear")
},
handler: () => {},
})
11 changes: 11 additions & 0 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,17 @@ export namespace Config {
ignore: z.array(z.string()).optional(),
})
.optional(),
workspace: z
.object({
directories: z
.array(z.string())
.optional()
.describe(
"Additional directories to allow access to. Can be absolute paths or relative to the project root.",
),
})
.optional()
.describe("Workspace configuration for multi-directory access"),
plugin: z.string().array().optional(),
snapshot: z.boolean().optional(),
share: z
Expand Down
2 changes: 2 additions & 0 deletions packages/opencode/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { McpCommand } from "./cli/cmd/mcp"
import { GithubCommand } from "./cli/cmd/github"
import { ExportCommand } from "./cli/cmd/export"
import { AttachCommand } from "./cli/cmd/attach"
import { WorkspaceCommand } from "./cli/cmd/workspace"

const cancel = new AbortController()

Expand Down Expand Up @@ -81,6 +82,7 @@ const cli = yargs(hideBin(process.argv))
.command(StatsCommand)
.command(ExportCommand)
.command(GithubCommand)
.command(WorkspaceCommand)
.fail((msg) => {
if (
msg.startsWith("Unknown argument") ||
Expand Down
70 changes: 70 additions & 0 deletions packages/opencode/src/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { SessionCompaction } from "../session/compaction"
import { SessionRevert } from "../session/revert"
import { lazy } from "../util/lazy"
import { InstanceBootstrap } from "../project/bootstrap"
import { Workspace } from "../workspace"

const ERRORS = {
400: {
Expand Down Expand Up @@ -159,6 +160,75 @@ export namespace Server {
return c.json(config)
},
)
.get(
"/workspace/directories",
describeRoute({
description: "List workspace directories",
operationId: "workspace.list",
responses: {
200: {
description: "List of allowed workspace directories",
content: {
"application/json": {
schema: resolver(z.array(z.string()).meta({ ref: "WorkspaceDirectories" })),
},
},
},
},
}),
async (c) => {
const directories = await Workspace.getAllowedDirectories()
return c.json(directories)
},
)
.post(
"/workspace/directories",
describeRoute({
description: "Add directory to workspace",
operationId: "workspace.addDirectory",
responses: {
200: {
description: "Successfully added directory",
content: {
"application/json": {
schema: resolver(Workspace.Info),
},
},
},
...ERRORS,
},
}),
validator("json", z.object({ directory: z.string() })),
async (c) => {
const { directory } = c.req.valid("json")
const workspace = await Workspace.addDirectory(directory)
return c.json(workspace)
},
)
.delete(
"/workspace/directories",
describeRoute({
description: "Remove directory from workspace",
operationId: "workspace.removeDirectory",
responses: {
200: {
description: "Successfully removed directory",
content: {
"application/json": {
schema: resolver(Workspace.Info.nullable()),
},
},
},
...ERRORS,
},
}),
validator("json", z.object({ directory: z.string() })),
async (c) => {
const { directory } = c.req.valid("json")
const workspace = await Workspace.removeDirectory(directory)
return c.json(workspace)
},
)
.get(
"/experimental/tool/ids",
describeRoute({
Expand Down
13 changes: 8 additions & 5 deletions packages/opencode/src/tool/bash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@ import { exec } from "child_process"
import { Tool } from "./tool"
import DESCRIPTION from "./bash.txt"
import { Permission } from "../permission"
import { Filesystem } from "../util/filesystem"
import { lazy } from "../util/lazy"
import { Log } from "../util/log"
import { Wildcard } from "../util/wildcard"
import { $ } from "bun"
import { Instance } from "../project/instance"
import { Agent } from "../agent/agent"
import { PathValidation } from "../workspace/validate"

const MAX_OUTPUT_LENGTH = 30_000
const DEFAULT_TIMEOUT = 1 * 60 * 1000
Expand Down Expand Up @@ -87,10 +87,13 @@ export const BashTool = Tool.define("bash", {
.text()
.then((x) => x.trim())
log.info("resolved path", { arg, resolved })
if (resolved && !Filesystem.contains(Instance.directory, resolved)) {
throw new Error(
`This command references paths outside of ${Instance.directory} so it is not allowed to be executed.`,
)
if (resolved) {
// Validate path access (checks workspace + requests permission if needed)
await PathValidation.validate(resolved, {
sessionID: ctx.sessionID,
messageID: ctx.messageID,
callID: ctx.callID,
})
}
}
}
Expand Down
12 changes: 8 additions & 4 deletions packages/opencode/src/tool/edit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ import DESCRIPTION from "./edit.txt"
import { File } from "../file"
import { Bus } from "../bus"
import { FileTime } from "../file/time"
import { Filesystem } from "../util/filesystem"
import { Instance } from "../project/instance"
import { Agent } from "../agent/agent"
import { PathValidation } from "../workspace/validate"

export const EditTool = Tool.define("edit", {
description: DESCRIPTION,
Expand All @@ -35,9 +35,13 @@ export const EditTool = Tool.define("edit", {
}

const filePath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath)
if (!Filesystem.contains(Instance.directory, filePath)) {
throw new Error(`File ${filePath} is not in the current working directory`)
}

// Validate path access (checks workspace + requests permission if needed)
await PathValidation.validate(filePath, {
sessionID: ctx.sessionID,
messageID: ctx.messageID,
callID: ctx.callID,
})

const agent = await Agent.get(ctx.agent)
let diff = ""
Expand Down
Loading
Loading