Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion packages/types/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -52,8 +61,9 @@ export interface RooCodeAPI extends EventEmitter<RooCodeAPIEvents> {
text: string
images?: string[]
configuration?: RooCodeSettings
overrides?: RunOverrides
}): Promise<HeadlessTaskReference>
resumeHeadlessTask(taskId: string): Promise<HeadlessTaskReference>
resumeHeadlessTask(taskId: string, overrides?: RunOverrides): Promise<HeadlessTaskReference>
respondToHeadlessAsk(input: { taskId: string; askId: string; response: HeadlessAskResponse }): Promise<void>
cancelHeadlessTask(input: {
rootTaskId: string
Expand Down
14 changes: 14 additions & 0 deletions packages/vscode-shim/src/__tests__/ExtensionContext.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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", () => {
Expand Down
5 changes: 5 additions & 0 deletions packages/vscode-shim/src/api/create-vscode-api-mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand All @@ -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
}

/**
Expand All @@ -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()
Expand Down
5 changes: 4 additions & 1 deletion packages/vscode-shim/src/context/ExtensionContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ export interface ExtensionContextOptions {
* Extension mode (Production, Development, or Test)
*/
extensionMode?: ExtensionMode

/** Secure storage supplied by the embedding host. */
secretStorage?: SecretStorage
}

/**
Expand Down Expand Up @@ -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()
Expand Down
2 changes: 2 additions & 0 deletions packages/vscode-shim/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
export {
// Main factory function
createVSCodeAPIMock,
type VSCodeAPIMockOptions,

// Classes
Uri,
Expand Down Expand Up @@ -77,6 +78,7 @@ export {
type WorkspaceConfiguration,
type Memento,
type SecretStorage,
type SecretStorageChangeEvent,
type FileStat,
type Terminal,
type CancellationToken,
Expand Down
24 changes: 10 additions & 14 deletions packages/vscode-shim/src/vscode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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/
Expand Down Expand Up @@ -142,15 +150,3 @@ export type {
DiagnosticCollection,
IdentityInfo,
} from "./interfaces/workspace.js"

// ============================================================================
// Secret Storage interface (backwards compatibility)
// ============================================================================
export interface SecretStorage {
get(key: string): Thenable<string | undefined>
store(key: string, value: string): Thenable<void>
delete(key: string): Thenable<void>
}

// Import Thenable for SecretStorage interface
import type { Thenable } from "./types.js"
4 changes: 4 additions & 0 deletions packages/zoo-host/eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { config } from "@roo-code/config-eslint/base"

/** @type {import("eslint").Linter.Config} */
export default [...config]
27 changes: 27 additions & 0 deletions packages/zoo-host/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
174 changes: 174 additions & 0 deletions packages/zoo-host/src/__tests__/host.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>()
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" } },
])
})
})
Loading
Loading