diff --git a/packages/types/src/api.ts b/packages/types/src/api.ts index e1a06357f2..3ce3594e26 100644 --- a/packages/types/src/api.ts +++ b/packages/types/src/api.ts @@ -17,6 +17,15 @@ export type HeadlessCapabilities = { rootTaskResults: true } +export type RunOverrides = { + provider?: string + profile?: string + model?: string + mode?: string + reasoningEffort?: "disabled" | "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max" + approval?: "interactive" | "safe" | "auto" +} + export type HeadlessTaskReference = { taskId: string; rootTaskId: string } export type HeadlessAskResponse = @@ -52,8 +61,9 @@ export interface RooCodeAPI extends EventEmitter { text: string images?: string[] configuration?: RooCodeSettings + overrides?: RunOverrides }): Promise - resumeHeadlessTask(taskId: string): Promise + resumeHeadlessTask(taskId: string, overrides?: RunOverrides): Promise respondToHeadlessAsk(input: { taskId: string; askId: string; response: HeadlessAskResponse }): Promise cancelHeadlessTask(input: { rootTaskId: string diff --git a/packages/vscode-shim/src/__tests__/ExtensionContext.test.ts b/packages/vscode-shim/src/__tests__/ExtensionContext.test.ts index beb71d7deb..8f14b55d0e 100644 --- a/packages/vscode-shim/src/__tests__/ExtensionContext.test.ts +++ b/packages/vscode-shim/src/__tests__/ExtensionContext.test.ts @@ -2,6 +2,7 @@ import { ExtensionContextImpl } from "../context/ExtensionContext.js" import * as fs from "fs" import * as path from "path" import { tmpdir } from "os" +import type { SecretStorage } from "../types.js" describe("ExtensionContextImpl", () => { let tempDir: string @@ -69,6 +70,19 @@ describe("ExtensionContextImpl", () => { expect(context.environmentVariableCollection).toEqual({}) }) + + it("uses an injected secure SecretStorage without creating a plaintext file", () => { + const secretStorage: SecretStorage = { + get: async () => undefined, + store: async () => undefined, + delete: async () => undefined, + onDidChange: () => ({ dispose() {} }), + } + const storageDir = path.join(tempDir, "secure-storage") + const context = new ExtensionContextImpl({ extensionPath, workspacePath, storageDir, secretStorage }) + expect(context.secrets).toBe(secretStorage) + expect(fs.existsSync(path.join(storageDir, "global-storage", "secrets.json"))).toBe(false) + }) }) describe("storage paths", () => { diff --git a/packages/vscode-shim/src/api/create-vscode-api-mock.ts b/packages/vscode-shim/src/api/create-vscode-api-mock.ts index fd4a94a8a6..7ef688ee00 100644 --- a/packages/vscode-shim/src/api/create-vscode-api-mock.ts +++ b/packages/vscode-shim/src/api/create-vscode-api-mock.ts @@ -55,6 +55,7 @@ import type { CancellationToken } from "../interfaces/document.js" import type { Disposable, DiagnosticCollection, IdentityInfo } from "../interfaces/workspace.js" import type { RelativePattern } from "../interfaces/document.js" import type { UriHandler } from "../interfaces/webview.js" +import type { SecretStorage } from "../types.js" // Package version constant const Package = { version: "1.0.0" } @@ -75,6 +76,9 @@ export interface VSCodeAPIMockOptions { * Set to a temp directory for ephemeral/no-persist mode. */ storageDir?: string + + /** Secure storage supplied by the embedding host. */ + secretStorage?: SecretStorage } /** @@ -90,6 +94,7 @@ export function createVSCodeAPIMock( extensionPath: extensionRootPath, workspacePath: workspacePath, storageDir: options?.storageDir, + secretStorage: options?.secretStorage, }) const workspace = new WorkspaceAPI(workspacePath, context) const window = new WindowAPI() diff --git a/packages/vscode-shim/src/context/ExtensionContext.ts b/packages/vscode-shim/src/context/ExtensionContext.ts index 324478bf34..843a5640f9 100644 --- a/packages/vscode-shim/src/context/ExtensionContext.ts +++ b/packages/vscode-shim/src/context/ExtensionContext.ts @@ -37,6 +37,9 @@ export interface ExtensionContextOptions { * Extension mode (Production, Development, or Test) */ extensionMode?: ExtensionMode + + /** Secure storage supplied by the embedding host. */ + secretStorage?: SecretStorage } /** @@ -108,7 +111,7 @@ export class ExtensionContextImpl implements ExtensionContext { }, }) - this.secrets = new FileSecretStorage(this.globalStoragePath) + this.secrets = options.secretStorage ?? new FileSecretStorage(this.globalStoragePath) // Load extension metadata (packageJSON) this.extension = this.loadExtensionMetadata() diff --git a/packages/vscode-shim/src/index.ts b/packages/vscode-shim/src/index.ts index 8f40746de7..cc2d2349d7 100644 --- a/packages/vscode-shim/src/index.ts +++ b/packages/vscode-shim/src/index.ts @@ -12,6 +12,7 @@ export { // Main factory function createVSCodeAPIMock, + type VSCodeAPIMockOptions, // Classes Uri, @@ -77,6 +78,7 @@ export { type WorkspaceConfiguration, type Memento, type SecretStorage, + type SecretStorageChangeEvent, type FileStat, type Terminal, type CancellationToken, diff --git a/packages/vscode-shim/src/vscode.ts b/packages/vscode-shim/src/vscode.ts index a25cd1e8d9..9eec054a99 100644 --- a/packages/vscode-shim/src/vscode.ts +++ b/packages/vscode-shim/src/vscode.ts @@ -52,7 +52,7 @@ export { WorkspaceAPI } from "./api/WorkspaceAPI.js" export { TabGroupsAPI, type Tab, type TabInputText, type TabGroup } from "./api/TabGroupsAPI.js" export { WindowAPI } from "./api/WindowAPI.js" export { CommandsAPI } from "./api/CommandsAPI.js" -export { createVSCodeAPIMock } from "./api/create-vscode-api-mock.js" +export { createVSCodeAPIMock, type VSCodeAPIMockOptions } from "./api/create-vscode-api-mock.js" // ============================================================================ // Enums from ./types.ts @@ -76,7 +76,15 @@ export { // ============================================================================ // Types from ./types.ts // ============================================================================ -export type { Thenable, Memento, FileStat, TextEditorOptions, ConfigurationInspect } from "./types.js" +export type { + Thenable, + Memento, + SecretStorage, + SecretStorageChangeEvent, + FileStat, + TextEditorOptions, + ConfigurationInspect, +} from "./types.js" // ============================================================================ // Interfaces from ./interfaces/ @@ -142,15 +150,3 @@ export type { DiagnosticCollection, IdentityInfo, } from "./interfaces/workspace.js" - -// ============================================================================ -// Secret Storage interface (backwards compatibility) -// ============================================================================ -export interface SecretStorage { - get(key: string): Thenable - store(key: string, value: string): Thenable - delete(key: string): Thenable -} - -// Import Thenable for SecretStorage interface -import type { Thenable } from "./types.js" diff --git a/packages/zoo-host/eslint.config.mjs b/packages/zoo-host/eslint.config.mjs new file mode 100644 index 0000000000..694bf73664 --- /dev/null +++ b/packages/zoo-host/eslint.config.mjs @@ -0,0 +1,4 @@ +import { config } from "@roo-code/config-eslint/base" + +/** @type {import("eslint").Linter.Config} */ +export default [...config] diff --git a/packages/zoo-host/package.json b/packages/zoo-host/package.json new file mode 100644 index 0000000000..ce2b9290c5 --- /dev/null +++ b/packages/zoo-host/package.json @@ -0,0 +1,27 @@ +{ + "name": "@roo-code/zoo-host", + "description": "Private supervised extension host for the Zoo CLI.", + "private": true, + "type": "module", + "main": "./dist/child.js", + "types": "./src/index.ts", + "scripts": { + "lint": "eslint src --ext=ts --max-warnings=0", + "check-types": "tsc --noEmit", + "test": "vitest run", + "build": "tsc", + "clean": "rimraf dist .turbo" + }, + "dependencies": { + "@roo-code/types": "workspace:^", + "@roo-code/vscode-shim": "workspace:^", + "@roo-code/zoo-protocol": "workspace:^" + }, + "devDependencies": { + "@roo-code/config-eslint": "workspace:^", + "@roo-code/config-typescript": "workspace:^", + "@types/node": "22.20.1", + "tsx": "4.22.4", + "vitest": "4.1.9" + } +} diff --git a/packages/zoo-host/src/__tests__/host.test.ts b/packages/zoo-host/src/__tests__/host.test.ts new file mode 100644 index 0000000000..d3be05e929 --- /dev/null +++ b/packages/zoo-host/src/__tests__/host.test.ts @@ -0,0 +1,174 @@ +import { fork } from "node:child_process" +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { fileURLToPath } from "node:url" + +import { describe, expect, it, vi } from "vitest" + +import { HostCommandDispatcher } from "../dispatcher.js" +import { validateHostRoots } from "../roots.js" +import { VaultSecretStorage, type VaultBackend } from "../security.js" +import { HostTransport } from "../transport.js" + +const childPath = fileURLToPath(new URL("../child.ts", import.meta.url)) +const tsxLoader = import.meta.resolve("tsx") + +describe("host security and transport", () => { + it("requires explicit absolute roots", () => { + expect(() => + validateHostRoots({ + extensionRoot: "relative", + workspaceRoot: "/workspace", + storageRoot: "/storage", + appRoot: "/app", + }), + ).toThrow("extensionRoot") + expect( + validateHostRoots({ + extensionRoot: "/extension", + workspaceRoot: "/workspace", + storageRoot: "/storage", + appRoot: "/app", + }), + ).toEqual({ + extensionRoot: "/extension", + workspaceRoot: "/workspace", + storageRoot: "/storage", + appRoot: "/app", + }) + }) + + it("starts the child process with version metadata in its config", async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), "zoo-host-child-")) + const extensionRoot = path.join(tempRoot, "extension") + const workspaceRoot = path.join(tempRoot, "workspace") + const storageRoot = path.join(tempRoot, "storage") + await Promise.all([mkdir(extensionRoot), mkdir(workspaceRoot), mkdir(storageRoot)]) + await writeFile( + path.join(extensionRoot, "extension.js"), + `const { EventEmitter } = require("node:events") +exports.activate = async () => { + const api = new EventEmitter() + api.initializeHeadless = async () => {} + api.shutdownHeadless = async () => {} + return api +} +exports.deactivate = async () => {} +`, + ) + + try { + const result = await new Promise<{ + code: number | null + signal: NodeJS.Signals | null + buildVersion: string | undefined + initialized: boolean + stderr: string + }>((resolve, reject) => { + const child = fork(childPath, [], { + execArgv: ["--import", tsxLoader], + stdio: ["ignore", "ignore", "pipe", "ipc"], + env: { + ...process.env, + ZOO_HOST_CONFIG: JSON.stringify({ + extensionRoot, + workspaceRoot, + storageRoot, + appRoot: extensionRoot, + buildVersion: "0.1.0", + }), + }, + }) + let buildVersion: string | undefined + let initialized = false + let stderr = "" + const messageTypes: string[] = [] + const timeout = setTimeout(() => { + child.kill("SIGKILL") + reject(new Error(`Zoo host child did not initialize (${messageTypes.join(", ")}): ${stderr}`)) + }, 10_000) + child.stderr?.setEncoding("utf8").on("data", (chunk: string) => (stderr += chunk)) + child.once("error", reject) + child.on("message", (message: { type?: string; buildVersion?: string }) => { + if (message.type) messageTypes.push(message.type) + if (message.type === "hello") { + buildVersion = message.buildVersion + child.send({ + type: "hello.select", + version: 1, + clientVersion: "0.1.0", + requiredCapabilities: [ + "task:start", + "task:resume", + "task:cancel", + "history:list", + "host:shutdown", + ], + }) + } + if (message.type === "host.heartbeat") { + initialized = true + child.kill("SIGTERM") + } + }) + child.once("close", (code, signal) => { + clearTimeout(timeout) + resolve({ code, signal, buildVersion, initialized, stderr }) + }) + }) + + expect(result).toEqual({ + code: null, + signal: "SIGTERM", + buildVersion: "0.1.0", + initialized: true, + stderr: "", + }) + } finally { + await rm(tempRoot, { recursive: true, force: true }) + } + }, 15_000) + + it("round trips secrets only through the injected vault", async () => { + const values = new Map() + const backend: VaultBackend = { + get: vi.fn(async (key) => values.get(key)), + store: vi.fn(async (key, value) => void values.set(key, value)), + delete: vi.fn(async (key) => void values.delete(key)), + } + const storage = new VaultSecretStorage(backend) + const changes: string[] = [] + storage.onDidChange(({ key }) => changes.push(key)) + await storage.store("api-key", "secret") + await expect(storage.get("api-key")).resolves.toBe("secret") + await storage.delete("api-key") + expect(changes).toEqual(["api-key", "api-key"]) + expect(backend.store).toHaveBeenCalledWith("api-key", "secret") + }) + + it("uses one monotonic sequence for ACK and DONE", async () => { + const sent: unknown[] = [] + const transport = new HostTransport("host-1", async (message) => void sent.push(message)) + const api = { + startHeadlessTask: vi.fn().mockResolvedValue({ taskId: "root", rootTaskId: "root" }), + } as never + const dispatcher = new HostCommandDispatcher(api, transport, "/workspace") + await dispatcher.dispatch({ v: 1, id: "cmd-1", type: "task.start", workspace: "/workspace", prompt: "hello" }) + expect(sent).toMatchObject([ + { seq: 1, type: "command.ack", commandId: "cmd-1" }, + { seq: 2, type: "command.done", commandId: "cmd-1", data: { commandType: "task.start" } }, + ]) + }) + + it("rejects workspace identity changes after ACK", async () => { + const sent: unknown[] = [] + const transport = new HostTransport("host-1", async (message) => void sent.push(message)) + const dispatcher = new HostCommandDispatcher({} as never, transport, "/workspace") + await dispatcher.dispatch({ v: 1, id: "cmd-1", type: "task.start", workspace: "/other", prompt: "hello" }) + expect(sent).toMatchObject([ + { seq: 1, type: "command.ack" }, + { seq: 2, type: "command.error", error: { code: "task_failed" } }, + ]) + }) +}) diff --git a/packages/zoo-host/src/bootstrap.ts b/packages/zoo-host/src/bootstrap.ts new file mode 100644 index 0000000000..f3cf2a09a9 --- /dev/null +++ b/packages/zoo-host/src/bootstrap.ts @@ -0,0 +1,59 @@ +import fs from "node:fs" +import path from "node:path" +import { createRequire } from "node:module" + +import type { RooCodeAPI } from "@roo-code/types" +import { createVSCodeAPI, type SecretStorage } from "@roo-code/vscode-shim" + +import { validateHostRoots, type HostRoots } from "./roots.js" + +type ExtensionModule = { activate(context: unknown): Promise; deactivate?(): Promise } + +export async function activateExtensionHost(rootsInput: HostRoots, secretStorage: SecretStorage) { + const roots = validateHostRoots(rootsInput) + const bundlePath = path.join(roots.extensionRoot, "extension.js") + if (!fs.existsSync(bundlePath)) throw new Error(`Extension bundle not found at ${bundlePath}`) + const vscode = createVSCodeAPI(roots.extensionRoot, roots.workspaceRoot, undefined, { + appRoot: roots.appRoot, + storageDir: roots.storageRoot, + secretStorage, + }) + ;(globalThis as Record).vscode = vscode + const require = createRequire(import.meta.url) + const Module = require("module") as { + _resolveFilename(request: string, parent: unknown, isMain: boolean, options: unknown): string + } + const originalResolve = Module._resolveFilename + Module._resolveFilename = function (request, parent, isMain, options) { + return request === "vscode" ? "zoo-vscode-shim" : originalResolve.call(this, request, parent, isMain, options) + } + require.cache["zoo-vscode-shim"] = { + id: "zoo-vscode-shim", + filename: "zoo-vscode-shim", + loaded: true, + exports: vscode, + children: [], + paths: [], + path: "", + isPreloading: false, + parent: null, + require, + } as unknown as NodeJS.Module + let extension: ExtensionModule + try { + extension = require(bundlePath) as ExtensionModule + } finally { + Module._resolveFilename = originalResolve + } + const api = await extension.activate(vscode.context) + await api.initializeHeadless() + return { + api, + async dispose() { + await api.shutdownHeadless() + await extension.deactivate?.() + vscode.context.dispose() + delete (globalThis as Record).vscode + }, + } +} diff --git a/packages/zoo-host/src/child.ts b/packages/zoo-host/src/child.ts new file mode 100644 index 0000000000..83c389ded8 --- /dev/null +++ b/packages/zoo-host/src/child.ts @@ -0,0 +1,102 @@ +import { randomUUID } from "node:crypto" +import { pathToFileURL } from "node:url" + +import { + hostHelloSchema, + parentHelloSchema, + validateParentHello, + ZOO_HOST_PROTOCOL_VERSION, +} from "@roo-code/zoo-protocol" + +import { activateExtensionHost } from "./bootstrap.js" +import { HostCommandDispatcher } from "./dispatcher.js" +import { validateHostRoots, type HostRoots } from "./roots.js" +import { createSystemVaultBackend, VaultSecretStorage } from "./security.js" +import { HostTransport } from "./transport.js" + +type ChildConfig = HostRoots & { hostId?: string; buildVersion: string } + +function sendProcessMessage(message: unknown): Promise { + return new Promise((resolve, reject) => { + if (!process.send || !process.connected) return reject(new Error("Zoo host IPC channel is unavailable")) + process.send(message, (error) => (error ? reject(error) : resolve())) + }) +} + +export async function runChild(config: ChildConfig): Promise { + if (!process.send) throw new Error("zoo-host must be started with a Node IPC channel") + const roots = validateHostRoots({ + extensionRoot: config.extensionRoot, + workspaceRoot: config.workspaceRoot, + storageRoot: config.storageRoot, + appRoot: config.appRoot, + }) + const hostId = config.hostId ?? randomUUID() + const hello = hostHelloSchema.parse({ + type: "hello", + hostId, + supportedVersions: [ZOO_HOST_PROTOCOL_VERSION], + capabilities: { + [ZOO_HOST_PROTOCOL_VERSION]: [ + "task:start", + "task:resume", + "task:input", + "task:cancel", + "ask:respond", + "history:list", + "host:snapshot", + "host:shutdown", + "checkpoint:unavailable", + ], + }, + buildVersion: config.buildVersion, + }) + await sendProcessMessage(hello) + + const selection = await new Promise>((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error("Host negotiation timed out")), 10_000) + process.once("message", (message) => { + clearTimeout(timeout) + try { + resolve(parentHelloSchema.parse(message)) + } catch (error) { + reject(error) + } + }) + }) + const negotiation = validateParentHello(hello, selection) + if (!negotiation.ok) throw new Error(negotiation.message) + + const secretStorage = new VaultSecretStorage(createSystemVaultBackend()) + const extension = await activateExtensionHost(roots, secretStorage) + const transport = new HostTransport(hostId, sendProcessMessage) + const dispatcher = new HostCommandDispatcher(extension.api, transport, roots.workspaceRoot) + transport.startHeartbeat() + process.on("message", (message) => { + void dispatcher.dispatch(message).catch(async (error) => { + await transport.send({ + type: "command.error", + commandId: "invalid", + error: { + code: "invalid_usage", + kind: "configuration", + phase: undefined, + message: error instanceof Error ? error.message : String(error), + }, + }) + }) + }) + process.once("disconnect", () => { + transport.stopHeartbeat() + void extension.dispose() + }) +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const config = JSON.parse(process.env.ZOO_HOST_CONFIG ?? "null") as ChildConfig | null + if (!config) throw new Error("ZOO_HOST_CONFIG is required") + void runChild(config).catch((error) => { + process.stderr.write(`zoo-host failed: ${error instanceof Error ? error.message : String(error)}\n`) + process.exitCode = 70 + }) +} diff --git a/packages/zoo-host/src/dispatcher.ts b/packages/zoo-host/src/dispatcher.ts new file mode 100644 index 0000000000..e3d36b167e --- /dev/null +++ b/packages/zoo-host/src/dispatcher.ts @@ -0,0 +1,84 @@ +import type { RooCodeAPI } from "@roo-code/types" +import { hostCommandSchema, type HostCommand } from "@roo-code/zoo-protocol" + +import { HostTransport } from "./transport.js" + +export class HostCommandDispatcher { + private queue = Promise.resolve() + private activeRootTaskId: string | undefined + + constructor( + private readonly api: RooCodeAPI, + private readonly transport: HostTransport, + private readonly workspace: string, + ) {} + + public dispatch(input: unknown): Promise { + const command = hostCommandSchema.parse(input) + const operation = this.queue.then(() => this.execute(command)) + this.queue = operation.catch(() => undefined) + return operation + } + + private async execute(command: HostCommand): Promise { + await this.transport.send({ type: "command.ack", commandId: command.id }) + try { + const data = await this.executeCommand(command) + await this.transport.send({ type: "command.done", commandId: command.id, data }) + } catch (error) { + await this.transport.send({ + type: "command.error", + commandId: command.id, + error: { + code: "task_failed", + kind: "runtime", + phase: command.type, + message: error instanceof Error ? error.message : String(error), + }, + }) + } + } + + private async executeCommand(command: HostCommand) { + switch (command.type) { + case "task.start": { + if (command.workspace !== this.workspace) throw new Error("Host workspace identity cannot change") + const task = await this.api.startHeadlessTask({ text: command.prompt, overrides: command.overrides }) + this.activeRootTaskId = task.rootTaskId + return { commandType: command.type, task } + } + case "task.resume": { + const task = await this.api.resumeHeadlessTask(command.taskId, command.overrides) + this.activeRootTaskId = task.rootTaskId + return { commandType: command.type, task } + } + case "task.input": + await this.api.sendMessage(command.text, command.images) + return { commandType: command.type, taskId: command.taskId } + case "ask.respond": + await this.api.respondToHeadlessAsk({ + taskId: command.taskId, + askId: command.askId, + response: + command.response === "message" + ? { response: "message", text: command.text! } + : { response: command.response }, + }) + return { commandType: command.type, taskId: command.taskId, askId: command.askId } + case "task.cancel": + await this.api.cancelHeadlessTask({ rootTaskId: command.rootTaskId, reason: command.reason }) + return { commandType: command.type, rootTaskId: command.rootTaskId } + case "host.snapshot": + return { + commandType: command.type, + lastSeq: this.transport.lastSequence, + activeRootTaskId: this.activeRootTaskId, + } + case "host.shutdown": + await this.api.shutdownHeadless() + return { commandType: command.type } + case "history.list": + return { commandType: command.type, workspace: command.workspace, tasks: [] } + } + } +} diff --git a/packages/zoo-host/src/index.ts b/packages/zoo-host/src/index.ts new file mode 100644 index 0000000000..55a49d1077 --- /dev/null +++ b/packages/zoo-host/src/index.ts @@ -0,0 +1,5 @@ +export * from "./bootstrap.js" +export * from "./dispatcher.js" +export * from "./roots.js" +export * from "./security.js" +export * from "./transport.js" diff --git a/packages/zoo-host/src/roots.ts b/packages/zoo-host/src/roots.ts new file mode 100644 index 0000000000..761f4f7a31 --- /dev/null +++ b/packages/zoo-host/src/roots.ts @@ -0,0 +1,20 @@ +import path from "node:path" + +export type HostRoots = { + extensionRoot: string + workspaceRoot: string + storageRoot: string + appRoot: string +} + +export function validateHostRoots(roots: HostRoots): HostRoots { + for (const [name, value] of Object.entries(roots)) { + if (!path.isAbsolute(value)) throw new Error(`${name} must be an absolute path`) + } + return { + extensionRoot: path.resolve(roots.extensionRoot), + workspaceRoot: path.resolve(roots.workspaceRoot), + storageRoot: path.resolve(roots.storageRoot), + appRoot: path.resolve(roots.appRoot), + } +} diff --git a/packages/zoo-host/src/security.ts b/packages/zoo-host/src/security.ts new file mode 100644 index 0000000000..8dd0655ef8 --- /dev/null +++ b/packages/zoo-host/src/security.ts @@ -0,0 +1,112 @@ +import { execFile as execFileCallback, spawn } from "node:child_process" +import { promisify } from "node:util" + +import type { SecretStorage, SecretStorageChangeEvent } from "@roo-code/vscode-shim" + +const execFile = promisify(execFileCallback) + +export interface VaultBackend { + get(account: string): Promise + store(account: string, value: string): Promise + delete(account: string): Promise +} + +async function spawnWithInput(command: string, args: string[], input: string): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { stdio: ["pipe", "pipe", "pipe"] }) + let stdout = "" + let stderr = "" + child.stdout.setEncoding("utf8").on("data", (chunk: string) => (stdout += chunk)) + child.stderr.setEncoding("utf8").on("data", (chunk: string) => (stderr += chunk)) + child.once("error", reject) + child.once("close", (code) => + code === 0 ? resolve(stdout) : reject(new Error(`${command} failed (${code}): ${stderr.trim()}`)), + ) + child.stdin.end(input) + }) +} + +export function createSystemVaultBackend(service = "Zoo Code CLI", platform = process.platform): VaultBackend { + if (platform === "darwin") { + return { + async get(account) { + try { + const { stdout } = await execFile("security", [ + "find-generic-password", + "-s", + service, + "-a", + account, + "-w", + ]) + return stdout.trimEnd() + } catch (error) { + if ((error as NodeJS.ErrnoException & { code?: number }).code === 44) return undefined + throw error + } + }, + async store(account, value) { + await execFile("security", ["add-generic-password", "-U", "-s", service, "-a", account, "-w", value]) + }, + async delete(account) { + try { + await execFile("security", ["delete-generic-password", "-s", service, "-a", account]) + } catch (error) { + if ((error as NodeJS.ErrnoException & { code?: number }).code !== 44) throw error + } + }, + } + } + if (platform === "linux") { + return { + async get(account) { + try { + const { stdout } = await execFile("secret-tool", ["lookup", "service", service, "account", account]) + return stdout.trimEnd() || undefined + } catch { + return undefined + } + }, + async store(account, value) { + await spawnWithInput( + "secret-tool", + ["store", `--label=${service}`, "service", service, "account", account], + value, + ) + }, + async delete(account) { + await execFile("secret-tool", ["clear", "service", service, "account", account]) + }, + } + } + throw new Error(`Persisted Zoo CLI credentials are unsupported on ${platform}`) +} + +export class VaultSecretStorage implements SecretStorage { + private readonly listeners = new Set<(event: SecretStorageChangeEvent) => unknown>() + + constructor(private readonly backend: VaultBackend) {} + + public readonly onDidChange = (listener: (event: SecretStorageChangeEvent) => unknown) => { + this.listeners.add(listener) + return { dispose: () => this.listeners.delete(listener) } + } + + public get(key: string): Promise { + return this.backend.get(key) + } + + public async store(key: string, value: string): Promise { + await this.backend.store(key, value) + this.fire(key) + } + + public async delete(key: string): Promise { + await this.backend.delete(key) + this.fire(key) + } + + private fire(key: string): void { + for (const listener of this.listeners) listener({ key }) + } +} diff --git a/packages/zoo-host/src/transport.ts b/packages/zoo-host/src/transport.ts new file mode 100644 index 0000000000..44c8b13940 --- /dev/null +++ b/packages/zoo-host/src/transport.ts @@ -0,0 +1,38 @@ +import { performance } from "node:perf_hooks" + +import { hostEventSchema, type HostEvent } from "@roo-code/zoo-protocol" + +export type SendIPC = (message: unknown) => Promise +type OutboundHostEvent = T extends HostEvent ? Omit : never + +export class HostTransport { + private sequence = 0 + private heartbeat: NodeJS.Timeout | undefined + + constructor( + public readonly hostId: string, + private readonly sendIPC: SendIPC, + ) {} + + public get lastSequence(): number { + return this.sequence + } + + public async send(event: OutboundHostEvent): Promise { + const message = hostEventSchema.parse({ v: 1, seq: ++this.sequence, hostId: this.hostId, ...event }) + await this.sendIPC(message) + } + + public startHeartbeat(intervalMs = 1_000): void { + if (this.heartbeat) return + this.heartbeat = setInterval(() => { + void this.send({ type: "host.heartbeat", monotonicMs: performance.now() }).catch(() => this.stopHeartbeat()) + }, intervalMs) + this.heartbeat.unref() + } + + public stopHeartbeat(): void { + if (this.heartbeat) clearInterval(this.heartbeat) + this.heartbeat = undefined + } +} diff --git a/packages/zoo-host/tsconfig.json b/packages/zoo-host/tsconfig.json new file mode 100644 index 0000000000..0ef9718389 --- /dev/null +++ b/packages/zoo-host/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "@roo-code/config-typescript/base.json", + "compilerOptions": { "outDir": "dist" }, + "include": ["src"], + "exclude": ["node_modules"] +} diff --git a/packages/zoo-host/vitest.config.ts b/packages/zoo-host/vitest.config.ts new file mode 100644 index 0000000000..4389381a32 --- /dev/null +++ b/packages/zoo-host/vitest.config.ts @@ -0,0 +1,5 @@ +import { defineConfig } from "vitest/config" + +export default defineConfig({ + test: { globals: true, environment: "node", include: ["src/**/*.test.ts"], watch: false }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 82da4164ca..ab86cce4b6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -419,6 +419,34 @@ importers: specifier: 4.1.9 version: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + packages/zoo-host: + dependencies: + '@roo-code/types': + specifier: workspace:^ + version: link:../types + '@roo-code/vscode-shim': + specifier: workspace:^ + version: link:../vscode-shim + '@roo-code/zoo-protocol': + specifier: workspace:^ + version: link:../zoo-protocol + devDependencies: + '@roo-code/config-eslint': + specifier: workspace:^ + version: link:../config-eslint + '@roo-code/config-typescript': + specifier: workspace:^ + version: link:../config-typescript + '@types/node': + specifier: 22.20.1 + version: 22.20.1 + tsx: + specifier: 4.22.4 + version: 4.22.4 + vitest: + specifier: 4.1.9 + version: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + packages/zoo-protocol: dependencies: zod: diff --git a/src/__tests__/provider-delegation.spec.ts b/src/__tests__/provider-delegation.spec.ts index 0154027753..dba788acd2 100644 --- a/src/__tests__/provider-delegation.spec.ts +++ b/src/__tests__/provider-delegation.spec.ts @@ -38,6 +38,7 @@ const makeParentTask = () => ({ taskId: "parent-1", emit: vi.fn(), + getDelegatedRunOverrides: vi.fn().mockReturnValue(undefined), flushPendingToolResultsToHistory: vi.fn().mockResolvedValue(true), retrySaveApiConversationHistory: vi.fn(), }) as any diff --git a/src/__tests__/single-open-invariant.spec.ts b/src/__tests__/single-open-invariant.spec.ts index 9eeec8a960..e19390f181 100644 --- a/src/__tests__/single-open-invariant.spec.ts +++ b/src/__tests__/single-open-invariant.spec.ts @@ -25,6 +25,7 @@ type PrivateClineProviderMethods = { } const privateClineProvider = ClineProvider.prototype as unknown as PrivateClineProviderMethods +const resolveRunOverrides = vi.fn(async (_overrides: unknown, apiConfiguration: unknown) => ({ apiConfiguration })) // Mock Task class used by ClineProvider to avoid heavy startup vi.mock("../core/task/Task", () => { @@ -73,6 +74,7 @@ describe("Single-open-task invariant", () => { const registry = new TaskRegistry() registry.push(existingTask as unknown as Task) const provider = { + resolveRunOverrides, taskRegistry: registry, taskScheduler: { schedule: schedulespy }, getCurrentTask: vi.fn(() => existingTask), @@ -123,6 +125,7 @@ describe("Single-open-task invariant", () => { registry2.push(parentTask as unknown as Task) const provider = { + resolveRunOverrides, taskRegistry: registry2, taskScheduler: new TaskScheduler(), setValues: vi.fn(), @@ -163,6 +166,7 @@ describe("Single-open-task invariant", () => { const schedulespy = vi.fn().mockResolvedValue(undefined) const provider = { + resolveRunOverrides, getCurrentTask: vi.fn(() => undefined), // ensure not rehydrating taskHistoryStore: { get: vi.fn(() => undefined) }, markDelegatedChildInterrupted: vi.fn().mockResolvedValue(undefined), @@ -237,6 +241,7 @@ describe("Single-open-task invariant", () => { registry.push(existingTask as unknown as Task) const provider = { + resolveRunOverrides, getCurrentTask: vi.fn(() => existingTask), taskRegistry: registry, taskHistoryStore: { get: vi.fn(() => undefined) }, diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 13190c1955..3ffb70e7cb 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -163,6 +163,10 @@ export interface TaskOptions extends CreateTaskOptions { initialStatus?: "active" | "delegated" | "completed" | "interrupted" rateLimitClock?: RateLimitClock diffFuzzyThreshold?: number + runMode?: string + runApiConfigName?: string + runApprovalMode?: "interactive" | "safe" | "auto" + isolateRunConfiguration?: boolean } export class Task extends EventEmitter implements TaskLike { @@ -207,6 +211,8 @@ export class Task extends EventEmitter implements TaskLike { * @see {@link waitForModeInitialization} - To ensure initialization is complete */ private _taskMode: string | undefined + private readonly runApprovalMode: "interactive" | "safe" | "auto" | undefined + private readonly isolateRunConfiguration: boolean /** * Promise that resolves when the task mode has been initialized. @@ -480,6 +486,10 @@ export class Task extends EventEmitter implements TaskLike { initialStatus, rateLimitClock, diffFuzzyThreshold, + runMode, + runApiConfigName, + runApprovalMode, + isolateRunConfiguration, }: TaskOptions) { super() @@ -529,6 +539,8 @@ export class Task extends EventEmitter implements TaskLike { }) this.apiConfiguration = apiConfiguration + this.runApprovalMode = runApprovalMode + this.isolateRunConfiguration = isolateRunConfiguration ?? false this.api = buildApiHandler(this.apiConfiguration) this.rateLimitClock = rateLimitClock ?? createRateLimitClock() this.autoApprovalHandler = new AutoApprovalHandler() @@ -547,7 +559,12 @@ export class Task extends EventEmitter implements TaskLike { // Store the task's mode and API config name when it's created. // For history items, use the stored values; for new tasks, we'll set them // after getting state. - if (historyItem) { + if (runMode) { + this._taskMode = runMode + this._taskApiConfigName = runApiConfigName + this.taskModeReady = Promise.resolve() + this.taskApiConfigReady = Promise.resolve() + } else if (historyItem) { this._taskMode = historyItem.mode || defaultModeSlug this._taskApiConfigName = historyItem.apiConfigName this.taskModeReady = Promise.resolve() @@ -583,7 +600,7 @@ export class Task extends EventEmitter implements TaskLike { this.messageQueueService.on("stateChanged", this.messageQueueStateChangedHandler) // Listen for provider profile changes to update parser state - this.setupProviderProfileChangeListener(provider) + if (!this.isolateRunConfiguration) this.setupProviderProfileChangeListener(provider) // Set up diff strategy this.diffStrategy = new MultiSearchReplaceDiffStrategy(diffFuzzyThreshold) @@ -740,6 +757,43 @@ export class Task extends EventEmitter implements TaskLike { provider.on(RooCodeEventName.ProviderProfileChanged, this.providerProfileChangeListener) } + private async getEffectiveState() { + const state = await this.providerRef.deref()?.getState() + if (!state || !this.isolateRunConfiguration) return state + const approval = + this.runApprovalMode === "interactive" + ? { autoApprovalEnabled: false } + : this.runApprovalMode === "safe" + ? { + autoApprovalEnabled: true, + alwaysAllowReadOnly: true, + alwaysAllowReadOnlyOutsideWorkspace: false, + alwaysAllowWrite: false, + alwaysAllowWriteOutsideWorkspace: false, + alwaysAllowWriteProtected: false, + alwaysAllowMcp: false, + alwaysAllowModeSwitch: false, + alwaysAllowSubtasks: false, + alwaysAllowExecute: false, + } + : this.runApprovalMode === "auto" + ? { + autoApprovalEnabled: true, + alwaysAllowReadOnly: true, + alwaysAllowReadOnlyOutsideWorkspace: true, + alwaysAllowWrite: true, + alwaysAllowWriteOutsideWorkspace: true, + alwaysAllowWriteProtected: true, + alwaysAllowMcp: true, + alwaysAllowModeSwitch: true, + alwaysAllowSubtasks: true, + alwaysAllowExecute: true, + allowedCommands: ["*"], + } + : {} + return { ...state, mode: await this.getTaskMode(), apiConfiguration: this.apiConfiguration, ...approval } + } + /** * Wait for the task mode to be initialized before proceeding. * This method ensures that any operations depending on the task mode @@ -1205,7 +1259,7 @@ export class Task extends EventEmitter implements TaskLike { // clearApprovalButtons message (which could arrive before buttons were // rendered, leaving them stuck on-screen). const provider = this.providerRef.deref() - const state = provider ? await provider.getState() : undefined + const state = provider ? await this.getEffectiveState() : undefined const approval = await checkAutoApproval({ state, ask: type, text, isProtected }) const isAutoAnswered = approval.decision === "approve" || approval.decision === "deny" const autoApprovalDecision = isAutoAnswered ? approval.decision : undefined @@ -1638,7 +1692,7 @@ export class Task extends EventEmitter implements TaskLike { const systemPrompt = await this.getSystemPrompt() // Get condensing configuration - const state = await this.providerRef.deref()?.getState() + const state = await this.getEffectiveState() const customCondensingPrompt = state?.customSupportPrompts?.CONDENSE const { mode, apiConfiguration } = state ?? {} @@ -1896,7 +1950,7 @@ export class Task extends EventEmitter implements TaskLike { return { enabledToolCount: 0, enabledServerCount: 0 } } - const { mcpEnabled } = (await provider.getState()) ?? {} + const { mcpEnabled } = (await this.getEffectiveState()) ?? {} if (!(mcpEnabled ?? true)) { return { enabledToolCount: 0, enabledServerCount: 0 } } @@ -2638,7 +2692,7 @@ export class Task extends EventEmitter implements TaskLike { ) const provider = this.providerRef.deref() - const state = provider ? await provider.getState() : undefined + const state = provider ? await this.getEffectiveState() : undefined const showRooIgnoredFiles = state?.showRooIgnoredFiles ?? false const includeDiagnosticMessages = state?.includeDiagnosticMessages ?? true @@ -2661,7 +2715,7 @@ export class Task extends EventEmitter implements TaskLike { if (slashCommandMode) { const provider = this.providerRef.deref() if (provider) { - const state = await provider.getState() + const state = await this.getEffectiveState() const targetMode = getModeBySlug(slashCommandMode, state?.customModes) if (targetMode) { await provider.handleModeSwitch(slashCommandMode) @@ -3334,7 +3388,7 @@ export class Task extends EventEmitter implements TaskLike { ) // Apply exponential backoff similar to first-chunk errors when auto-resubmit is enabled - const stateForBackoff = await this.providerRef.deref()?.getState() + const stateForBackoff = await this.getEffectiveState() if (stateForBackoff?.autoApprovalEnabled) { await this.backoffAndAnnounce(currentItem.retryAttempt ?? 0, error) @@ -3719,7 +3773,7 @@ export class Task extends EventEmitter implements TaskLike { // apiConversationHistory at line 1876. Since the assistant failed to respond, // we need to remove that message before retrying to avoid having two consecutive // user messages (which would cause tool_result validation errors). - const state = await this.providerRef.deref()?.getState() + const state = await this.getEffectiveState() // Only pop the user message that this iteration added. When // shouldAddUserMessage is false (empty continuation, resumed history, // or flushPendingToolResultsToHistory message) there is nothing to @@ -3832,7 +3886,7 @@ export class Task extends EventEmitter implements TaskLike { } private async getSystemPrompt(): Promise { - const { mcpEnabled } = (await this.providerRef.deref()?.getState()) ?? {} + const { mcpEnabled } = (await this.getEffectiveState()) ?? {} let mcpHub: McpHub | undefined if (mcpEnabled ?? true) { const provider = this.providerRef.deref() @@ -3856,7 +3910,7 @@ export class Task extends EventEmitter implements TaskLike { const rooIgnoreInstructions = this.rooIgnoreController?.getInstructions() - const state = await this.providerRef.deref()?.getState() + const state = await this.getEffectiveState() const { mode, @@ -3932,7 +3986,7 @@ export class Task extends EventEmitter implements TaskLike { } private async handleContextWindowExceededError(): Promise { - const state = await this.providerRef.deref()?.getState() + const state = await this.getEffectiveState() const { profileThresholds = {}, mode, apiConfiguration } = state ?? {} const { contextTokens } = this.getTokenUsage() @@ -4073,7 +4127,7 @@ export class Task extends EventEmitter implements TaskLike { * the `api_req_rate_limit_wait` say type (not an error). */ private async maybeWaitForProviderRateLimit(retryAttempt: number): Promise { - const state = await this.providerRef.deref()?.getState() + const state = await this.getEffectiveState() const rateLimitSeconds = state?.apiConfiguration?.rateLimitSeconds ?? this.apiConfiguration?.rateLimitSeconds ?? 0 @@ -4105,7 +4159,7 @@ export class Task extends EventEmitter implements TaskLike { retryAttempt: number = 0, options: { skipProviderRateLimit?: boolean } = {}, ): ApiStream { - const state = await this.providerRef.deref()?.getState() + const state = await this.getEffectiveState() const { apiConfiguration, @@ -4518,7 +4572,7 @@ export class Task extends EventEmitter implements TaskLike { // Shared exponential backoff for retries (first-chunk and mid-stream) private async backoffAndAnnounce(retryAttempt: number, error: any): Promise { try { - const state = await this.providerRef.deref()?.getState() + const state = await this.getEffectiveState() const baseDelay = state?.requestDelaySeconds || 5 let exponentialDelay = Math.min( diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 3537292009..b8573b1d16 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -9,6 +9,7 @@ import type { Mock } from "vitest" import { providerIdentifiers, + RooCodeEventName, type GlobalState, type ProviderSettings, type ModelInfo, @@ -381,6 +382,24 @@ describe("Cline", () => { })) }) + it("initializes an isolated run with its explicit mode and profile", async () => { + const providerOn = vi.spyOn(mockProvider, "on") + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "isolated task", + startTask: false, + runMode: "debug", + runApiConfigName: "ci", + isolateRunConfiguration: true, + }) + + await task.waitForModeInitialization() + expect(task.taskMode).toBe("debug") + expect(task.taskApiConfigName).toBe("ci") + expect(providerOn).not.toHaveBeenCalledWith(RooCodeEventName.ProviderProfileChanged, expect.any(Function)) + }) + describe("empty-response retries", () => { function stream(chunks: ApiStreamChunk[]): AsyncGenerator { return (async function* () { diff --git a/src/core/task/__tests__/ask-clear-approval-buttons.spec.ts b/src/core/task/__tests__/ask-clear-approval-buttons.spec.ts index ce5af1f192..8a79fe4b08 100644 --- a/src/core/task/__tests__/ask-clear-approval-buttons.spec.ts +++ b/src/core/task/__tests__/ask-clear-approval-buttons.spec.ts @@ -35,6 +35,22 @@ async function attachQueue(task: Task) { } describe("Task.ask auto-approval stamping", () => { + it.each([ + ["interactive", { autoApprovalEnabled: false }], + ["safe", { autoApprovalEnabled: true, alwaysAllowReadOnly: true, alwaysAllowExecute: false }], + ["auto", { autoApprovalEnabled: true, alwaysAllowWrite: true, allowedCommands: ["*"] }], + ] as const)("applies the %s isolated approval policy", async (approval, expected) => { + const task = buildTask({ postMessageToWebview: vi.fn(), getState: async () => ({ mode: "code" }) }) + Object.defineProperties(task, { + isolateRunConfiguration: { value: true }, + runApprovalMode: { value: approval }, + }) + task["apiConfiguration"] = { apiProvider: "anthropic" } + task.getTaskMode = vi.fn().mockResolvedValue("code") + + await expect(task["getEffectiveState"]()).resolves.toMatchObject(expected) + }) + it("accepts a response only for the exact pending headless ask", () => { const task = buildTask(undefined) const handleWebviewAskResponse = vi.fn() diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index cbdebfea5c..26c6868cbe 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -34,6 +34,7 @@ import { type CreateTaskOptions, type TokenUsage, type ToolUsage, + type RunOverrides, type ExtensionMessage, type ExtensionState, type MarketplaceInstalledMetadata, @@ -51,6 +52,8 @@ import { DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, getModelId, isRetiredProvider, + isTypicalProvider, + modelIdKeysByProvider, providerIdentifiers, } from "@roo-code/types" import { RateLimitClock, createRateLimitClock } from "../task/RateLimitClock" @@ -1060,7 +1063,7 @@ export class ClineProvider public async createTaskWithHistoryItem( historyItem: HistoryItem & { rootTask?: Task; parentTask?: Task }, - options?: { startTask?: boolean }, + options?: { startTask?: boolean; runOverrides?: RunOverrides }, ) { const isCliRuntime = process.env.ROO_CLI_RUNTIME === "1" // CLI injects runtime provider settings from command flags/env at startup. @@ -1076,8 +1079,8 @@ export class ClineProvider await this.evictCurrentTask() } - // If the history item has a saved mode, restore it and its associated API configuration. - if (historyItem.mode) { + // Run overrides resolve history state in memory and must not restore it globally. + if (historyItem.mode && !options?.runOverrides) { // Validate that the mode still exists const customModes = await this.customModesManager.getCustomModes() const modeExists = getModeBySlug(historyItem.mode, customModes) !== undefined @@ -1139,7 +1142,7 @@ export class ClineProvider // If the history item has a saved API config name (provider profile), restore it. // This overrides any mode-based config restoration above, because the task's // specific provider profile takes precedence over mode defaults. - if (historyItem.apiConfigName && !skipProfileRestoreFromHistory) { + if (historyItem.apiConfigName && !skipProfileRestoreFromHistory && !options?.runOverrides) { const listApiConfig = await this.providerSettingsManager.listConfig() // Keep global state/UI in sync with latest profiles for parity with mode restoration above. await this.updateGlobalState("listApiConfigMeta", listApiConfig) @@ -1174,7 +1177,7 @@ export class ClineProvider } const { - apiConfiguration, + apiConfiguration: persistedApiConfiguration, enableCheckpoints, checkpointTimeout, experiments, @@ -1183,12 +1186,16 @@ export class ClineProvider diffFuzzyThreshold, } = await this.getState() + const resolvedRun = await this.resolveRunOverrides(options?.runOverrides, persistedApiConfiguration, { + mode: historyItem.mode, + profile: historyItem.apiConfigName, + }) const task = new Task({ provider: this, - apiConfiguration, + apiConfiguration: resolvedRun.apiConfiguration, enableCheckpoints, checkpointTimeout, - consecutiveMistakeLimit: apiConfiguration.consecutiveMistakeLimit, + consecutiveMistakeLimit: resolvedRun.apiConfiguration.consecutiveMistakeLimit, historyItem, experiments, rootTask: historyItem.rootTask, @@ -1201,6 +1208,10 @@ export class ClineProvider initialStatus: historyItem.status, rateLimitClock: this.rateLimitClock, diffFuzzyThreshold, + runMode: resolvedRun.mode, + runApiConfigName: resolvedRun.profile, + runApprovalMode: options?.runOverrides?.approval, + isolateRunConfiguration: !!options?.runOverrides, }) if (isRehydratingCurrentTask) { @@ -3125,6 +3136,51 @@ export class ClineProvider return this.recentTasksCache } + private async resolveRunOverrides( + overrides: RunOverrides | undefined, + baseline: ProviderSettings, + history: { mode?: string; profile?: string } = {}, + ): Promise<{ apiConfiguration: ProviderSettings; mode?: string; profile?: string }> { + if (!overrides) return { apiConfiguration: baseline } + if (overrides.profile && overrides.provider) + throw new Error("profile and provider overrides are mutually exclusive") + + let apiConfiguration = structuredClone(baseline) + let profile = history.profile + if (overrides.profile) { + apiConfiguration = await this.providerSettingsManager.getProfile({ name: overrides.profile }) + profile = overrides.profile + } + if (overrides.provider) { + if (!Object.values(providerIdentifiers).includes(overrides.provider as ProviderName)) { + throw new Error(`Unknown provider override: ${overrides.provider}`) + } + apiConfiguration = { ...apiConfiguration, apiProvider: overrides.provider as ProviderName } + profile = undefined + } + if (overrides.model) { + const provider = apiConfiguration.apiProvider + if (isTypicalProvider(provider)) { + const key = modelIdKeysByProvider[provider] + apiConfiguration = { ...apiConfiguration, [key]: overrides.model } + } else { + apiConfiguration = { ...apiConfiguration, apiModelId: overrides.model } + } + } + if (overrides.reasoningEffort) { + apiConfiguration = { + ...apiConfiguration, + enableReasoningEffort: overrides.reasoningEffort !== "disabled", + reasoningEffort: overrides.reasoningEffort === "disabled" ? undefined : overrides.reasoningEffort, + } + } + const mode = overrides.mode ?? history.mode ?? defaultModeSlug + if (!getModeBySlug(mode, await this.customModesManager.getCustomModes())) { + throw new Error(`Unknown mode override: ${mode}`) + } + return { apiConfiguration, mode, profile } + } + // When initializing a new task, (not from history but from a tool command // new_task) there is no need to remove the previous task since the new // task is a subtask of the previous one, and when it finishes it is removed @@ -3137,8 +3193,12 @@ export class ClineProvider parentTask?: Task, options: CreateTaskOptions = {}, configuration: RooCodeSettings = {}, + runOverrides?: RunOverrides, ): Promise { - if (configuration) { + if (runOverrides && Object.keys(configuration).length > 0) { + throw new Error("Persistent configuration and run overrides cannot be combined") + } + if (!runOverrides && configuration) { await this.setValues(configuration) if (configuration.allowedCommands) { @@ -3179,13 +3239,19 @@ export class ClineProvider } const { - apiConfiguration, + apiConfiguration: persistedApiConfiguration, enableCheckpoints, checkpointTimeout, experiments, organizationAllowList, diffFuzzyThreshold, } = await this.getState() + const resolvedRun = await this.resolveRunOverrides(runOverrides, persistedApiConfiguration) + const apiConfiguration = resolvedRun.apiConfiguration + + if (!ProfileValidator.isProfileAllowed(apiConfiguration, organizationAllowList)) { + throw new OrganizationAllowListViolationError(t("common:errors.violated_organization_allowlist")) + } // Single-open-task invariant: always enforce for user-initiated top-level tasks. if (!parentTask) { @@ -3194,10 +3260,6 @@ export class ClineProvider }) } - if (!ProfileValidator.isProfileAllowed(apiConfiguration, organizationAllowList)) { - throw new OrganizationAllowListViolationError(t("common:errors.violated_organization_allowlist")) - } - const task = new Task({ provider: this, apiConfiguration, @@ -3216,6 +3278,10 @@ export class ClineProvider // its initial state update, so state.currentTaskId is available ASAP. startTask: false, diffFuzzyThreshold, + runMode: resolvedRun.mode, + runApiConfigName: resolvedRun.profile, + runApprovalMode: runOverrides?.approval, + isolateRunConfiguration: !!runOverrides, ...options, rateLimitClock: this.rateLimitClock, }) diff --git a/src/core/webview/__tests__/ClineProvider.run-overrides.spec.ts b/src/core/webview/__tests__/ClineProvider.run-overrides.spec.ts new file mode 100644 index 0000000000..c7d0773870 --- /dev/null +++ b/src/core/webview/__tests__/ClineProvider.run-overrides.spec.ts @@ -0,0 +1,106 @@ +import { describe, expect, it, vi } from "vitest" + +import type { ProviderSettings } from "@roo-code/types" + +import { ClineProvider } from "../ClineProvider" + +describe("ClineProvider run overrides", () => { + function createResolverHost(profile: ProviderSettings = { apiProvider: "anthropic", apiModelId: "profile-model" }) { + return { + providerSettingsManager: { + getProfile: vi.fn().mockResolvedValue(profile), + activateProfile: vi.fn(), + saveConfig: vi.fn(), + }, + customModesManager: { getCustomModes: vi.fn().mockResolvedValue([]) }, + setValues: vi.fn(), + setMode: vi.fn(), + } + } + + it("resolves provider, model, mode, reasoning, and approval without persistence", async () => { + const host = createResolverHost() + const result = await ClineProvider.prototype["resolveRunOverrides"].call( + // The resolver touches only the explicit read-only collaborators above. + host as unknown as ClineProvider, + { provider: "openrouter", model: "openai/model", mode: "code", reasoningEffort: "high", approval: "safe" }, + { apiProvider: "anthropic", apiModelId: "persisted-model" }, + ) + expect(result).toMatchObject({ + mode: "code", + apiConfiguration: { + apiProvider: "openrouter", + openRouterModelId: "openai/model", + enableReasoningEffort: true, + reasoningEffort: "high", + }, + }) + expect(host.setValues).not.toHaveBeenCalled() + expect(host.setMode).not.toHaveBeenCalled() + expect(host.providerSettingsManager.activateProfile).not.toHaveBeenCalled() + expect(host.providerSettingsManager.saveConfig).not.toHaveBeenCalled() + }) + + it("loads a profile read-only and rejects provider/profile ambiguity", async () => { + const host = createResolverHost({ apiProvider: "openrouter", openRouterModelId: "profile-model" }) + const result = await ClineProvider.prototype["resolveRunOverrides"].call( + host as unknown as ClineProvider, + { profile: "ci", model: "override-model", mode: "code" }, + { apiProvider: "anthropic" }, + ) + expect(host.providerSettingsManager.getProfile).toHaveBeenCalledWith({ name: "ci" }) + expect(result.profile).toBe("ci") + expect(result.apiConfiguration.openRouterModelId).toBe("override-model") + await expect( + ClineProvider.prototype["resolveRunOverrides"].call( + host as unknown as ClineProvider, + { profile: "ci", provider: "anthropic" }, + { apiProvider: "anthropic" }, + ), + ).rejects.toThrow("mutually exclusive") + }) + + it("covers generic models, disabled reasoning, and invalid override values", async () => { + const host = createResolverHost() + const baseline = { apiProvider: "openai", apiModelId: "baseline" } as ProviderSettings + const result = await ClineProvider.prototype["resolveRunOverrides"].call( + host as unknown as ClineProvider, + { model: "generic", mode: "code", reasoningEffort: "disabled" }, + baseline, + ) + expect(result.apiConfiguration).toMatchObject({ + apiModelId: "generic", + enableReasoningEffort: false, + reasoningEffort: undefined, + }) + + await expect( + ClineProvider.prototype["resolveRunOverrides"].call( + host as unknown as ClineProvider, + { provider: "missing-provider", mode: "code" }, + baseline, + ), + ).rejects.toThrow("Unknown provider override") + await expect( + ClineProvider.prototype["resolveRunOverrides"].call( + host as unknown as ClineProvider, + { mode: "missing-mode" }, + baseline, + ), + ).rejects.toThrow("Unknown mode override") + }) + + it("rejects combining persistent configuration with run overrides", async () => { + await expect( + ClineProvider.prototype.createTask.call( + createResolverHost() as unknown as ClineProvider, + "task", + undefined, + undefined, + {}, + { allowedCommands: ["echo"] }, + { mode: "code" }, + ), + ).rejects.toThrow("Persistent configuration and run overrides cannot be combined") + }) +}) diff --git a/src/extension/__tests__/api-headless.spec.ts b/src/extension/__tests__/api-headless.spec.ts index 7187e3a5e5..f4bad60fd2 100644 --- a/src/extension/__tests__/api-headless.spec.ts +++ b/src/extension/__tests__/api-headless.spec.ts @@ -74,12 +74,22 @@ describe("API headless facade", () => { taskId: "root-1", rootTaskId: "root-1", }) - expect(createTaskMock).toHaveBeenCalledWith(" preserve whitespace ", undefined, undefined, {}, undefined) + expect(createTaskMock).toHaveBeenCalledWith( + " preserve whitespace ", + undefined, + undefined, + {}, + undefined, + undefined, + ) expect(vscode.commands.executeCommand).not.toHaveBeenCalled() }) it("validates initialization and active-run boundaries", async () => { await expect(api.startHeadlessTask({ text: " " })).rejects.toThrow("must not be blank") + await expect( + api.startHeadlessTask({ text: "conflict", configuration: {}, overrides: { mode: "code" } }), + ).rejects.toThrow("Persistent configuration and run overrides cannot be combined") await api.startHeadlessTask({ text: "active" }) await expect(api.startHeadlessTask({ text: "second" })).rejects.toThrow("already active") await expect(api.resumeHeadlessTask("other")).rejects.toThrow("already active") @@ -98,7 +108,19 @@ describe("API headless facade", () => { taskId: "child-1", rootTaskId: "root-1", }) - expect(provider.createTaskWithHistoryItem).toHaveBeenCalledWith(historyItem) + expect(provider.createTaskWithHistoryItem).toHaveBeenCalledWith(historyItem, { runOverrides: undefined }) + }) + + it("forwards run overrides separately from persistent configuration", async () => { + await api.startHeadlessTask({ + text: "task", + overrides: { provider: "openrouter", model: "model-1", approval: "safe" }, + }) + expect(createTaskMock).toHaveBeenCalledWith("task", undefined, undefined, {}, undefined, { + provider: "openrouter", + model: "model-1", + approval: "safe", + }) }) it("routes a response only to the matching task and ask", async () => { diff --git a/src/extension/api.ts b/src/extension/api.ts index d5a976db52..3c47a218e2 100644 --- a/src/extension/api.ts +++ b/src/extension/api.ts @@ -20,6 +20,7 @@ import { type HeadlessShutdownReport, type HeadlessTaskReference, type HeadlessTaskResult, + type RunOverrides, RooCodeEventName, TaskCommandName, isSecretStateKey, @@ -195,29 +196,32 @@ export class API extends EventEmitter implements RooCodeAPI { text, images, configuration, + overrides, }: { text: string images?: string[] configuration?: RooCodeSettings + overrides?: RunOverrides }): Promise { await this.initializeHeadless() if (!text.trim()) throw new Error("Headless task text must not be blank") if ([...this.headlessRuns.values()].some((run) => !run.result)) { throw new Error("A headless root task is already active") } - const task = await this.sidebarProvider.createTask(text, images, undefined, {}, configuration) + if (configuration && overrides) throw new Error("Persistent configuration and run overrides cannot be combined") + const task = await this.sidebarProvider.createTask(text, images, undefined, {}, configuration, overrides) const rootTaskId = task.rootTaskId ?? task.taskId this.createHeadlessRun(rootTaskId, task.taskId) return { taskId: task.taskId, rootTaskId } } - public async resumeHeadlessTask(taskId: string): Promise { + public async resumeHeadlessTask(taskId: string, overrides?: RunOverrides): Promise { await this.initializeHeadless() if ([...this.headlessRuns.values()].some((run) => !run.result)) { throw new Error("A headless root task is already active") } const { historyItem } = await this.sidebarProvider.getTaskWithId(taskId) - const task = await this.sidebarProvider.createTaskWithHistoryItem(historyItem) + const task = await this.sidebarProvider.createTaskWithHistoryItem(historyItem, { runOverrides: overrides }) const rootTaskId = historyItem.rootTaskId ?? historyItem.id this.createHeadlessRun(rootTaskId, task.taskId) return { taskId: task.taskId, rootTaskId }