From 4339ab676194df5eb71e404f16e43f79306b5dc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=86=A0=E8=BE=B0?= Date: Mon, 10 Aug 2026 12:31:06 +0800 Subject: [PATCH 01/10] fix(security): require confirmation for delete commands, including cmd /c del --- packages/opencode/src/agent/agent.ts | 9 +++++ packages/opencode/src/tool/bash.ts | 18 ++++++++-- packages/opencode/test/agent/agent.test.ts | 21 ++++++++++++ packages/opencode/test/tool/bash.test.ts | 38 ++++++++++++++++++++++ 4 files changed, 83 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index afcd51a1e..aa48dc935 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -126,6 +126,15 @@ export const layer = Layer.effect( "*.env.*": "ask", "*.env.example": "allow", }, + // Deleting files is irreversible, so require confirmation even though + // bash defaults to allow. Specific rules come after "*": "allow" so + // findLast makes them win (#2073). + bash: { + "*": "allow", + "rm *": "ask", + "del *": "ask", + "remove-item *": "ask", + }, }) const user = Permission.fromConfig(cfg.permission ?? {}) diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index ad306f608..a3b100538 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -36,6 +36,7 @@ const CWD = new Set(["cd", "push-location", "set-location"]) const FILES = new Set([ ...CWD, "rm", + "del", "cp", "mv", "mkdir", @@ -547,10 +548,21 @@ export const BashTool = Tool.define( for (const node of commands(root)) { const command = parts(node) const tokens = command.map((item) => item.text) - const cmd = ps ? tokens[0]?.toLowerCase() : tokens[0] + // Normalize `cmd /c ` (Windows cmd.exe) to `` so delete + // commands like `cmd /c del` are detected like their direct forms (#2073). + let cmd = ps ? tokens[0]?.toLowerCase() : tokens[0] + let patternTokens = tokens + let fileArgs = command + if (!ps && cmd === "cmd" && tokens[1]?.toLowerCase() === "/c" && tokens[2]) { + cmd = tokens[2]?.toLowerCase() + patternTokens = tokens.slice(2) + // Drop the `cmd /c` prefix from file args too, so the external-path + // check does not treat `/c` or `del` itself as a file argument. + fileArgs = command.slice(2) + } if (cmd && FILES.has(cmd)) { - for (const arg of pathArgs(command, ps)) { + for (const arg of pathArgs(fileArgs, ps)) { const resolved = yield* argPath(arg, cwd, ps, shell) log.info("resolved path", { arg, resolved }) if (!resolved || Instance.containsPath(resolved)) continue @@ -561,7 +573,7 @@ export const BashTool = Tool.define( if (tokens.length && (!cmd || !CWD.has(cmd))) { scan.patterns.add(source(node)) - scan.always.add(BashArity.prefix(tokens).join(" ") + " *") + scan.always.add(BashArity.prefix(patternTokens).join(" ") + " *") } if (isDelete(tokens, ps)) scan.deletes.add(source(node)) diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index 38e00ec4b..36fecfdaf 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -573,6 +573,27 @@ test("default permission includes doom_loop and external_directory as ask", asyn }) }) +test("build agent default bash permission asks for deletes, allows other bash", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const build = await load(tmp.path, (svc) => svc.get("build")) + expect(build).toBeDefined() + // Irreversible deletions require confirmation even though bash defaults + // to allow. `del *` also covers `cmd /c del` once the scanner normalizes + // it (#2073). + expect(Permission.evaluate("bash", "rm victim.txt", build!.permission).action).toBe("ask") + expect(Permission.evaluate("bash", "del victim.txt", build!.permission).action).toBe("ask") + expect(Permission.evaluate("bash", "remove-item victim.txt", build!.permission).action).toBe("ask") + // Non-delete bash remains allowed. + expect(Permission.evaluate("bash", "ls -la", build!.permission).action).toBe("allow") + expect(Permission.evaluate("bash", "bun test", build!.permission).action).toBe("allow") + expect(evalPerm(build, "bash")).toBe("allow") + }, + }) +}) + test("webfetch is allowed by default", async () => { await using tmp = await tmpdir() await Instance.provide({ diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts index 7f9f12863..6b6e42741 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/bash.test.ts @@ -488,6 +488,44 @@ describe("tool.bash permissions", () => { }) }) + each("treats `cmd /c del` like a direct delete (external_directory ask + `del` pattern)", async () => { + // Use the real system temp dir (NOT the fixture tmpdir, which lives inside + // the repo's git worktree and would count as "contained" by the instance). + const external = await fs.mkdtemp(path.join(os.tmpdir(), "mimocode-cmd-del-")) + try { + await Bun.write(path.join(external, "victim.txt"), "x") + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await initBash() + const requests: Array> = [] + const file = path.join(external, "victim.txt").replaceAll("\\", "/") + await Effect.runPromise( + bash.execute( + { + command: `cmd /c del ${file}`, + description: "Delete outside file via cmd", + }, + capture(requests), + ), + ) + // `cmd /c` is normalized away, so the external path is still detected + // and the ask pattern becomes `del *` (not `cmd *`) — #2073. + const extDirReq = requests.find((r) => r.permission === "external_directory") + expect(extDirReq).toBeDefined() + expect(extDirReq!.patterns).toContain(glob(path.join(path.dirname(file), "*"))) + const bashReq = requests.find((r) => r.permission === "bash") + expect(bashReq).toBeDefined() + expect(bashReq!.always).toContain("del *") + expect(bashReq!.always).not.toContain("cmd *") + }, + }) + } finally { + await fs.rm(external, { recursive: true, force: true }).catch(() => {}) + } + }) + if (process.platform === "win32") { if (bash) { test( From 53f1c54154d9f7336bb0d58aa55a6ed768be0d90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=86=A0=E8=BE=B0?= Date: Mon, 10 Aug 2026 12:49:21 +0800 Subject: [PATCH 02/10] fix(security): route cmd /c delete commands through the confirmation gate --- packages/opencode/src/tool/bash.ts | 51 +++++--- packages/opencode/test/tool/bash.test.ts | 157 +++++++++++++++++++++-- 2 files changed, 178 insertions(+), 30 deletions(-) diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index a3b100538..d05bccfeb 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -36,7 +36,10 @@ const CWD = new Set(["cd", "push-location", "set-location"]) const FILES = new Set([ ...CWD, "rm", + "rmdir", "del", + "erase", + "rd", "cp", "mv", "mkdir", @@ -354,20 +357,22 @@ const parse = Effect.fn("BashTool.parse")(function* (command: string, ps: boolea return tree.rootNode }) -const ask = Effect.fn("BashTool.ask")(function* (ctx: Tool.Context, scan: Scan) { - if (scan.dirs.size > 0) { - const globs = Array.from(scan.dirs).map((dir) => { - if (process.platform === "win32") return AppFileSystem.normalizePathPattern(path.join(dir, "*")) - return path.join(dir, "*") - }) - yield* ctx.ask({ - permission: "external_directory", - patterns: globs, - always: globs, - metadata: {}, - }) - } +const askExternalDirectory = Effect.fn("BashTool.askExternalDirectory")(function* (ctx: Tool.Context, scan: Scan) { + if (scan.dirs.size === 0) return + const globs = Array.from(scan.dirs).map((dir) => { + if (process.platform === "win32") return AppFileSystem.normalizePathPattern(path.join(dir, "*")) + return path.join(dir, "*") + }) + yield* ctx.ask({ + permission: "external_directory", + patterns: globs, + always: globs, + metadata: {}, + }) +}) +const ask = Effect.fn("BashTool.ask")(function* (ctx: Tool.Context, scan: Scan) { + yield* askExternalDirectory(ctx, scan) if (scan.patterns.size === 0) return yield* ctx.ask({ permission: "bash", @@ -553,7 +558,8 @@ export const BashTool = Tool.define( let cmd = ps ? tokens[0]?.toLowerCase() : tokens[0] let patternTokens = tokens let fileArgs = command - if (!ps && cmd === "cmd" && tokens[1]?.toLowerCase() === "/c" && tokens[2]) { + const normalized = !ps && tokens[0]?.toLowerCase() === "cmd" && tokens[1]?.toLowerCase() === "/c" && tokens[2] + if (normalized) { cmd = tokens[2]?.toLowerCase() patternTokens = tokens.slice(2) // Drop the `cmd /c` prefix from file args too, so the external-path @@ -572,11 +578,15 @@ export const BashTool = Tool.define( } if (tokens.length && (!cmd || !CWD.has(cmd))) { - scan.patterns.add(source(node)) + // The permission gate evaluates request.patterns, so a normalized + // `cmd /c ` must surface the inner command (e.g. `del /path`) + // rather than the opaque `cmd /c del /path` form, or the `del *` ask + // rule would never match (#2073). + scan.patterns.add(normalized ? patternTokens.join(" ") : source(node)) scan.always.add(BashArity.prefix(patternTokens).join(" ") + " *") } - if (isDelete(tokens, ps)) scan.deletes.add(source(node)) + if (isDelete(patternTokens, ps)) scan.deletes.add(source(node)) } return scan @@ -884,10 +894,15 @@ export const BashTool = Tool.define( // the delete UI shows the full command (including any external // paths it touches), so a separate bash/external_directory // prompt would just be a second confirmation of the same thing. - // MIMOCODE_AUTO_APPROVE_DELETE trusts deletes and falls back to - // the regular ask (where a `bash: deny` rule still blocks). + // MIMOCODE_AUTO_APPROVE_DELETE trusts the model with deletes and + // skips that confirmation too — but it must NOT fall through to + // the regular bash ask, which would now re-prompt via the + // default `rm *`/`del *`/`remove-item *` ask rules. External + // directory access is still guarded either way (#2073). if (scan.deletes.size > 0 && !Flag.MIMOCODE_AUTO_APPROVE_DELETE) { yield* askDelete(ctx, scan, params.command) + } else if (scan.deletes.size > 0) { + yield* askExternalDirectory(ctx, scan) } else { yield* ask(ctx, scan) } diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts index 6b6e42741..631a90e1f 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/bash.test.ts @@ -488,37 +488,72 @@ describe("tool.bash permissions", () => { }) }) - each("treats `cmd /c del` like a direct delete (external_directory ask + `del` pattern)", async () => { + each("routes `cmd /c del ` through the forced bash_delete ask", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "victim.txt"), "x") + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await initBash() + const requests: Array> = [] + await Effect.runPromise( + bash.execute( + { + command: "cmd /c del victim.txt", + description: "Delete via cmd", + }, + capture(requests), + ), + ) + // `cmd /c` is normalized to `del`, so the delete routes through the + // forced bash_delete ask like a direct `del` (not the raw-pattern gate, + // where `cmd /c del ...` would fall through `*: allow`) — #2073. + const deleteReq = requests.find((r) => r.permission === "bash_delete") + expect(deleteReq).toBeDefined() + expect(deleteReq!.patterns).toContain("cmd /c del victim.txt") + expect(deleteReq!.metadata.command).toBe("cmd /c del victim.txt") + expect(requests.find((r) => r.permission === "bash")).toBeUndefined() + expect(requests.find((r) => r.permission === "external_directory")).toBeUndefined() + }, + }) + }) + + each("surfaces the normalized inner command in the bash ask pattern", async () => { // Use the real system temp dir (NOT the fixture tmpdir, which lives inside // the repo's git worktree and would count as "contained" by the instance). - const external = await fs.mkdtemp(path.join(os.tmpdir(), "mimocode-cmd-del-")) + const external = await fs.mkdtemp(path.join(os.tmpdir(), "mimocode-cmd-cat-")) try { - await Bun.write(path.join(external, "victim.txt"), "x") + await Bun.write(path.join(external, "notes.txt"), "x") await using tmp = await tmpdir() await Instance.provide({ directory: tmp.path, fn: async () => { const bash = await initBash() const requests: Array> = [] - const file = path.join(external, "victim.txt").replaceAll("\\", "/") + const file = path.join(external, "notes.txt").replaceAll("\\", "/") await Effect.runPromise( bash.execute( { - command: `cmd /c del ${file}`, - description: "Delete outside file via cmd", + command: `cmd /c cat ${file}`, + description: "Read outside file via cmd", }, capture(requests), ), ) - // `cmd /c` is normalized away, so the external path is still detected - // and the ask pattern becomes `del *` (not `cmd *`) — #2073. - const extDirReq = requests.find((r) => r.permission === "external_directory") - expect(extDirReq).toBeDefined() - expect(extDirReq!.patterns).toContain(glob(path.join(path.dirname(file), "*"))) + // `cat` is a FILES command but not a delete, so the regular bash ask + // fires — and the gate must see the normalized `cat ` (not the + // opaque `cmd /c cat `) so `cat *` rules apply (#2073). const bashReq = requests.find((r) => r.permission === "bash") expect(bashReq).toBeDefined() - expect(bashReq!.always).toContain("del *") + expect(bashReq!.patterns).toContain(`cat ${file}`) + expect(bashReq!.always).toContain("cat *") expect(bashReq!.always).not.toContain("cmd *") + const extDirReq = requests.find((r) => r.permission === "external_directory") + expect(extDirReq).toBeDefined() + expect(extDirReq!.patterns).toContain(glob(path.join(path.dirname(file), "*"))) }, }) } finally { @@ -526,6 +561,104 @@ describe("tool.bash permissions", () => { } }) + each("routes `cmd /c rd ` through the forced bash_delete ask", async () => { + const external = await fs.mkdtemp(path.join(os.tmpdir(), "mimocode-cmd-rd-")) + try { + await Bun.write(path.join(external, "victim.txt"), "x") + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await initBash() + const requests: Array> = [] + const file = path.join(external, "victim.txt").replaceAll("\\", "/") + await Effect.runPromise( + bash.execute( + { + command: `cmd /c rd ${file}`, + description: "Remove dir via cmd", + }, + capture(requests), + ), + ) + // `rd`/`erase`/`rmdir` are deletes too — `cmd /c rd` must reach the + // forced bash_delete ask, and the external path is shown in the + // delete UI (#2073). + const deleteReq = requests.find((r) => r.permission === "bash_delete") + expect(deleteReq).toBeDefined() + expect(deleteReq!.patterns).toContain(`cmd /c rd ${file}`) + expect(deleteReq!.metadata.command).toBe(`cmd /c rd ${file}`) + expect(requests.find((r) => r.permission === "bash")).toBeUndefined() + }, + }) + } finally { + await fs.rm(external, { recursive: true, force: true }).catch(() => {}) + } + }) + + test("MIMOCODE_AUTO_APPROVE_DELETE skips the ask for `cmd /c del`", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "victim.txt"), "x") + }, + }) + // The flag is snapshotted at module load, so drive a fresh process with the + // env var set and assert no permission request fires for an internal delete. + const script = ` +import { Effect, Layer, ManagedRuntime } from "effect" +import * as CrossSpawnSpawner from "./src/effect/cross-spawn-spawner" +import { AppFileSystem } from "@mimo-ai/shared/filesystem" +import { Plugin } from "./src/plugin" +import { Truncate } from "./src/tool" +import { Agent } from "./src/agent/agent" +import { Git } from "./src/git" +import { BashTool } from "./src/tool/bash" +import { Instance } from "./src/project/instance" +import { SessionID, MessageID } from "./src/session/schema" + +const runtime = ManagedRuntime.make( + Layer.mergeAll( + CrossSpawnSpawner.defaultLayer, + AppFileSystem.defaultLayer, + Plugin.defaultLayer, + Truncate.defaultLayer, + Agent.defaultLayer, + Git.defaultLayer, + ), +) + +const requests: any[] = [] +const ctx = { + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make(""), + callID: "", + agent: "build", + abort: AbortSignal.any([]), + messages: [], + metadata: () => Effect.void, + ask: (req: any) => Effect.sync(() => requests.push(req)), +} + +await Instance.provide({ + directory: process.env.INSTANCE_DIR!, + fn: async () => { + const bash = await runtime.runPromise(BashTool.pipe(Effect.flatMap((info: any) => info.init()))) + await runtime.runPromise(bash.execute({ command: "cmd /c del victim.txt", description: "del via cmd" }, ctx as any)) + }, +}) + +process.stdout.write(JSON.stringify(requests)) +process.exit(0) +` + const result = Bun.spawnSync({ + cmd: [process.execPath, "-e", script], + cwd: path.join(__dirname, "../.."), + env: { ...process.env, MIMOCODE_AUTO_APPROVE_DELETE: "true", INSTANCE_DIR: tmp.path }, + }) + expect(result.exitCode).toBe(0) + expect(JSON.parse(result.stdout.toString())).toEqual([]) + }) + if (process.platform === "win32") { if (bash) { test( From a9e19547e5f3e35d51f6bc7d07b3406c16404e57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=86=A0=E8=BE=B0?= Date: Mon, 10 Aug 2026 12:59:11 +0800 Subject: [PATCH 03/10] fix(security): normalize cmd.exe /c delete wraps too --- packages/opencode/src/tool/bash.ts | 6 +- packages/opencode/test/tool/bash.test.ts | 76 +++++++++++++++++------- 2 files changed, 61 insertions(+), 21 deletions(-) diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index d05bccfeb..5646de5c9 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -555,10 +555,14 @@ export const BashTool = Tool.define( const tokens = command.map((item) => item.text) // Normalize `cmd /c ` (Windows cmd.exe) to `` so delete // commands like `cmd /c del` are detected like their direct forms (#2073). + // `cmd`/`cmd.exe` is a native executable invoked identically from bash + // and PowerShell (both parsers tokenize it the same), so this applies + // on any host — case-insensitive with the `.exe` suffix optional. let cmd = ps ? tokens[0]?.toLowerCase() : tokens[0] let patternTokens = tokens let fileArgs = command - const normalized = !ps && tokens[0]?.toLowerCase() === "cmd" && tokens[1]?.toLowerCase() === "/c" && tokens[2] + const normalized = + /^cmd(?:\.exe)?$/i.test(tokens[0] ?? "") && tokens[1]?.toLowerCase() === "/c" && Boolean(tokens[2]) if (normalized) { cmd = tokens[2]?.toLowerCase() patternTokens = tokens.slice(2) diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts index 631a90e1f..90143d8e6 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/bash.test.ts @@ -488,7 +488,7 @@ describe("tool.bash permissions", () => { }) }) - each("routes `cmd /c del ` through the forced bash_delete ask", async () => { + each("routes `cmd /c del` (incl. `cmd.exe` and mixed-case) through the forced bash_delete ask", async () => { await using tmp = await tmpdir({ init: async (dir) => { await Bun.write(path.join(dir, "victim.txt"), "x") @@ -498,29 +498,65 @@ describe("tool.bash permissions", () => { directory: tmp.path, fn: async () => { const bash = await initBash() - const requests: Array> = [] - await Effect.runPromise( - bash.execute( - { - command: "cmd /c del victim.txt", - description: "Delete via cmd", - }, - capture(requests), - ), - ) - // `cmd /c` is normalized to `del`, so the delete routes through the - // forced bash_delete ask like a direct `del` (not the raw-pattern gate, - // where `cmd /c del ...` would fall through `*: allow`) — #2073. - const deleteReq = requests.find((r) => r.permission === "bash_delete") - expect(deleteReq).toBeDefined() - expect(deleteReq!.patterns).toContain("cmd /c del victim.txt") - expect(deleteReq!.metadata.command).toBe("cmd /c del victim.txt") - expect(requests.find((r) => r.permission === "bash")).toBeUndefined() - expect(requests.find((r) => r.permission === "external_directory")).toBeUndefined() + // `cmd`/`cmd.exe` (any case) is normalized to the inner `del` command, so + // each routes through the forced bash_delete ask like a direct `del` + // (not the raw-pattern gate, where `cmd /c del ...` would fall through + // `*: allow`) — #2073. + for (const command of ["cmd /c del victim.txt", "cmd.exe /c del victim.txt", "CMD.EXE /c del victim.txt"]) { + const requests: Array> = [] + await Effect.runPromise( + bash.execute( + { + command, + description: "Delete via cmd", + }, + capture(requests), + ), + ) + const deleteReq = requests.find((r) => r.permission === "bash_delete") + expect(deleteReq).toBeDefined() + expect(deleteReq!.patterns).toContain(command) + expect(deleteReq!.metadata.command).toBe(command) + expect(requests.find((r) => r.permission === "bash")).toBeUndefined() + expect(requests.find((r) => r.permission === "external_directory")).toBeUndefined() + } }, }) }) + test( + "normalizes `cmd /c del` on a PowerShell host too", + withShell({ label: "powershell", shell: "pwsh" }, async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "victim.txt"), "x") + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await initBash() + const err = new Error("stop after permission") + const requests: Array> = [] + await expect( + Effect.runPromise( + bash.execute( + { command: "cmd /c del victim.txt", description: "Delete via cmd" }, + capture(requests, err), + ), + ), + ).rejects.toThrow(err.message) + // `cmd`/`cmd.exe` is a native executable, so the `cmd /c` wrap must be + // normalized on a PowerShell host too — not bypassed by `*: allow` + // (#2073). + const deleteReq = requests.find((r) => r.permission === "bash_delete") + expect(deleteReq).toBeDefined() + expect(deleteReq!.patterns).toContain("cmd /c del victim.txt") + }, + }) + }), + ) + each("surfaces the normalized inner command in the bash ask pattern", async () => { // Use the real system temp dir (NOT the fixture tmpdir, which lives inside // the repo's git worktree and would count as "contained" by the instance). From 1532b88904eaaae4c4191901ce5baa31481cc685 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=86=A0=E8=BE=B0?= Date: Tue, 11 Aug 2026 12:37:47 +0800 Subject: [PATCH 04/10] fix(security): redact MCP access tokens from debug output --- packages/opencode/src/cli/cmd/mcp.ts | 48 +++++++++++-------- .../opencode/test/cli/mcp-auth-status.test.ts | 17 +++++++ 2 files changed, 45 insertions(+), 20 deletions(-) create mode 100644 packages/opencode/test/cli/mcp-auth-status.test.ts diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index 6b59e5b7d..6a1e8c190 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -42,6 +42,33 @@ function getAuthStatusText(status: MCP.AuthStatus): string { } } +export function mcpDebugAuthLines(authStatus: MCP.AuthStatus, entry?: McpAuth.Entry) { + return [ + `Auth status: ${getAuthStatusIcon(authStatus)} ${getAuthStatusText(authStatus)}`, + ...(!entry?.tokens + ? [] + : [ + ` Access token: present`, + ...(entry.tokens.expiresAt + ? [ + ` Expires: ${new Date(entry.tokens.expiresAt * 1000).toISOString()} ${ + entry.tokens.expiresAt < Date.now() / 1000 ? "(EXPIRED)" : "" + }`, + ] + : []), + ...(entry.tokens.refreshToken ? [` Refresh token: present`] : []), + ]), + ...(!entry?.clientInfo + ? [] + : [ + ` Client ID: ${entry.clientInfo.clientId}`, + ...(entry.clientInfo.clientSecretExpiresAt + ? [` Client secret expires: ${new Date(entry.clientInfo.clientSecretExpiresAt * 1000).toISOString()}`] + : []), + ]), + ] +} + type McpEntry = NonNullable[string] type McpConfigured = ConfigMCP.Info @@ -675,26 +702,7 @@ export const McpDebugCommand = cmd({ } }), ) - prompts.log.info(`Auth status: ${getAuthStatusIcon(authStatus)} ${getAuthStatusText(authStatus)}`) - - if (entry?.tokens) { - prompts.log.info(` Access token: ${entry.tokens.accessToken.substring(0, 20)}...`) - if (entry.tokens.expiresAt) { - const expiresDate = new Date(entry.tokens.expiresAt * 1000) - const isExpired = entry.tokens.expiresAt < Date.now() / 1000 - prompts.log.info(` Expires: ${expiresDate.toISOString()} ${isExpired ? "(EXPIRED)" : ""}`) - } - if (entry.tokens.refreshToken) { - prompts.log.info(` Refresh token: present`) - } - } - if (entry?.clientInfo) { - prompts.log.info(` Client ID: ${entry.clientInfo.clientId}`) - if (entry.clientInfo.clientSecretExpiresAt) { - const expiresDate = new Date(entry.clientInfo.clientSecretExpiresAt * 1000) - prompts.log.info(` Client secret expires: ${expiresDate.toISOString()}`) - } - } + mcpDebugAuthLines(authStatus, entry).forEach((line) => prompts.log.info(line)) const spinner = prompts.spinner() spinner.start("Testing connection...") diff --git a/packages/opencode/test/cli/mcp-auth-status.test.ts b/packages/opencode/test/cli/mcp-auth-status.test.ts new file mode 100644 index 000000000..2593b4e26 --- /dev/null +++ b/packages/opencode/test/cli/mcp-auth-status.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, test } from "bun:test" +import { mcpDebugAuthLines } from "../../src/cli/cmd/mcp" + +describe("mcp auth status output", () => { + test("does not print partial access tokens", () => { + const accessToken = "mcp_secret_access_token_1234567890" + const output = mcpDebugAuthLines("authenticated", { + tokens: { + accessToken, + }, + }).join("\n") + + expect(output).toContain("Access token: present") + expect(output).not.toContain(accessToken) + expect(output).not.toContain(accessToken.slice(0, 20)) + }) +}) From bacecf3d4649bb7b03d460b25d19ab29fc972537 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=86=A0=E8=BE=B0?= Date: Tue, 11 Aug 2026 12:40:05 +0800 Subject: [PATCH 05/10] fix(security): use timing-safe HTTP API password comparison --- .../server/routes/instance/httpapi/server.ts | 3 ++- packages/opencode/src/util/crypto.ts | 13 +++++++++++++ .../opencode/test/server/httpapi-auth.test.ts | 17 +++++++++++++++++ 3 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/src/util/crypto.ts create mode 100644 packages/opencode/test/server/httpapi-auth.test.ts diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 731cfa087..91ebfbbcf 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -9,6 +9,7 @@ import { InstanceBootstrap } from "@/project/bootstrap" import { Instance } from "@/project/instance" import { lazy } from "@/util/lazy" import { Filesystem } from "@/util" +import { timingSafeStringEqual } from "@/util/crypto" import { ConfigApi, configHandlers } from "./config" import { PermissionApi, permissionHandlers } from "./permission" import { ProjectApi, projectHandlers } from "./project" @@ -77,7 +78,7 @@ const auth = Layer.succeed( if (credential.username !== user) { return yield* new Unauthorized({ message: "Unauthorized" }) } - if (Redacted.value(credential.password) !== Flag.MIMOCODE_SERVER_PASSWORD) { + if (!timingSafeStringEqual(Redacted.value(credential.password), Flag.MIMOCODE_SERVER_PASSWORD)) { return yield* new Unauthorized({ message: "Unauthorized" }) } return yield* effect diff --git a/packages/opencode/src/util/crypto.ts b/packages/opencode/src/util/crypto.ts new file mode 100644 index 000000000..9ace2b3ae --- /dev/null +++ b/packages/opencode/src/util/crypto.ts @@ -0,0 +1,13 @@ +import { timingSafeEqual } from "node:crypto" + +export function timingSafeStringEqual(input: string, expected: string) { + const inputBytes = Buffer.from(input) + const expectedBytes = Buffer.from(expected) + + if (inputBytes.byteLength !== expectedBytes.byteLength) { + timingSafeEqual(expectedBytes, expectedBytes) + return false + } + + return timingSafeEqual(inputBytes, expectedBytes) +} diff --git a/packages/opencode/test/server/httpapi-auth.test.ts b/packages/opencode/test/server/httpapi-auth.test.ts new file mode 100644 index 000000000..32961839d --- /dev/null +++ b/packages/opencode/test/server/httpapi-auth.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, test } from "bun:test" +import { timingSafeStringEqual } from "../../src/util/crypto" + +describe("experimental http api authorization", () => { + test("compares server passwords with a timing-safe helper", async () => { + expect(timingSafeStringEqual("secret", "secret")).toBe(true) + expect(timingSafeStringEqual("secret", "wrong!")).toBe(false) + expect(timingSafeStringEqual("secret", "secret-extra")).toBe(false) + + const source = await Bun.file( + new URL("../../src/server/routes/instance/httpapi/server.ts", import.meta.url), + ).text() + + expect(source).toContain("timingSafeStringEqual") + expect(source).not.toContain("Redacted.value(credential.password) !== Flag.MIMOCODE_SERVER_PASSWORD") + }) +}) From 4649b374f2370a605dcd3cd11a5cb3d3e96ad4d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=86=A0=E8=BE=B0?= Date: Tue, 11 Aug 2026 12:43:07 +0800 Subject: [PATCH 06/10] docs(security): point advisory link to MiMo Code --- SECURITY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SECURITY.md b/SECURITY.md index e7e59f4a2..a45fa3b3d 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -38,7 +38,7 @@ Server mode is opt-in only. When enabled, set `OPENCODE_SERVER_PASSWORD` to requ We appreciate your efforts to responsibly disclose your findings, and will make every effort to acknowledge your contributions. -To report a security issue, please use the GitHub Security Advisory ["Report a Vulnerability"](https://github.com/anomalyco/opencode/security/advisories/new) tab. +To report a security issue, please use the GitHub Security Advisory ["Report a Vulnerability"](https://github.com/XiaomiMiMo/MiMo-Code/security/advisories/new) tab. The team will send a response indicating the next steps in handling your report. After the initial reply to your report, the security team will keep you informed of the progress towards a fix and full announcement, and may ask for additional information or guidance. From 42260dc5e3444c13a841e8d5d611a120d78311b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=86=A0=E8=BE=B0?= Date: Tue, 11 Aug 2026 12:48:09 +0800 Subject: [PATCH 07/10] fix(security): guard PowerShell output paths --- packages/opencode/src/tool/bash.ts | 47 +++++++++++++-- packages/opencode/test/tool/bash.test.ts | 76 ++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 5646de5c9..0a5c8cbac 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -58,8 +58,11 @@ const FILES = new Set([ "remove-item", "new-item", "rename-item", + "invoke-webrequest", + "out-file", + "start-bitstransfer", ]) -const FLAGS = new Set(["-destination", "-literalpath", "-path"]) +const FLAGS = new Set(["-destination", "-filepath", "-literalpath", "-outfile", "-path"]) const SWITCHES = new Set(["-confirm", "-debug", "-force", "-nonewline", "-recurse", "-verbose", "-whatif"]) export function bashDescription(gpt = false) { @@ -191,6 +194,30 @@ function commands(node: Node) { return node.descendantsOfType("command").filter((child): child is Node => Boolean(child)) } +function redirectionArgs(node: Node) { + return [ + ...node + .descendantsOfType("file_redirect") + .filter((child): child is Node => Boolean(child)) + .filter((child) => !child.text.trimStart().startsWith("<<")) + .flatMap((child) => + Array.from({ length: child.childCount }, (_, i) => child.child(i)) + .filter((item): item is Node => Boolean(item)) + .filter((item) => item.type === "word" || item.type === "string" || item.type === "raw_string") + .map((item) => item.text), + ), + ...node + .descendantsOfType("redirection") + .filter((child): child is Node => Boolean(child)) + .flatMap((child) => + child + .descendantsOfType("redirected_file_name") + .filter((item): item is Node => Boolean(item)) + .map((item) => item.text.trim()), + ), + ] +} + // Returns true when `tokens` (the flat argv of a single command node) invokes // an irreversible deletion — either a direct removal command (rm, remove-item, // …) or a destructive git subcommand (git reset --hard, git clean -f, …). @@ -550,6 +577,14 @@ export const BashTool = Tool.define( deletes: new Set(), } + const addExternalPath = Effect.fn("BashTool.collect.addExternalPath")(function* (arg: string) { + const resolved = yield* argPath(arg, cwd, ps, shell) + log.info("resolved path", { arg, resolved }) + if (!resolved || Instance.containsPath(resolved)) return + const dir = (yield* fs.isDir(resolved)) ? resolved : path.dirname(resolved) + scan.dirs.add(dir) + }) + for (const node of commands(root)) { const command = parts(node) const tokens = command.map((item) => item.text) @@ -573,11 +608,7 @@ export const BashTool = Tool.define( if (cmd && FILES.has(cmd)) { for (const arg of pathArgs(fileArgs, ps)) { - const resolved = yield* argPath(arg, cwd, ps, shell) - log.info("resolved path", { arg, resolved }) - if (!resolved || Instance.containsPath(resolved)) continue - const dir = (yield* fs.isDir(resolved)) ? resolved : path.dirname(resolved) - scan.dirs.add(dir) + yield* addExternalPath(arg) } } @@ -593,6 +624,10 @@ export const BashTool = Tool.define( if (isDelete(patternTokens, ps)) scan.deletes.add(source(node)) } + for (const arg of redirectionArgs(root)) { + yield* addExternalPath(arg) + } + return scan }) diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts index 90143d8e6..e11559fdf 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/bash.test.ts @@ -695,6 +695,82 @@ process.exit(0) expect(JSON.parse(result.stdout.toString())).toEqual([]) }) + test( + "asks for external_directory permission for PowerShell OutFile env paths", + withShell({ label: "pwsh", shell: path.join(os.tmpdir(), "pwsh") }, async () => { + await using outerTmp = await tmpdir() + await using tmp = await tmpdir({ git: true }) + const key = "MIMOCODE_TEST_OUTFILE_DIR" + const prev = process.env[key] + process.env[key] = outerTmp.path + try { + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await initBash() + const err = new Error("stop after permission") + const requests: Array> = [] + await expect( + Effect.runPromise( + bash.execute( + { + command: `"payload" | Out-File -FilePath $env:${key}/payload.txt`, + description: "Write file to env path", + }, + capture(requests, err), + ), + ), + ).rejects.toThrow(err.message) + const extDirReq = requests.find((r) => r.permission === "external_directory") + expect(extDirReq).toBeDefined() + expect(extDirReq!.patterns).toContain(glob(path.join(outerTmp.path, "*"))) + }, + }) + } finally { + if (prev === undefined) delete process.env[key] + else process.env[key] = prev + } + }), + ) + + test( + "asks for external_directory permission for PowerShell redirection env paths", + withShell({ label: "pwsh", shell: path.join(os.tmpdir(), "pwsh") }, async () => { + await using outerTmp = await tmpdir() + await using tmp = await tmpdir({ git: true }) + const key = "MIMOCODE_TEST_REDIRECT_DIR" + const prev = process.env[key] + process.env[key] = outerTmp.path + try { + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await initBash() + const err = new Error("stop after permission") + const requests: Array> = [] + await expect( + Effect.runPromise( + bash.execute( + { + command: `Write-Output payload > $env:${key}/payload.txt`, + description: "Redirect output to env path", + }, + capture(requests, err), + ), + ), + ).rejects.toThrow(err.message) + const extDirReq = requests.find((r) => r.permission === "external_directory") + expect(extDirReq).toBeDefined() + expect(extDirReq!.patterns).toContain(glob(path.join(outerTmp.path, "*"))) + }, + }) + } finally { + if (prev === undefined) delete process.env[key] + else process.env[key] = prev + } + }), + ) + if (process.platform === "win32") { if (bash) { test( From 38151dadb038d758081cf30078170b457c42cd49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=86=A0=E8=BE=B0?= Date: Tue, 11 Aug 2026 13:06:13 +0800 Subject: [PATCH 08/10] fix(compaction): reuse parent prefix for cache efficiency --- packages/opencode/src/session/compaction.ts | 41 +++++++++++++++++-- .../test/session/compaction-prefix.test.ts | 9 ++++ 2 files changed, 47 insertions(+), 3 deletions(-) create mode 100644 packages/opencode/test/session/compaction-prefix.test.ts diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 97daa217d..6b7029c88 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -14,10 +14,17 @@ import { Config } from "@/config" import { NotFoundError } from "@/storage" import { ModelID, ProviderID } from "@/provider/schema" import { Effect, Layer, Context } from "effect" +import { FetchHttpClient } from "effect/unstable/http" import { InstanceState } from "@/effect" import { isOverflow as overflow, usable } from "./overflow" import { makeRuntime } from "@/effect/run-service" import { fn } from "@/util/fn" +import { buildLLMRequestPrefix } from "./llm-request-prefix" +import { SystemPrompt } from "./system" +import { Instruction } from "./instruction" +import { LLM } from "./llm" +import { ToolRegistry } from "../tool" +import { AppFileSystem } from "@mimo-ai/shared/filesystem" const log = Log.create({ service: "session.compaction" }) @@ -108,6 +115,10 @@ export const layer: Layer.Layer< | Plugin.Service | SessionProcessor.Service | Provider.Service + | SystemPrompt.Service + | Instruction.Service + | LLM.Service + | ToolRegistry.Service > = Layer.effect( Service, Effect.gen(function* () { @@ -118,6 +129,10 @@ export const layer: Layer.Layer< const plugin = yield* Plugin.Service const processors = yield* SessionProcessor.Service const provider = yield* Provider.Service + const system = yield* SystemPrompt.Service + const instruction = yield* Instruction.Service + const llm = yield* LLM.Service + const toolRegistry = yield* ToolRegistry.Service const isOverflow = Effect.fn("SessionCompaction.isOverflow")(function* (input: { tokens: MessageV2.Assistant["tokens"] @@ -333,6 +348,18 @@ export const layer: Layer.Layer< const msgs = structuredClone(selected.head) yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs }) const modelMessages = yield* MessageV2.toModelMessagesEffect(msgs, model, { stripMedia: true }) + const parentAgent = yield* agents.get(userMessage.agent) + const [env, instructions] = yield* Effect.all([ + system.environment(model, userMessage.time.created), + instruction.system().pipe(Effect.orDie), + ]) + const prefix = yield* buildLLMRequestPrefix({ + sessionID: input.sessionID, + agent: parentAgent, + model, + msgs, + additions: [...env, ...instructions.content], + }).pipe(Effect.provideService(LLM.Service, llm), Effect.provideService(ToolRegistry.Service, toolRegistry)) const ctx = yield* InstanceState.context const msg: MessageV2.Assistant = { id: MessageID.ascending(), @@ -369,10 +396,12 @@ export const layer: Layer.Layer< }) const result = yield* processor.process({ user: userMessage, - agent, + agent: parentAgent, sessionID: input.sessionID, - tools: {}, - system: [], + tools: prefix.tools, + system: [...env, ...instructions.content], + prebuiltSystem: prefix.system, + toolChoice: "none", messages: [ ...modelMessages, { @@ -544,6 +573,12 @@ export const layer: Layer.Layer< export const defaultLayer = Layer.suspend(() => layer.pipe( + Layer.provide(ToolRegistry.defaultLayer), + Layer.provide(LLM.defaultLayer), + Layer.provide(Instruction.layer), + Layer.provide(FetchHttpClient.layer), + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(SystemPrompt.defaultLayer), Layer.provide(Provider.defaultLayer), Layer.provide(Session.defaultLayer), Layer.provide(SessionProcessor.defaultLayer), diff --git a/packages/opencode/test/session/compaction-prefix.test.ts b/packages/opencode/test/session/compaction-prefix.test.ts new file mode 100644 index 000000000..8d4f23714 --- /dev/null +++ b/packages/opencode/test/session/compaction-prefix.test.ts @@ -0,0 +1,9 @@ +import { expect, test } from "bun:test" + +test("compaction reuses the parent request prefix and disables tool calls", async () => { + const source = await Bun.file(new URL("../../src/session/compaction.ts", import.meta.url)).text() + + expect(source).toContain("buildLLMRequestPrefix") + expect(source).toContain("prebuiltSystem: prefix.system") + expect(source).toContain('toolChoice: "none"') +}) From 1820f33c0a09f8b60abf3b220fe52499565254ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=86=A0=E8=BE=B0?= Date: Tue, 11 Aug 2026 13:11:40 +0800 Subject: [PATCH 09/10] fix(tui): align autocomplete labels by display width --- .../cli/cmd/tui/component/prompt/autocomplete.tsx | 14 +++++++++----- .../test/cli/cmd/tui/autocomplete-display.test.ts | 12 ++++++++++++ 2 files changed, 21 insertions(+), 5 deletions(-) create mode 100644 packages/opencode/test/cli/cmd/tui/autocomplete-display.test.ts diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index cb27edc81..0699ea510 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -1,7 +1,6 @@ import type { BoxRenderable, TextareaRenderable, KeyEvent, ScrollBoxRenderable } from "@opentui/core" import { pathToFileURL } from "bun" import fuzzysort from "fuzzysort" -import { firstBy } from "remeda" import { createMemo, createResource, createEffect, onMount, onCleanup, Index, Show, createSignal } from "solid-js" import { createStore } from "solid-js/store" import { useSDK } from "@tui/context/sdk" @@ -23,6 +22,11 @@ import { useFrecency } from "./frecency" import { detectTrigger, exactSubmitOption } from "./autocomplete-detect" import { charAfterCursor, tokenEndWidth } from "./offset" +export function padAutocompleteDisplay(labels: string[]) { + const max = Math.max(...labels.map((label) => Bun.stringWidth(label)), 0) + return labels.map((label) => label + " ".repeat(Math.max(0, max + 2 - Bun.stringWidth(label)))) +} + function removeLineRange(input: string) { const hashIndex = input.lastIndexOf("#") return hashIndex !== -1 ? input.substring(0, hashIndex) : input @@ -424,11 +428,11 @@ export function Autocomplete(props: { results.sort((a, b) => a.display.localeCompare(b.display)) - const max = firstBy(results, [(x) => x.display.length, "desc"])?.display.length - if (!max) return results - return results.map((item) => ({ + const displays = padAutocompleteDisplay(results.map((item) => item.display)) + if (!displays.length) return results + return results.map((item, index) => ({ ...item, - display: item.display.padEnd(max + 2), + display: displays[index]!, })) }) diff --git a/packages/opencode/test/cli/cmd/tui/autocomplete-display.test.ts b/packages/opencode/test/cli/cmd/tui/autocomplete-display.test.ts new file mode 100644 index 000000000..8fb860019 --- /dev/null +++ b/packages/opencode/test/cli/cmd/tui/autocomplete-display.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, test } from "bun:test" +import { padAutocompleteDisplay } from "../../../../src/cli/cmd/tui/component/prompt/autocomplete" + +describe("padAutocompleteDisplay", () => { + test("pads CJK labels by terminal display width", () => { + const result = padAutocompleteDisplay(["/help", "/前端设计"]) + + expect(result.map((item) => Bun.stringWidth(item))).toEqual([11, 11]) + expect(result[0]).toBe("/help ") + expect(result[1]).toBe("/前端设计 ") + }) +}) From 421e8525729bef84982406b7f102bdab54a763bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=86=A0=E8=BE=B0?= Date: Tue, 11 Aug 2026 13:16:47 +0800 Subject: [PATCH 10/10] fix(websearch): route Xiaomi provider variants to MiMo backend --- packages/opencode/src/tool/websearch/index.ts | 6 +++++- packages/opencode/test/tool/websearch-routing.test.ts | 10 ++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/test/tool/websearch-routing.test.ts diff --git a/packages/opencode/src/tool/websearch/index.ts b/packages/opencode/src/tool/websearch/index.ts index a8f66fafa..20e07039a 100644 --- a/packages/opencode/src/tool/websearch/index.ts +++ b/packages/opencode/src/tool/websearch/index.ts @@ -12,6 +12,10 @@ const WEBFETCH_FALLBACK = "Web search unavailable. Use `webfetch` with a relevant URL instead, or enable the Web Search plugin at https://platform.xiaomimimo.com/console/plugin." const MAX_TIMEOUT = 120 * 1000 // 2 minutes +export function usesMimoWebsearch(providerID: string | undefined) { + return providerID?.startsWith("xiaomi") === true +} + const Parameters = z.object({ query: z.string().describe("Websearch query"), numResults: z.number().optional().describe("Number of search results to return (default: 8)"), @@ -63,7 +67,7 @@ export const WebSearchTool = Tool.define( const timeout = params.timeout === undefined ? undefined : Math.min(params.timeout * 1000, MAX_TIMEOUT) const result = - model?.providerID === "xiaomi" + model !== undefined && usesMimoWebsearch(model.providerID) ? yield* Effect.catchCause( Effect.gen(function* () { const info = yield* auth.get("xiaomi") diff --git a/packages/opencode/test/tool/websearch-routing.test.ts b/packages/opencode/test/tool/websearch-routing.test.ts new file mode 100644 index 000000000..5f80b128c --- /dev/null +++ b/packages/opencode/test/tool/websearch-routing.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, test } from "bun:test" +import { usesMimoWebsearch } from "../../src/tool/websearch" + +describe("websearch provider routing", () => { + test("uses the MiMo backend for Xiaomi provider variants", () => { + expect(usesMimoWebsearch("xiaomi")).toBe(true) + expect(usesMimoWebsearch("xiaomi-token-plan-cn")).toBe(true) + expect(usesMimoWebsearch("openai")).toBe(false) + }) +})