Skip to content
Closed
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
27 changes: 9 additions & 18 deletions packages/opencode/src/cli/cmd/debug/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export const AgentCommand = cmd({
})
.option("params", {
type: "string",
description: "Tool params as JSON or a JS object literal",
description: "Tool params as JSON object",
}),
async handler(args) {
await bootstrap(process.cwd(), async () => {
Expand Down Expand Up @@ -86,29 +86,20 @@ async function resolveTools(agent: Agent.Info, availableTools: Awaited<ReturnTyp
return resolved
}

function parseToolParams(input?: string) {
function parseToolParams(input?: string): Record<string, unknown> {
if (!input) return {}
const trimmed = input.trim()
if (trimmed.length === 0) return {}

const parsed = iife(() => {
try {
return JSON.parse(trimmed)
} catch (jsonError) {
try {
return new Function(`return (${trimmed})`)()
} catch (evalError) {
throw new Error(
`Failed to parse --params. Use JSON or a JS object literal. JSON error: ${jsonError}. Eval error: ${evalError}.`,
)
}
try {
const parsed = JSON.parse(trimmed)
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("Tool params must be a JSON object.")
}
})

if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("Tool params must be an object.")
return parsed as Record<string, unknown>
} catch (e) {
throw new Error(`Failed to parse --params as JSON: ${e instanceof Error ? e.message : String(e)}`)
}
return parsed as Record<string, unknown>
}

async function createToolContext(agent: Agent.Info) {
Expand Down
38 changes: 28 additions & 10 deletions packages/opencode/src/file/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,26 @@ import { Global } from "../global"
export namespace File {
const log = Log.create({ service: "file" })

async function resolveRealPath(filepath: string): Promise<string> {
try {
return await fs.promises.realpath(filepath)
} catch (err) {
// If realpath fails (for example, the file doesn't exist yet), resolve the
// parent directory with realpath and append the basename. This ensures that
// all existing path components are fully resolved (and symlinks detected),
// avoiding an insecure lexical-only fallback.
const parentDir = path.dirname(filepath)
try {
const realParent = await fs.promises.realpath(parentDir)
return path.join(realParent, path.basename(filepath))
} catch {
// If the parent directory cannot be resolved safely, propagate the
// original error instead of falling back to an unchecked lexical path.
throw err
}
}
}

export const Info = z
.object({
path: z.string(),
Expand Down Expand Up @@ -429,15 +449,14 @@ export namespace File {
const project = Instance.project
const full = path.join(Instance.directory, file)

// TODO: Filesystem.contains is lexical only - symlinks inside the project can escape.
// TODO: On Windows, cross-drive paths bypass this check. Consider realpath canonicalization.
if (!Instance.containsPath(full)) {
const realFull = await resolveRealPath(full)
if (!Instance.containsPath(realFull)) {
throw new Error(`Access denied: path escapes project directory`)
}

// Fast path: check extension before any filesystem operations
if (isImageByExtension(file)) {
const bunFile = Bun.file(full)
const bunFile = Bun.file(realFull)
if (await bunFile.exists()) {
const buffer = await bunFile.arrayBuffer().catch(() => new ArrayBuffer(0))
const content = Buffer.from(buffer).toString("base64")
Expand All @@ -451,7 +470,7 @@ export namespace File {
return { type: "binary", content: "" }
}

const bunFile = Bun.file(full)
const bunFile = Bun.file(realFull)

if (!(await bunFile.exists())) {
return { type: "text", content: "" }
Expand Down Expand Up @@ -509,20 +528,19 @@ export namespace File {
}
const resolved = dir ? path.join(Instance.directory, dir) : Instance.directory

// TODO: Filesystem.contains is lexical only - symlinks inside the project can escape.
// TODO: On Windows, cross-drive paths bypass this check. Consider realpath canonicalization.
if (!Instance.containsPath(resolved)) {
const realResolved = await resolveRealPath(resolved)
if (!Instance.containsPath(realResolved)) {
throw new Error(`Access denied: path escapes project directory`)
}

const nodes: Node[] = []
for (const entry of await fs.promises
.readdir(resolved, {
.readdir(realResolved, {
withFileTypes: true,
})
.catch(() => [])) {
if (exclude.includes(entry.name)) continue
const fullPath = path.join(resolved, entry.name)
const fullPath = path.join(realResolved, entry.name)
const relativePath = path.relative(Instance.directory, fullPath)
const type = entry.isDirectory() ? "directory" : "file"
nodes.push({
Expand Down
7 changes: 5 additions & 2 deletions packages/opencode/src/global/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ if (version !== CACHE_VERSION) {
}),
),
)
} catch (e) {}
await Bun.file(path.join(Global.Path.cache, "version")).write(CACHE_VERSION)
await Bun.file(path.join(Global.Path.cache, "version")).write(CACHE_VERSION)
} catch (e) {
// Log to stderr since Log module may not be initialized yet (circular dependency)
console.error("[global] failed to clear cache:", e instanceof Error ? e.message : String(e))
}
}
6 changes: 3 additions & 3 deletions packages/opencode/src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -801,7 +801,7 @@ export namespace MCP {

// Register the callback BEFORE opening the browser to avoid race condition
// when the IdP has an active SSO session and redirects immediately
const callbackPromise = McpOAuthCallback.waitForCallback(oauthState)
const callbackPromise = McpOAuthCallback.waitForCallback(oauthState, mcpName)

try {
const subprocess = await open(authorizationUrl)
Expand All @@ -812,11 +812,11 @@ export namespace MCP {
await new Promise<void>((resolve, reject) => {
// Give the process a moment to fail if it's going to
const timeout = setTimeout(() => resolve(), 500)
subprocess.on("error", (error) => {
subprocess.once("error", (error) => {
clearTimeout(timeout)
reject(error)
})
subprocess.on("exit", (code) => {
subprocess.once("exit", (code) => {
if (code !== null && code !== 0) {
clearTimeout(timeout)
reject(new Error(`Browser open failed with exit code ${code}`))
Expand Down
17 changes: 10 additions & 7 deletions packages/opencode/src/mcp/oauth-callback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ interface PendingAuth {
resolve: (code: string) => void
reject: (error: Error) => void
timeout: ReturnType<typeof setTimeout>
mcpName: string
}

export namespace McpOAuthCallback {
Expand Down Expand Up @@ -136,7 +137,7 @@ export namespace McpOAuthCallback {
log.info("oauth callback server started", { port: OAUTH_CALLBACK_PORT })
}

export function waitForCallback(oauthState: string): Promise<string> {
export function waitForCallback(oauthState: string, mcpName: string): Promise<string> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
if (pendingAuths.has(oauthState)) {
Expand All @@ -145,16 +146,18 @@ export namespace McpOAuthCallback {
}
}, CALLBACK_TIMEOUT_MS)

pendingAuths.set(oauthState, { resolve, reject, timeout })
pendingAuths.set(oauthState, { resolve, reject, timeout, mcpName })
})
}

export function cancelPending(mcpName: string): void {
const pending = pendingAuths.get(mcpName)
if (pending) {
clearTimeout(pending.timeout)
pendingAuths.delete(mcpName)
pending.reject(new Error("Authorization cancelled"))
for (const [state, pending] of pendingAuths) {
if (pending.mcpName === mcpName) {
clearTimeout(pending.timeout)
pendingAuths.delete(state)
pending.reject(new Error("Authorization cancelled"))
return
}
}
}

Expand Down
69 changes: 47 additions & 22 deletions packages/opencode/src/patch/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -514,67 +514,92 @@ export namespace Patch {
return hasChanges ? diff : ""
}

// Path validation helper to prevent path traversal attacks
function validatePatchPath(filePath: string, cwd: string): string {
// Resolve the file path against cwd. If filePath is absolute, path.resolve
// will return the normalized absolute path; if it's relative, it will be
// resolved under cwd.
const resolved = path.resolve(cwd, filePath)
const normalizedCwd = path.resolve(cwd)

// Ensure the resolved path is within the working directory.
// Check both that it starts with cwd+sep OR equals cwd exactly.
if (!resolved.startsWith(normalizedCwd + path.sep) && resolved !== normalizedCwd) {
throw new Error(`Path traversal detected: ${filePath} escapes working directory`)
}

return resolved
}

// Apply hunks to filesystem
export async function applyHunksToFiles(hunks: Hunk[]): Promise<AffectedPaths> {
export async function applyHunksToFiles(hunks: Hunk[], cwd?: string): Promise<AffectedPaths> {
if (hunks.length === 0) {
throw new Error("No files were modified.")
}

const workdir = cwd || process.cwd()
const added: string[] = []
const modified: string[] = []
const deleted: string[] = []

for (const hunk of hunks) {
switch (hunk.type) {
case "add":
case "add": {
const resolvedPath = validatePatchPath(hunk.path, workdir)
// Create parent directories
const addDir = path.dirname(hunk.path)
const addDir = path.dirname(resolvedPath)
if (addDir !== "." && addDir !== "/") {
await fs.mkdir(addDir, { recursive: true })
}

await fs.writeFile(hunk.path, hunk.contents, "utf-8")
added.push(hunk.path)
log.info(`Added file: ${hunk.path}`)
await fs.writeFile(resolvedPath, hunk.contents, "utf-8")
added.push(resolvedPath)
log.info(`Added file: ${resolvedPath}`)
break
}

case "delete":
await fs.unlink(hunk.path)
deleted.push(hunk.path)
log.info(`Deleted file: ${hunk.path}`)
case "delete": {
const resolvedPath = validatePatchPath(hunk.path, workdir)
await fs.unlink(resolvedPath)
deleted.push(resolvedPath)
log.info(`Deleted file: ${resolvedPath}`)
break
}

case "update":
const fileUpdate = deriveNewContentsFromChunks(hunk.path, hunk.chunks)
case "update": {
const resolvedPath = validatePatchPath(hunk.path, workdir)
const fileUpdate = deriveNewContentsFromChunks(resolvedPath, hunk.chunks)

if (hunk.move_path) {
const resolvedMovePath = validatePatchPath(hunk.move_path, workdir)
// Handle file move
const moveDir = path.dirname(hunk.move_path)
const moveDir = path.dirname(resolvedMovePath)
if (moveDir !== "." && moveDir !== "/") {
await fs.mkdir(moveDir, { recursive: true })
}

await fs.writeFile(hunk.move_path, fileUpdate.content, "utf-8")
await fs.unlink(hunk.path)
modified.push(hunk.move_path)
log.info(`Moved file: ${hunk.path} -> ${hunk.move_path}`)
await fs.writeFile(resolvedMovePath, fileUpdate.content, "utf-8")
await fs.unlink(resolvedPath)
modified.push(resolvedMovePath)
log.info(`Moved file: ${resolvedPath} -> ${resolvedMovePath}`)
} else {
// Regular update
await fs.writeFile(hunk.path, fileUpdate.content, "utf-8")
modified.push(hunk.path)
log.info(`Updated file: ${hunk.path}`)
await fs.writeFile(resolvedPath, fileUpdate.content, "utf-8")
modified.push(resolvedPath)
log.info(`Updated file: ${resolvedPath}`)
}
break
}
}
}

return { added, modified, deleted }
}

// Main patch application function
export async function applyPatch(patchText: string): Promise<AffectedPaths> {
export async function applyPatch(patchText: string, cwd?: string): Promise<AffectedPaths> {
const { hunks } = parsePatch(patchText)
return applyHunksToFiles(hunks)
return applyHunksToFiles(hunks, cwd)
}

// Async version of maybeParseApplyPatchVerified
Expand Down
9 changes: 8 additions & 1 deletion packages/opencode/src/session/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -360,7 +360,14 @@ export namespace SessionProcessor {
message: retry,
next: Date.now() + delay,
})
await SessionRetry.sleep(delay, input.abort).catch(() => {})
try {
await SessionRetry.sleep(delay, input.abort)
} catch {
// If sleep was aborted, check if we should stop
if (input.abort.aborted) {
break
}
}
continue
}
input.assistantMessage.error = error
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/session/retry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ export namespace SessionRetry {
if (json.type === "error" && json.error?.code?.includes("rate_limit")) {
return "Rate Limited"
}
return JSON.stringify(json)
return undefined
} catch {
return undefined
}
Expand Down
Loading
Loading