diff --git a/packages/kilo-vscode/tests/settings-accessibility.spec.ts b/packages/kilo-vscode/tests/settings-accessibility.spec.ts index 4abad0a998..b655b29e61 100644 --- a/packages/kilo-vscode/tests/settings-accessibility.spec.ts +++ b/packages/kilo-vscode/tests/settings-accessibility.spec.ts @@ -65,7 +65,7 @@ test.describe("settings tab accessibility", () => { await expect(tab).toHaveAttribute("aria-selected", "true") await expect(page.getByRole("tabpanel", { name: "Sandboxing" })).toBeVisible() const network = page.getByRole("switch", { name: "Restrict Network Access" }) - await expect(page.getByText(/Local MCP servers and plugin hooks run outside this restriction/)).toBeVisible() + await expect(network).toHaveAccessibleDescription(/Local MCP servers and plugin hooks run outside this restriction/) await expect(network).toBeChecked() await page.locator('[data-slot="switch-control"]').click() await expect(network).not.toBeChecked() diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/SandboxingTab.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/SandboxingTab.tsx index fdc27be5eb..e67ec17c7a 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/SandboxingTab.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/SandboxingTab.tsx @@ -5,6 +5,8 @@ import { useConfig } from "../../context/config" import { useLanguage } from "../../context/language" import SettingsRow from "./SettingsRow" +const description = "sandbox-network-description" + const SandboxingTab: Component = () => { const { config, updateConfig } = useConfig() const language = useLanguage() @@ -15,10 +17,12 @@ const SandboxingTab: Component = () => { updateConfig({ experimental: { diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/SettingsRow.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/SettingsRow.tsx index 5596527526..f913d9426c 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/SettingsRow.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/SettingsRow.tsx @@ -4,6 +4,7 @@ import { Component, JSX, Show } from "solid-js" const SettingsRow: Component<{ title: string description?: string + descriptionId?: string tag?: () => string | undefined last?: boolean children: JSX.Element @@ -32,7 +33,9 @@ const SettingsRow: Component<{ {(tag) => {tag()}} {props.description !== null && props.description !== undefined && ( -
{props.description}
+
+ {props.description} +
)}
{props.children}
diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index dddfd9875e..620fc1e238 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -2,6 +2,8 @@ import { expect, mock, beforeEach } from "bun:test" import { Cause, Effect, Exit } from "effect" import type { MCP as MCPNS } from "../../src/mcp/index" import { testEffect } from "../lib/effect" +import * as SandboxNetwork from "../../src/kilocode/sandbox/network" // kilocode_change +import { run as runSandbox, type Profile } from "@kilocode/sandbox" // kilocode_change // --- Mock infrastructure --- @@ -183,11 +185,77 @@ const { McpOAuthCallback } = await import("../../src/mcp/oauth-callback") const it = testEffect(MCP.defaultLayer) +// kilocode_change start +function sandboxProfile(): Profile { + return { + filesystem: { allowWrite: [], denyWrite: [], denyNames: [] }, + network: { mode: "deny", allowedHosts: [] }, + environment: { deny: [], set: {} }, + } +} +// kilocode_change end + function statusName(status: Record | MCPNS.Status, server: string) { if ("status" in status) return status.status return status[server]?.status } +// kilocode_change start +it.instance( + "classifies production remote MCP tools while leaving local MCP tools available", + () => + MCP.Service.use((mcp: MCPNS.Interface) => + Effect.gen(function* () { + lastCreatedClientName = "local-server" + getOrCreateClientState("local-server") + yield* mcp.add("local-server", { type: "local", command: ["node", "fake.js"] }) + + lastCreatedClientName = "remote-server" + getOrCreateClientState("remote-server") + yield* mcp.add("remote-server", { + type: "remote", + url: "http://localhost:9999/mcp", + oauth: false, + }) + + const tools = yield* mcp.tools() + const local = Object.entries(tools).find(([key]) => key.startsWith("local") && key.endsWith("_test_tool"))?.[1] + const remote = Object.entries(tools).find( + ([key]) => key.startsWith("remote") && key.endsWith("_test_tool"), + )?.[1] + if (!local || !remote) return yield* Effect.die(new Error("expected MCP tools are missing")) + + let localCalled = false + let remoteCalled = false + const localExit = yield* runSandbox( + sandboxProfile(), + SandboxNetwork.mcp( + local, + Effect.sync(() => { + localCalled = true + }), + ), + ).pipe(Effect.exit) + const remoteExit = yield* runSandbox( + sandboxProfile(), + SandboxNetwork.mcp( + remote, + Effect.sync(() => { + remoteCalled = true + }), + ), + ).pipe(Effect.exit) + + expect(Exit.isSuccess(localExit)).toBe(true) + expect(localCalled).toBe(true) + expect(Exit.isFailure(remoteExit)).toBe(true) + expect(remoteCalled).toBe(false) + }), + ), + { config: { mcp: {} } }, +) +// kilocode_change end + // ======================================================================== // Test: tools() are cached after connect // ======================================================================== diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index 23340de170..7414b7e312 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect } from "bun:test" import path from "path" import fs from "fs/promises" import { fileURLToPath, pathToFileURL } from "url" -import { Effect, Layer, Result, Schema } from "effect" +import { Effect, Exit, Layer, Result, Schema } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { ToolRegistry } from "@/tool/registry" import { Tool } from "@/tool/tool" @@ -35,6 +35,8 @@ import { ToolJsonSchema } from "@/tool/json-schema" import { MessageID, SessionID } from "@/session/schema" import { RuntimeFlags } from "@/effect/runtime-flags" import { Command } from "@/command" // kilocode_change +import * as SandboxNetwork from "@/kilocode/sandbox/network" // kilocode_change +import { run as runSandbox, type Profile } from "@kilocode/sandbox" // kilocode_change const node = CrossSpawnSpawner.defaultLayer const configLayer = TestConfig.layer({ @@ -106,12 +108,56 @@ const scout = testEffect( const withBrokenPlugin = testEffect( Layer.mergeAll(registryLayer({ plugin: brokenPluginLayer }), node, Agent.defaultLayer), ) +const sandboxed = testEffect( + Layer.mergeAll(registryLayer({ flags: { experimentalLspTool: true } }), node, Agent.defaultLayer), +) // kilocode_change afterEach(async () => { await disposeAllInstances() }) +// kilocode_change start +function sandboxProfile(): Profile { + return { + filesystem: { allowWrite: [], denyWrite: [], denyNames: [] }, + network: { mode: "deny", allowedHosts: [] }, + environment: { deny: [], set: {} }, + } +} +// kilocode_change end + describe("tool.registry", () => { + // kilocode_change start + sandboxed.instance("preserves built-in network classification through production tool definition processing", () => + Effect.gen(function* () { + const registry = yield* ToolRegistry.Service + const agent = yield* Agent.Service + const build = yield* agent.get("build") + if (!build) return yield* Effect.die(new Error("build agent not found")) + const tools = yield* registry.tools({ + providerID: ProviderID.opencode, + modelID: ModelID.make("test"), + agent: build, + }) + const all = yield* registry.all() + const read = tools.find((tool) => tool.id === "read") + const search = all.find((tool) => tool.id === "lsp") + if (!read || !search) return yield* Effect.die(new Error("expected built-in tools are missing")) + + const allowed = yield* runSandbox(sandboxProfile(), SandboxNetwork.tool(read, Effect.succeed("allowed"))).pipe( + Effect.exit, + ) + const denied = yield* runSandbox( + sandboxProfile(), + SandboxNetwork.tool(search, Effect.succeed("unexpected")), + ).pipe(Effect.exit) + + expect(Exit.isSuccess(allowed)).toBe(true) + expect(Exit.isFailure(denied)).toBe(true) + }), + ) + // kilocode_change end + it.instance("hides repo research tools unless experimental", () => Effect.gen(function* () { const registry = yield* ToolRegistry.Service diff --git a/packages/ui/src/components/switch.tsx b/packages/ui/src/components/switch.tsx index f4f95baf57..fe6b68edee 100644 --- a/packages/ui/src/components/switch.tsx +++ b/packages/ui/src/components/switch.tsx @@ -5,13 +5,14 @@ import type { ComponentProps, ParentProps } from "solid-js" export interface SwitchProps extends ParentProps> { hideLabel?: boolean description?: string + inputProps?: ComponentProps } export function Switch(props: SwitchProps) { - const [local, others] = splitProps(props, ["children", "class", "hideLabel", "description"]) + const [local, others] = splitProps(props, ["children", "class", "hideLabel", "description", "inputProps"]) return ( - + {local.children}