Skip to content

Commit d692b18

Browse files
committed
feat(cli): add secure supervised Zoo host
1 parent 83a32fc commit d692b18

24 files changed

Lines changed: 838 additions & 48 deletions

packages/types/src/api.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,15 @@ export type HeadlessCapabilities = {
1717
rootTaskResults: true
1818
}
1919

20+
export type RunOverrides = {
21+
provider?: string
22+
profile?: string
23+
model?: string
24+
mode?: string
25+
reasoningEffort?: "disabled" | "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"
26+
approval?: "interactive" | "safe" | "auto"
27+
}
28+
2029
export type HeadlessTaskReference = { taskId: string; rootTaskId: string }
2130

2231
export type HeadlessAskResponse =
@@ -52,8 +61,9 @@ export interface RooCodeAPI extends EventEmitter<RooCodeAPIEvents> {
5261
text: string
5362
images?: string[]
5463
configuration?: RooCodeSettings
64+
overrides?: RunOverrides
5565
}): Promise<HeadlessTaskReference>
56-
resumeHeadlessTask(taskId: string): Promise<HeadlessTaskReference>
66+
resumeHeadlessTask(taskId: string, overrides?: RunOverrides): Promise<HeadlessTaskReference>
5767
respondToHeadlessAsk(input: { taskId: string; askId: string; response: HeadlessAskResponse }): Promise<void>
5868
cancelHeadlessTask(input: {
5969
rootTaskId: string

packages/vscode-shim/src/__tests__/ExtensionContext.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { ExtensionContextImpl } from "../context/ExtensionContext.js"
22
import * as fs from "fs"
33
import * as path from "path"
44
import { tmpdir } from "os"
5+
import type { SecretStorage } from "../types.js"
56

67
describe("ExtensionContextImpl", () => {
78
let tempDir: string
@@ -69,6 +70,19 @@ describe("ExtensionContextImpl", () => {
6970

7071
expect(context.environmentVariableCollection).toEqual({})
7172
})
73+
74+
it("uses an injected secure SecretStorage without creating a plaintext file", () => {
75+
const secretStorage: SecretStorage = {
76+
get: async () => undefined,
77+
store: async () => undefined,
78+
delete: async () => undefined,
79+
onDidChange: () => ({ dispose() {} }),
80+
}
81+
const storageDir = path.join(tempDir, "secure-storage")
82+
const context = new ExtensionContextImpl({ extensionPath, workspacePath, storageDir, secretStorage })
83+
expect(context.secrets).toBe(secretStorage)
84+
expect(fs.existsSync(path.join(storageDir, "global-storage", "secrets.json"))).toBe(false)
85+
})
7286
})
7387

7488
describe("storage paths", () => {

packages/vscode-shim/src/api/create-vscode-api-mock.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ import type { CancellationToken } from "../interfaces/document.js"
5555
import type { Disposable, DiagnosticCollection, IdentityInfo } from "../interfaces/workspace.js"
5656
import type { RelativePattern } from "../interfaces/document.js"
5757
import type { UriHandler } from "../interfaces/webview.js"
58+
import type { SecretStorage } from "../types.js"
5859

5960
// Package version constant
6061
const Package = { version: "1.0.0" }
@@ -75,6 +76,9 @@ export interface VSCodeAPIMockOptions {
7576
* Set to a temp directory for ephemeral/no-persist mode.
7677
*/
7778
storageDir?: string
79+
80+
/** Secure storage supplied by the embedding host. */
81+
secretStorage?: SecretStorage
7882
}
7983

8084
/**
@@ -90,6 +94,7 @@ export function createVSCodeAPIMock(
9094
extensionPath: extensionRootPath,
9195
workspacePath: workspacePath,
9296
storageDir: options?.storageDir,
97+
secretStorage: options?.secretStorage,
9398
})
9499
const workspace = new WorkspaceAPI(workspacePath, context)
95100
const window = new WindowAPI()

packages/vscode-shim/src/context/ExtensionContext.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ export interface ExtensionContextOptions {
3737
* Extension mode (Production, Development, or Test)
3838
*/
3939
extensionMode?: ExtensionMode
40+
41+
/** Secure storage supplied by the embedding host. */
42+
secretStorage?: SecretStorage
4043
}
4144

4245
/**
@@ -108,7 +111,7 @@ export class ExtensionContextImpl implements ExtensionContext {
108111
},
109112
})
110113

111-
this.secrets = new FileSecretStorage(this.globalStoragePath)
114+
this.secrets = options.secretStorage ?? new FileSecretStorage(this.globalStoragePath)
112115

113116
// Load extension metadata (packageJSON)
114117
this.extension = this.loadExtensionMetadata()

packages/vscode-shim/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
export {
1313
// Main factory function
1414
createVSCodeAPIMock,
15+
type VSCodeAPIMockOptions,
1516

1617
// Classes
1718
Uri,
@@ -77,6 +78,7 @@ export {
7778
type WorkspaceConfiguration,
7879
type Memento,
7980
type SecretStorage,
81+
type SecretStorageChangeEvent,
8082
type FileStat,
8183
type Terminal,
8284
type CancellationToken,

packages/vscode-shim/src/vscode.ts

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ export { WorkspaceAPI } from "./api/WorkspaceAPI.js"
5252
export { TabGroupsAPI, type Tab, type TabInputText, type TabGroup } from "./api/TabGroupsAPI.js"
5353
export { WindowAPI } from "./api/WindowAPI.js"
5454
export { CommandsAPI } from "./api/CommandsAPI.js"
55-
export { createVSCodeAPIMock } from "./api/create-vscode-api-mock.js"
55+
export { createVSCodeAPIMock, type VSCodeAPIMockOptions } from "./api/create-vscode-api-mock.js"
5656

5757
// ============================================================================
5858
// Enums from ./types.ts
@@ -76,7 +76,15 @@ export {
7676
// ============================================================================
7777
// Types from ./types.ts
7878
// ============================================================================
79-
export type { Thenable, Memento, FileStat, TextEditorOptions, ConfigurationInspect } from "./types.js"
79+
export type {
80+
Thenable,
81+
Memento,
82+
SecretStorage,
83+
SecretStorageChangeEvent,
84+
FileStat,
85+
TextEditorOptions,
86+
ConfigurationInspect,
87+
} from "./types.js"
8088

8189
// ============================================================================
8290
// Interfaces from ./interfaces/
@@ -142,15 +150,3 @@ export type {
142150
DiagnosticCollection,
143151
IdentityInfo,
144152
} from "./interfaces/workspace.js"
145-
146-
// ============================================================================
147-
// Secret Storage interface (backwards compatibility)
148-
// ============================================================================
149-
export interface SecretStorage {
150-
get(key: string): Thenable<string | undefined>
151-
store(key: string, value: string): Thenable<void>
152-
delete(key: string): Thenable<void>
153-
}
154-
155-
// Import Thenable for SecretStorage interface
156-
import type { Thenable } from "./types.js"
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import { config } from "@roo-code/config-eslint/base"
2+
3+
/** @type {import("eslint").Linter.Config} */
4+
export default [...config]

packages/zoo-host/package.json

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{
2+
"name": "@roo-code/zoo-host",
3+
"description": "Private supervised extension host for the Zoo CLI.",
4+
"private": true,
5+
"type": "module",
6+
"main": "./dist/child.js",
7+
"types": "./src/index.ts",
8+
"scripts": {
9+
"lint": "eslint src --ext=ts --max-warnings=0",
10+
"check-types": "tsc --noEmit",
11+
"test": "vitest run",
12+
"build": "tsc",
13+
"clean": "rimraf dist .turbo"
14+
},
15+
"dependencies": {
16+
"@roo-code/types": "workspace:^",
17+
"@roo-code/vscode-shim": "workspace:^",
18+
"@roo-code/zoo-protocol": "workspace:^"
19+
},
20+
"devDependencies": {
21+
"@roo-code/config-eslint": "workspace:^",
22+
"@roo-code/config-typescript": "workspace:^",
23+
"@types/node": "22.20.1",
24+
"vitest": "4.1.9"
25+
}
26+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { describe, expect, it, vi } from "vitest"
2+
3+
import { HostCommandDispatcher } from "../dispatcher.js"
4+
import { validateHostRoots } from "../roots.js"
5+
import { VaultSecretStorage, type VaultBackend } from "../security.js"
6+
import { HostTransport } from "../transport.js"
7+
8+
describe("host security and transport", () => {
9+
it("requires explicit absolute roots", () => {
10+
expect(() =>
11+
validateHostRoots({
12+
extensionRoot: "relative",
13+
workspaceRoot: "/workspace",
14+
storageRoot: "/storage",
15+
appRoot: "/app",
16+
}),
17+
).toThrow("extensionRoot")
18+
expect(
19+
validateHostRoots({
20+
extensionRoot: "/extension",
21+
workspaceRoot: "/workspace",
22+
storageRoot: "/storage",
23+
appRoot: "/app",
24+
}),
25+
).toEqual({
26+
extensionRoot: "/extension",
27+
workspaceRoot: "/workspace",
28+
storageRoot: "/storage",
29+
appRoot: "/app",
30+
})
31+
})
32+
33+
it("round trips secrets only through the injected vault", async () => {
34+
const values = new Map<string, string>()
35+
const backend: VaultBackend = {
36+
get: vi.fn(async (key) => values.get(key)),
37+
store: vi.fn(async (key, value) => void values.set(key, value)),
38+
delete: vi.fn(async (key) => void values.delete(key)),
39+
}
40+
const storage = new VaultSecretStorage(backend)
41+
const changes: string[] = []
42+
storage.onDidChange(({ key }) => changes.push(key))
43+
await storage.store("api-key", "secret")
44+
await expect(storage.get("api-key")).resolves.toBe("secret")
45+
await storage.delete("api-key")
46+
expect(changes).toEqual(["api-key", "api-key"])
47+
expect(backend.store).toHaveBeenCalledWith("api-key", "secret")
48+
})
49+
50+
it("uses one monotonic sequence for ACK and DONE", async () => {
51+
const sent: unknown[] = []
52+
const transport = new HostTransport("host-1", async (message) => void sent.push(message))
53+
const api = {
54+
startHeadlessTask: vi.fn().mockResolvedValue({ taskId: "root", rootTaskId: "root" }),
55+
} as never
56+
const dispatcher = new HostCommandDispatcher(api, transport, "/workspace")
57+
await dispatcher.dispatch({ v: 1, id: "cmd-1", type: "task.start", workspace: "/workspace", prompt: "hello" })
58+
expect(sent).toMatchObject([
59+
{ seq: 1, type: "command.ack", commandId: "cmd-1" },
60+
{ seq: 2, type: "command.done", commandId: "cmd-1", data: { commandType: "task.start" } },
61+
])
62+
})
63+
64+
it("rejects workspace identity changes after ACK", async () => {
65+
const sent: unknown[] = []
66+
const transport = new HostTransport("host-1", async (message) => void sent.push(message))
67+
const dispatcher = new HostCommandDispatcher({} as never, transport, "/workspace")
68+
await dispatcher.dispatch({ v: 1, id: "cmd-1", type: "task.start", workspace: "/other", prompt: "hello" })
69+
expect(sent).toMatchObject([
70+
{ seq: 1, type: "command.ack" },
71+
{ seq: 2, type: "command.error", error: { code: "task_failed" } },
72+
])
73+
})
74+
})

packages/zoo-host/src/bootstrap.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import fs from "node:fs"
2+
import path from "node:path"
3+
import { createRequire } from "node:module"
4+
5+
import type { RooCodeAPI } from "@roo-code/types"
6+
import { createVSCodeAPI, type SecretStorage } from "@roo-code/vscode-shim"
7+
8+
import { validateHostRoots, type HostRoots } from "./roots.js"
9+
10+
type ExtensionModule = { activate(context: unknown): Promise<RooCodeAPI>; deactivate?(): Promise<void> }
11+
12+
export async function activateExtensionHost(rootsInput: HostRoots, secretStorage: SecretStorage) {
13+
const roots = validateHostRoots(rootsInput)
14+
const bundlePath = path.join(roots.extensionRoot, "extension.js")
15+
if (!fs.existsSync(bundlePath)) throw new Error(`Extension bundle not found at ${bundlePath}`)
16+
const vscode = createVSCodeAPI(roots.extensionRoot, roots.workspaceRoot, undefined, {
17+
appRoot: roots.appRoot,
18+
storageDir: roots.storageRoot,
19+
secretStorage,
20+
})
21+
;(globalThis as Record<string, unknown>).vscode = vscode
22+
const require = createRequire(import.meta.url)
23+
const Module = require("module") as {
24+
_resolveFilename(request: string, parent: unknown, isMain: boolean, options: unknown): string
25+
}
26+
const originalResolve = Module._resolveFilename
27+
Module._resolveFilename = function (request, parent, isMain, options) {
28+
return request === "vscode" ? "zoo-vscode-shim" : originalResolve.call(this, request, parent, isMain, options)
29+
}
30+
require.cache["zoo-vscode-shim"] = {
31+
id: "zoo-vscode-shim",
32+
filename: "zoo-vscode-shim",
33+
loaded: true,
34+
exports: vscode,
35+
children: [],
36+
paths: [],
37+
path: "",
38+
isPreloading: false,
39+
parent: null,
40+
require,
41+
} as unknown as NodeJS.Module
42+
let extension: ExtensionModule
43+
try {
44+
extension = require(bundlePath) as ExtensionModule
45+
} finally {
46+
Module._resolveFilename = originalResolve
47+
}
48+
const api = await extension.activate(vscode.context)
49+
await api.initializeHeadless()
50+
return {
51+
api,
52+
async dispose() {
53+
await api.shutdownHeadless()
54+
await extension.deactivate?.()
55+
vscode.context.dispose()
56+
delete (globalThis as Record<string, unknown>).vscode
57+
},
58+
}
59+
}

0 commit comments

Comments
 (0)