From 71936e613764319abbb83c6237f30f141f048df6 Mon Sep 17 00:00:00 2001 From: Evan Jacobson Date: Thu, 28 May 2026 14:05:43 -0600 Subject: [PATCH 1/6] Docs: add VS Code proxy and CA troubleshooting --- .../code-with-ai/platforms/vscode/index.md | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/packages/kilo-docs/pages/code-with-ai/platforms/vscode/index.md b/packages/kilo-docs/pages/code-with-ai/platforms/vscode/index.md index 9c3cfb5c07d..ba857166eb4 100644 --- a/packages/kilo-docs/pages/code-with-ai/platforms/vscode/index.md +++ b/packages/kilo-docs/pages/code-with-ai/platforms/vscode/index.md @@ -41,6 +41,29 @@ Key features include: Settings apply across extension surfaces, including the sidebar and Agent Manager. The standalone CLI uses the same `~/.config/kilo/kilo.jsonc` (global) and `./kilo.jsonc` (project) files when used directly. +## Proxy and Certificate Troubleshooting + +Kilo Code for VS Code starts its embedded runtime from the extension and applies the relevant VS Code network settings to that runtime. On managed networks, configure proxy and certificate trust in VS Code settings rather than in a separate CLI install. + +Use these settings when your organization requires a proxy or inspects HTTPS traffic: + +- Set `http.proxy` to your organization proxy URL. +- Use `http.noProxy` for hosts that should bypass the proxy. +- Leave `http.proxySupport` enabled unless you intentionally want VS Code and Kilo Code to ignore proxy settings. +- Install your organization's root certificate authority in the operating system trust store when HTTPS inspection is in use. +- If the operating system trust store is not enough, set `kilo-code.new.extraCaCerts` to the absolute path of a PEM file that contains the additional certificate authority certificates. +- Keep `http.proxyStrictSSL` enabled whenever possible. Disable it only as a temporary troubleshooting step or when your administrator explicitly requires it, because it disables TLS certificate verification for this path. + +Example user or workspace settings: + +```json +{ + "http.proxy": "http://proxy.example.com:8080", + "http.noProxy": ["localhost", "127.0.0.1", ".example.internal"], + "kilo-code.new.extraCaCerts": "/absolute/path/to/corporate-ca.pem" +} +``` + {% /tab %} {% tab label="VSCode (Legacy)" %} From e28cb43b7e04328f19d6ae631d1961c13132bbf5 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Fri, 29 May 2026 09:55:59 +0200 Subject: [PATCH 2/6] refactor(cli): remove Permission promise facade --- .../src/kilo-sessions/kilo-sessions.ts | 6 +- .../src/kilo-sessions/remote-sender.ts | 3 +- packages/opencode/src/permission/index.ts | 13 -- .../external-directory-allow.test.ts | 183 ++++++++++-------- .../permission/next.reply-http.test.ts | 19 +- .../permission-allow-everything.test.ts | 20 +- .../kilocode/sessions/remote-sender.test.ts | 13 +- script/check-opencode-promise-facades.ts | 1 - 8 files changed, 143 insertions(+), 115 deletions(-) diff --git a/packages/opencode/src/kilo-sessions/kilo-sessions.ts b/packages/opencode/src/kilo-sessions/kilo-sessions.ts index a4f5b80155e..c39d64a16e8 100644 --- a/packages/opencode/src/kilo-sessions/kilo-sessions.ts +++ b/packages/opencode/src/kilo-sessions/kilo-sessions.ts @@ -190,13 +190,15 @@ export namespace KiloSessions { const STATUS_TIMEOUT_MS = 3_000 async function deriveStatus(sessionID: string): Promise<"idle" | "busy" | "question" | "permission" | "retry"> { - const permissions = (await Permission.list()).filter((p) => p.sessionID === sessionID) + const { AppRuntime } = await import("@/effect/app-runtime") + const permissions = ( + await AppRuntime.runPromise(Permission.Service.use((svc) => svc.list())) + ).filter((p) => p.sessionID === sessionID) if (permissions.length > 0) return "permission" const questions = (await Question.list()).filter((q) => q.sessionID === sessionID) if (questions.length > 0) return "question" - const { AppRuntime } = await import("@/effect/app-runtime") const status = await AppRuntime.runPromise(SessionStatus.Service.use((svc) => svc.get(SessionID.make(sessionID)))) if (status.type === "offline") return "retry" return status.type diff --git a/packages/opencode/src/kilo-sessions/remote-sender.ts b/packages/opencode/src/kilo-sessions/remote-sender.ts index 31d4ee52ecf..6d3944b65b1 100644 --- a/packages/opencode/src/kilo-sessions/remote-sender.ts +++ b/packages/opencode/src/kilo-sessions/remote-sender.ts @@ -130,10 +130,11 @@ export namespace RemoteSender { // sees state that was asked before it connected — analogous to the Cloud // Agent's `connected` event carrying pending question/permission fields. async function replay(sessionId: string) { + const { AppRuntime } = await import("@/effect/app-runtime") const [suggestions, questions, permissions] = await Promise.all([ Suggestion.list(), Question.list(), - Permission.list(), + AppRuntime.runPromise(Permission.Service.use((svc) => svc.list())), ]) for (const suggestion of suggestions) { if (suggestion.sessionID !== sessionId) continue diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index 60a23d4b48e..c5ee7406378 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -17,7 +17,6 @@ import os from "os" import z from "zod" // kilocode_change import { evaluate as evalRule } from "./evaluate" import { PermissionID } from "./schema" -import { makeRuntime } from "@/effect/run-service" // kilocode_change import { ConfigProtection } from "@/kilocode/permission/config-paths" // kilocode_change import { Identifier } from "@/id/id" // kilocode_change import { drainCovered } from "@/kilocode/permission/drain" // kilocode_change @@ -553,16 +552,4 @@ export function toConfig(rules: Ruleset): ConfigPermission.Info { } // kilocode_change end -// kilocode_change start - legacy promise helpers for Kilo callsites -const { runPromise } = makeRuntime(Service, defaultLayer) -export const list = () => runPromise((svc) => svc.list()) -export const ask = (input: AskInput) => runPromise((svc) => svc.ask(input)) -const replyPromise = (input: ReplyInput) => runPromise((svc) => svc.reply(input)) -export { replyPromise as reply } -export const saveAlwaysRules = (input: z.infer) => - runPromise((svc) => svc.saveAlwaysRules(input)) -export const allowEverything = (input: z.infer) => - runPromise((svc) => svc.allowEverything(input)) -// kilocode_change end - export * as Permission from "." diff --git a/packages/opencode/test/kilocode/permission/external-directory-allow.test.ts b/packages/opencode/test/kilocode/permission/external-directory-allow.test.ts index d5e42127027..3b4c4e6e9a3 100644 --- a/packages/opencode/test/kilocode/permission/external-directory-allow.test.ts +++ b/packages/opencode/test/kilocode/permission/external-directory-allow.test.ts @@ -1,10 +1,11 @@ import { afterEach, describe, expect, test } from "bun:test" -import { Effect, Layer, ManagedRuntime } from "effect" +import { Cause, Effect, Exit, Fiber, Layer, ManagedRuntime } from "effect" import path from "path" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Global } from "@opencode-ai/core/global" import { Agent } from "../../../src/agent/agent" +import { Bus } from "../../../src/bus" import { Config } from "../../../src/config/config" import { Permission } from "../../../src/permission" import { PermissionID } from "../../../src/permission/schema" @@ -14,7 +15,8 @@ import { Shell } from "../../../src/shell/shell" import { Truncate } from "../../../src/tool/truncate" import { ShellTool } from "../../../src/tool/shell" import { Plugin } from "../../../src/plugin" -import { disposeAllInstances, tmpdir } from "../../fixture/fixture" +import { disposeAllInstances, provideTmpdirInstance, tmpdir } from "../../fixture/fixture" +import { testEffect } from "../../lib/effect" import { ConfigProtection } from "../../../src/kilocode/permission/config-paths" const runtime = ManagedRuntime.make( @@ -65,6 +67,31 @@ const variants = (dir: string) => { const config = path.resolve(Global.Path.config) const configFile = path.join(config, "hello.txt") const configGlob = glob(path.join(config, "*")) +const bus = Bus.layer +const env = Layer.mergeAll( + Permission.layer.pipe(Layer.provide(bus), Layer.provide(Config.defaultLayer)), + bus, + CrossSpawnSpawner.defaultLayer, +) +const it = testEffect(env) + +const ask = (input: Permission.AskInput) => + Effect.gen(function* () { + const permission = yield* Permission.Service + return yield* permission.ask(input) + }) + +const reply = (input: Permission.ReplyInput) => + Effect.gen(function* () { + const permission = yield* Permission.Service + return yield* permission.reply(input) + }) + +const list = () => + Effect.gen(function* () { + const permission = yield* Permission.Service + return yield* permission.list() + }) const capture = (requests: Array>, stop?: Error) => ({ ...ctx, @@ -90,52 +117,46 @@ const withShell = (item: { shell: string }, fn: () => Promise) => async () } } -async function reject() { - const requests = await Permission.list() - for (const req of requests) { - await Permission.reply({ requestID: req.id, reply: "reject" }) - } -} - -async function immediate(pending: Promise) { - try { - await Promise.race([ - pending, - new Promise((_, reject) => - setTimeout(() => reject(new Error("timed out waiting for permission to resolve")), 500), - ), - ]) - } finally { - const requests = await Permission.list() - if (requests.length > 0) { - await reject() - await pending.catch(() => undefined) +const reject = () => + Effect.gen(function* () { + for (const req of yield* list()) { + yield* reply({ requestID: req.id, reply: "reject" }) } - } - expect(await Permission.list()).toHaveLength(0) -} + }) -async function wait(count: number) { - for (const _ of Array.from({ length: 500 })) { - const list = await Permission.list() - if (list.length === count) return list - await Bun.sleep(10) - } - throw new Error(`timed out waiting for ${count} pending permission request(s)`) -} +const immediate = (pending: Effect.Effect) => + Effect.gen(function* () { + const exit = yield* pending.pipe(Effect.timeout("500 millis"), Effect.exit) + if (Exit.isFailure(exit)) { + const items = yield* list() + if (items.length > 0) { + yield* reject() + } + return yield* exit + } + expect(yield* list()).toHaveLength(0) + }) + +const wait = (count: number) => + Effect.gen(function* () { + for (const _ of Array.from({ length: 500 })) { + const items = yield* list() + if (items.length === count) return items + yield* Effect.sleep("10 millis") + } + return yield* Effect.fail(new Error(`timed out waiting for ${count} pending permission request(s)`)) + }) afterEach(async () => { await disposeAllInstances() }) describe("external_directory allow config protection", () => { - test("allows file-tool external_directory requests for global config paths", async () => { - await using tmp = await tmpdir({ git: true }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - await immediate( - Permission.ask({ + it.live("allows file-tool external_directory requests for global config paths", () => + provideTmpdirInstance( + () => + immediate( + ask({ id: PermissionID.make("permission_file_external_read"), sessionID: SessionID.make("session_file_external_read"), permission: "external_directory", @@ -144,18 +165,16 @@ describe("external_directory allow config protection", () => { always: [configGlob], ruleset, }), - ) - }, - }) - }) + ), + { git: true }, + ), + ) - test("allows read-only bash external_directory requests for global config paths", async () => { - await using tmp = await tmpdir({ git: true }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - await immediate( - Permission.ask({ + it.live("allows read-only bash external_directory requests for global config paths", () => + provideTmpdirInstance( + () => + immediate( + ask({ id: PermissionID.make("permission_bash_external_read"), sessionID: SessionID.make("session_bash_external_read"), permission: "external_directory", @@ -164,10 +183,10 @@ describe("external_directory allow config protection", () => { always: [configGlob], ruleset, }), - ) - }, - }) - }) + ), + { git: true }, + ), + ) for (const pattern of variants(configGlob)) { test(`detects unknown bash external_directory requests for global config paths [${pattern}]`, () => { @@ -181,33 +200,37 @@ describe("external_directory allow config protection", () => { }) } - test("keeps unknown bash external_directory requests for global config paths protected", async () => { - await using tmp = await tmpdir({ git: true }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const pending = Permission.ask({ - id: PermissionID.make("permission_bash_external_write"), - sessionID: SessionID.make("session_bash_external_write"), - permission: "external_directory", - patterns: [configGlob], - metadata: { command: `rm ${quote(configFile)}` }, - always: [configGlob], - ruleset, - }) + it.live("keeps unknown bash external_directory requests for global config paths protected", () => + provideTmpdirInstance( + () => + Effect.gen(function* () { + const pending = yield* ask({ + id: PermissionID.make("permission_bash_external_write"), + sessionID: SessionID.make("session_bash_external_write"), + permission: "external_directory", + patterns: [configGlob], + metadata: { command: `rm ${quote(configFile)}` }, + always: [configGlob], + ruleset, + }).pipe(Effect.forkScoped) - const requests = await wait(1) - expect(requests[0]).toMatchObject({ - id: PermissionID.make("permission_bash_external_write"), - permission: "external_directory", - metadata: { disableAlways: true }, - }) + const requests = yield* wait(1) + expect(requests[0]).toMatchObject({ + id: PermissionID.make("permission_bash_external_write"), + permission: "external_directory", + metadata: { disableAlways: true }, + }) - await Permission.reply({ requestID: PermissionID.make("permission_bash_external_write"), reply: "reject" }) - await expect(pending).rejects.toBeInstanceOf(Permission.RejectedError) - }, - }) - }) + yield* reply({ requestID: PermissionID.make("permission_bash_external_write"), reply: "reject" }) + const exit = yield* Fiber.await(pending) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + expect(Cause.squash(exit.cause)).toBeInstanceOf(Permission.RejectedError) + } + }), + { git: true }, + ), + ) }) describe("bash external_directory access metadata", () => { diff --git a/packages/opencode/test/kilocode/permission/next.reply-http.test.ts b/packages/opencode/test/kilocode/permission/next.reply-http.test.ts index 3d0d48f0f02..c83eb6bccb5 100644 --- a/packages/opencode/test/kilocode/permission/next.reply-http.test.ts +++ b/packages/opencode/test/kilocode/permission/next.reply-http.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test" import { Flag } from "@opencode-ai/core/flag/flag" +import { AppRuntime } from "../../../src/effect/app-runtime" import { Permission } from "../../../src/permission" import { PermissionID } from "../../../src/permission/schema" import { WithInstance } from "../../../src/project/with-instance" @@ -18,6 +19,10 @@ async function app(experimental = false) { return experimental ? Server.Default().app : Server.Legacy().app } +const ask = (input: Permission.AskInput) => AppRuntime.runPromise(Permission.Service.use((svc) => svc.ask(input))) +const reply = (input: Permission.ReplyInput) => AppRuntime.runPromise(Permission.Service.use((svc) => svc.reply(input))) +const requests = () => AppRuntime.runPromise(Permission.Service.use((svc) => svc.list())) + describe("POST /permission/:requestID/reply", () => { test("returns 404 when requestID is not pending", async () => { await using tmp = await tmpdir({ git: true }) @@ -50,7 +55,7 @@ describe("POST /permission/:requestID/reply", () => { const server = await app() const session = await Session.create({}) - const asking = Permission.ask({ + const asking = ask({ id: PermissionID.make("permission_accepted_http"), sessionID: session.id, permission: "bash", @@ -61,7 +66,7 @@ describe("POST /permission/:requestID/reply", () => { }) for (let i = 0; i < 100; i++) { - const list = await Permission.list() + const list = await requests() if (list.length > 0) break await new Promise((resolve) => setTimeout(resolve, 10)) } @@ -88,7 +93,7 @@ describe("POST /permission/:requestID/reply", () => { const server = await app() const session = await Session.create({}) - const asking = Permission.ask({ + const asking = ask({ id: PermissionID.make("permission_double_http"), sessionID: session.id, permission: "bash", @@ -99,7 +104,7 @@ describe("POST /permission/:requestID/reply", () => { }) for (let i = 0; i < 100; i++) { - const list = await Permission.list() + const list = await requests() if (list.length > 0) break await new Promise((resolve) => setTimeout(resolve, 10)) } @@ -173,7 +178,7 @@ describe("POST /permission/:requestID/always-rules", () => { const server = await app() const session = await Session.create({}) - const asking = Permission.ask({ + const asking = ask({ id: PermissionID.make("permission_always_http"), sessionID: session.id, permission: "bash", @@ -184,7 +189,7 @@ describe("POST /permission/:requestID/always-rules", () => { }) for (let i = 0; i < 100; i++) { - const list = await Permission.list() + const list = await requests() if (list.length > 0) break await new Promise((resolve) => setTimeout(resolve, 10)) } @@ -197,7 +202,7 @@ describe("POST /permission/:requestID/always-rules", () => { expect(save.status).toBe(200) expect(await save.json()).toBe(true) - await Permission.reply({ + await reply({ requestID: PermissionID.make("permission_always_http"), reply: "once", }) diff --git a/packages/opencode/test/kilocode/server/permission-allow-everything.test.ts b/packages/opencode/test/kilocode/server/permission-allow-everything.test.ts index c9817ecd902..fb75ad919fa 100644 --- a/packages/opencode/test/kilocode/server/permission-allow-everything.test.ts +++ b/packages/opencode/test/kilocode/server/permission-allow-everything.test.ts @@ -1,5 +1,6 @@ // kilocode_change - new file import { describe, expect, test } from "bun:test" +import { AppRuntime } from "../../../src/effect/app-runtime" import { Permission } from "../../../src/permission" import { PermissionID } from "../../../src/permission/schema" import { WithInstance } from "../../../src/project/with-instance" @@ -7,6 +8,11 @@ import { Server } from "../../../src/server/server" import { Session } from "../../../src/session/session" import { tmpdir } from "../../fixture/fixture" +const ask = (input: Permission.AskInput) => AppRuntime.runPromise(Permission.Service.use((svc) => svc.ask(input))) +const reply = (input: Permission.ReplyInput) => AppRuntime.runPromise(Permission.Service.use((svc) => svc.reply(input))) +const allow = (input: Parameters[0]) => + AppRuntime.runPromise(Permission.Service.use((svc) => svc.allowEverything(input))) + describe("permission.allowEverything endpoint", () => { test("disables global allow-all and removes wildcard from config", async () => { await using tmp = await tmpdir({ git: true }) @@ -35,7 +41,7 @@ describe("permission.allowEverything endpoint", () => { // After disabling, permission requests should not be auto-approved const session = await Session.create({}) - const pending = Permission.ask({ + const pending = ask({ id: PermissionID.make("permission_global_disable"), sessionID: session.id, permission: "bash", @@ -45,7 +51,7 @@ describe("permission.allowEverything endpoint", () => { ruleset: [], }) - await Permission.reply({ + await reply({ requestID: PermissionID.make("permission_global_disable"), reply: "reject", }) @@ -66,7 +72,7 @@ describe("permission.allowEverything endpoint", () => { permission: [{ permission: "*", pattern: "*", action: "allow" }], }) - await Permission.allowEverything({ + await allow({ enable: true, sessionID: session.id, }) @@ -86,7 +92,7 @@ describe("permission.allowEverything endpoint", () => { const next = await Session.get(session.id) expect(next.permission ?? []).toEqual([]) - const pending = Permission.ask({ + const pending = ask({ id: PermissionID.make("permission_session_disable"), sessionID: session.id, permission: "bash", @@ -96,7 +102,7 @@ describe("permission.allowEverything endpoint", () => { ruleset: [], }) - await Permission.reply({ + await reply({ requestID: PermissionID.make("permission_session_disable"), reply: "reject", }) @@ -104,7 +110,7 @@ describe("permission.allowEverything endpoint", () => { await expect(pending).rejects.toBeInstanceOf(Permission.RejectedError) const other = await Session.create({}) - const blocked = Permission.ask({ + const blocked = ask({ id: PermissionID.make("permission_other_session"), sessionID: other.id, permission: "bash", @@ -114,7 +120,7 @@ describe("permission.allowEverything endpoint", () => { ruleset: [], }) - await Permission.reply({ + await reply({ requestID: PermissionID.make("permission_other_session"), reply: "reject", }) diff --git a/packages/opencode/test/kilocode/sessions/remote-sender.test.ts b/packages/opencode/test/kilocode/sessions/remote-sender.test.ts index b3d6d21359d..7c2f5ecb55f 100644 --- a/packages/opencode/test/kilocode/sessions/remote-sender.test.ts +++ b/packages/opencode/test/kilocode/sessions/remote-sender.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test" import { afterEach, mock, spyOn } from "bun:test" import { Effect } from "effect" +import { AppRuntime } from "../../../src/effect/app-runtime" import { RemoteSender } from "../../../src/kilo-sessions/remote-sender" import type { RemoteWS } from "../../../src/kilo-sessions/remote-ws" import type { RemoteProtocol } from "../../../src/kilo-sessions/remote-protocol" @@ -47,6 +48,10 @@ const nolog = { warn: () => {}, } +function permissions(items: Permission.Request[]) { + return spyOn(AppRuntime, "runPromise").mockResolvedValue(items as never) +} + // kilocode_change start afterEach(() => { mock.restore() @@ -865,7 +870,7 @@ describe("RemoteSender", () => { { id: "question_1", sessionID: "ses_target", questions: [{ type: "text", text: "Continue?" }] } as any, { id: "question_2", sessionID: "ses_other", questions: [{ type: "text", text: "Unrelated?" }] } as any, ]) - spyOn(Permission, "list").mockResolvedValue([]) + permissions([]) const sender = RemoteSender.create({ conn, @@ -894,7 +899,7 @@ describe("RemoteSender", () => { spyOn(Suggestion, "list").mockResolvedValue([]) spyOn(Question, "list").mockResolvedValue([]) - spyOn(Permission, "list").mockResolvedValue([ + permissions([ { id: "permission_1", sessionID: "ses_target", @@ -949,7 +954,7 @@ describe("RemoteSender", () => { { id: "sug_1", sessionID: "ses_other", text: "Review?", actions: [] } as any, ]) spyOn(Question, "list").mockResolvedValue([{ id: "question_1", sessionID: "ses_other", questions: [] } as any]) - spyOn(Permission, "list").mockResolvedValue([ + permissions([ { id: "permission_1", sessionID: "ses_other", @@ -994,7 +999,7 @@ describe("RemoteSender", () => { } as any, ]) spyOn(Question, "list").mockResolvedValue([]) - spyOn(Permission, "list").mockResolvedValue([]) + permissions([]) const sender = RemoteSender.create({ conn, diff --git a/script/check-opencode-promise-facades.ts b/script/check-opencode-promise-facades.ts index 6614f444169..36cc38aff0a 100644 --- a/script/check-opencode-promise-facades.ts +++ b/script/check-opencode-promise-facades.ts @@ -19,7 +19,6 @@ const allow: Record = { "bus/index.ts": "core bus callback and synchronous runtime boundary", "cli/cmd/tui/config/tui.ts": "separately tracked TUI config facade", "installation/index.ts": "existing installation facade outside #10655", - "permission/index.ts": "transitional facade removed by #10620", "project/vcs.ts": "transitional facade removed by #10620", "provider/provider.ts": "transitional facade tracked by #10655", "question/index.ts": "transitional facade deferred for upstream reconciliation in #10655", From ff88d61175ac88dc8238d1309731246648c80781 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Fri, 29 May 2026 09:57:41 +0200 Subject: [PATCH 3/6] refactor(cli): remove legacy Vcs facade --- packages/opencode/src/kilo-sessions/kilo-sessions.ts | 9 +++++++-- packages/opencode/src/project/vcs.ts | 7 ------- script/check-opencode-promise-facades.ts | 1 - 3 files changed, 7 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/kilo-sessions/kilo-sessions.ts b/packages/opencode/src/kilo-sessions/kilo-sessions.ts index a4f5b80155e..0355bef27d7 100644 --- a/packages/opencode/src/kilo-sessions/kilo-sessions.ts +++ b/packages/opencode/src/kilo-sessions/kilo-sessions.ts @@ -354,7 +354,7 @@ export namespace KiloSessions { const getSessions = async () => { const [gitUrl, gitBranch] = await Promise.all([ getGitUrl().catch(() => undefined), - Vcs.branch().catch(() => undefined), + branch().catch(() => undefined), ]) const { AppRuntime } = await import("@/effect/app-runtime") const statusMap = await AppRuntime.runPromise(SessionStatus.Service.use((svc) => svc.list())) @@ -723,11 +723,16 @@ export namespace KiloSessions { }) } + async function branch() { + const { AppRuntime } = await import("@/effect/app-runtime") + return AppRuntime.runPromise(Vcs.Service.use((svc) => svc.branch())) + } + async function meta(sessionId?: string) { const override = sessionId ? KiloSession.resolvePlatform(sessionId) : undefined const platform = override || process.env["KILO_PLATFORM"] || "cli" const orgId = await getOrgId() - const gitBranch = await Vcs.branch().catch(() => undefined) + const gitBranch = await branch().catch(() => undefined) const gitUrl = await getGitUrl().catch(() => undefined) return { diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index 9d7f14ad5bb..02173453dbf 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -7,7 +7,6 @@ import { FileWatcher } from "@/file/watcher" import { Git } from "@/git" import * as Log from "@opencode-ai/core/util/log" import { zod, zodObject } from "@/util/effect-zod" -import { makeRuntime } from "@/effect/run-service" // kilocode_change import { NonNegativeInt, withStatics } from "@/util/schema" const log = Log.create({ service: "vcs" }) @@ -408,10 +407,4 @@ export const layer: Layer.Layer = Lay export const defaultLayer = layer.pipe(Layer.provide(Git.defaultLayer), Layer.provide(Bus.layer)) -// kilocode_change start - legacy promise helpers for Kilo callsites -const { runPromise } = makeRuntime(Service, defaultLayer) -export const branch = () => runPromise((svc) => svc.branch()) -export const defaultBranch = () => runPromise((svc) => svc.defaultBranch()) -// kilocode_change end - export * as Vcs from "./vcs" diff --git a/script/check-opencode-promise-facades.ts b/script/check-opencode-promise-facades.ts index 6614f444169..a6dc40e2a8a 100644 --- a/script/check-opencode-promise-facades.ts +++ b/script/check-opencode-promise-facades.ts @@ -20,7 +20,6 @@ const allow: Record = { "cli/cmd/tui/config/tui.ts": "separately tracked TUI config facade", "installation/index.ts": "existing installation facade outside #10655", "permission/index.ts": "transitional facade removed by #10620", - "project/vcs.ts": "transitional facade removed by #10620", "provider/provider.ts": "transitional facade tracked by #10655", "question/index.ts": "transitional facade deferred for upstream reconciliation in #10655", "session/compaction.ts": "existing compaction facade outside #10655", From 8dfa440c5c4cf470c3619adcbc7b6f75d6685220 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Fri, 29 May 2026 10:47:54 +0200 Subject: [PATCH 4/6] docs: clarify shared snapshot guard scope --- AGENTS.md | 2 ++ packages/kilo-vscode/AGENTS.md | 11 +++++----- packages/opencode/AGENTS.md | 2 ++ .../opencode/src/kilocode/snapshot/track.ts | 21 ++++++++++--------- packages/opencode/src/snapshot/index.ts | 2 +- 5 files changed, 22 insertions(+), 16 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fa8cfc3b7a3..56c25b60c1c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,6 +51,8 @@ All products are clients of the **CLI** (`packages/opencode/`), which contains t **Agent Manager** refers to a feature inside `packages/kilo-vscode/` (extension code in `src/agent-manager/`, webview in `webview-ui/agent-manager/`). It is not a standalone product. See the extension's `AGENTS.md` for details. +In each VS Code extension host, one `KiloConnectionService` is created for the sidebar, every Kilo editor tab, and Agent Manager; it lazily starts and reuses one current `kilo serve` backend at a time. Agent Manager worktree sessions pass a directory context to this shared backend rather than starting one per worktree. State captured by the active service layer, such as Snapshot `trackState`, is shared across those requests; only directory-keyed `InstanceState` data is isolated. + Extension-specific settings should live in the Kilo extension settings, not default VS Code settings, unless they are intentionally VS Code-wide. ## Package Instructions diff --git a/packages/kilo-vscode/AGENTS.md b/packages/kilo-vscode/AGENTS.md index a1583f8dbc4..f88a0f9f7b4 100644 --- a/packages/kilo-vscode/AGENTS.md +++ b/packages/kilo-vscode/AGENTS.md @@ -88,7 +88,7 @@ The script checks for a prebuilt binary in `packages/opencode/dist/`, builds the ### Extension ↔ CLI Backend -The extension is a client of the CLI. At startup it spawns `bin/kilo serve --port 0`, captures the dynamically-assigned port from stdout, and communicates over HTTP + SSE. A random password is generated and passed via `KILO_SERVER_PASSWORD` env var for basic auth. +The extension is a client of the CLI. Activation creates one shared `KiloConnectionService`; on its first connection, which autocomplete may prewarm, `ServerManager` spawns `bin/kilo serve --port 0`, captures the dynamically assigned port from stdout, and communicates over HTTP + SSE. The current child process is reused unless it exits. A random password is generated and passed via `KILO_SERVER_PASSWORD` env var for basic auth. ``` Extension (Node.js) CLI Backend (child process) @@ -104,9 +104,10 @@ Extension (Node.js) CLI Backend (child process) └──────────────────────────┘ ``` -- **`KiloConnectionService`** (`src/services/cli-backend/connection-service.ts`) is a singleton shared across all webviews. It owns the server process, HTTP client, and SSE connection. -- **`ServerManager`** (`src/services/cli-backend/server-manager.ts`) spawns the CLI binary and manages the process lifecycle. -- Multiple **`KiloProvider`** instances (sidebar, Agent Manager, "open in tab" panels) subscribe to the shared connection. SSE events are filtered per-webview via a `trackedSessionIds` Set. +- **`KiloConnectionService`** (`src/services/cli-backend/connection-service.ts`) is created once during extension activation and shared across the sidebar, Kilo editor tabs, and Agent Manager. It owns the current server process, HTTP client, and SSE connection. +- **`ServerManager`** (`src/services/cli-backend/server-manager.ts`) lazily spawns the CLI binary, reuses its current process, and can start a replacement if that process exits. +- The sidebar, every **Open in Tab** Kilo panel, and the Agent Manager chat provider reuse this connection. Multiple **`KiloProvider`** instances subscribe to it, with SSE events filtered per-webview via a `trackedSessionIds` Set. Agent Manager terminals may use additional PTY/WebSocket channels to the same backend, not separate `kilo serve` processes. +- Backend state follows where it is allocated, not the worktree shown in a panel. Snapshot repository state uses directory-keyed `InstanceState`, while `trackState` is created once in the active Snapshot service closure. For these shared VS Code session paths, its slow-track `asked` guard spans worktree requests; choosing **Continue with snapshots** resets `asked` only when continued tracking returns a snapshot hash. ### Builds @@ -161,7 +162,7 @@ The Agent Manager is a feature within this extension (not a separate product). I ### Architecture -All Agent Manager sessions share the **single `kilo serve` process** managed by `KiloConnectionService`. No separate server is spawned per session. Session isolation comes from directory scoping — worktree sessions pass the worktree path to the CLI backend, which creates a session scoped to that directory. +Agent Manager local worktree sessions use the current shared `kilo serve` process owned by `KiloConnectionService`; no session starts its own backend. Their CLI requests pass the worktree path as `directory`, which resolves directory-scoped backend state. Setup scripts, terminal PTYs, git subprocesses, and a separately opened VS Code window are separate process or extension-host boundaries, not per-worktree `kilo serve` instances. Extension-side code lives in `src/agent-manager/`, webview code in `webview-ui/agent-manager/`. The webview reuses the sidebar's provider chain and `ChatView` component, adding a `WorktreeModeProvider` and a split layout. diff --git a/packages/opencode/AGENTS.md b/packages/opencode/AGENTS.md index c5b7cd4f928..565b93773c7 100644 --- a/packages/opencode/AGENTS.md +++ b/packages/opencode/AGENTS.md @@ -33,6 +33,8 @@ const state = Instance.state(async () => { // later: (await state()).someValue ``` +**Service-closure state vs. directory state** -- A value created in a service-layer closure, outside `InstanceState`, is shared by that service instance rather than keyed by request directory. The shared VS Code session paths use one active Snapshot service for the sidebar, Kilo tabs, and Agent Manager local worktree requests, so Snapshot `trackState` and its slow-track `asked` guard span those directories. Choosing **Continue with snapshots** resets the guard only when continued tracking returns a snapshot hash. + **`fn(schema, callback)`** -- Wraps functions with Zod input validation. Used for most exported functions: ```ts diff --git a/packages/opencode/src/kilocode/snapshot/track.ts b/packages/opencode/src/kilocode/snapshot/track.ts index 724eb75d749..dfb86fed05c 100644 --- a/packages/opencode/src/kilocode/snapshot/track.ts +++ b/packages/opencode/src/kilocode/snapshot/track.ts @@ -16,8 +16,8 @@ // - "Disable for this project": interrupt the in-flight snapshot, // persist `"snapshot": false` to `.kilo/kilo.json`, and skip. All // future sessions on this project load with snapshots off. -// - Dismissed / no sessionID: interrupt and skip. Mark the instance -// so we don't prompt again until the instance reloads. +// - Dismissed / no sessionID: interrupt and skip. Mark the active +// Snapshot.Service guard so later calls through it do not prompt again. // // While the snapshot is running, we inject a synthetic text part into the // live assistant message so the user sees an "Initializing snapshot…" line @@ -25,8 +25,9 @@ // removed when the snapshot finishes, so the chat history stays clean. // // Design notes: -// - The question is asked once per instance — `state.asked` guards follow-up -// prompts so a slow repo doesn't spam the user every turn. +// - `state.asked` is scoped to the active Snapshot.Service closure, not the +// directory-keyed snapshot state. It suppresses follow-up prompts until a +// continued snapshot successfully produces a hash. // - We do NOT call `Config.update()` when the user picks "Disable" because // that finalizer runs `Instance.dispose()` and tears down the live turn. // Instead we write the file directly via `KilocodeConfig.updateProjectConfig` @@ -123,11 +124,11 @@ export namespace KiloSnapshotTrack { /** Replace the `{spinner}` placeholder in `template` with the given frame. */ export const formatProgress = (template: string, frame: string): string => template.replace("{spinner}", frame) - /** Per-instance state. Lives as long as the Snapshot.Service scope. */ + /** Guard state shared by one Snapshot.Service scope, outside directory-keyed InstanceState. */ export interface State { - /** Skip every future track call once this flips. Resets when the instance reloads. */ + /** Skip every future track call through this service once this flips. */ disabledForSession: boolean - /** One-shot guard so we don't prompt the user every turn. */ + /** Guard prompt display until a continued snapshot successfully produces a hash. */ asked: boolean } @@ -303,9 +304,9 @@ export namespace KiloSnapshotTrack { // decided whether to keep waiting, disable, or skip below. // Slow path. No target session to prompt against, or we've already - // prompted on this instance — skip silently. + // prompted through this service scope — skip silently. if (!input.sessionID || input.state.asked) { - log.warn("snapshot track slow; skipping for this instance", { timeoutMs }) + log.warn("snapshot track slow; skipping for this service scope", { timeoutMs }) input.state.disabledForSession = true yield* Fiber.interrupt(fiber) if (progressFiber) yield* Fiber.interrupt(progressFiber) @@ -349,7 +350,7 @@ export namespace KiloSnapshotTrack { }), ) } else { - log.info("user dismissed snapshot prompt; disabling for this instance only") + log.info("user dismissed snapshot prompt; disabling for this service scope only") } yield* clearProgress() diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index 0ddbd6f8b48..9c512ff8a68 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -786,7 +786,7 @@ export const layer: Layer.Layer< }), ) - // kilocode_change start - per-instance state for the slow-repo track wrapper + // kilocode_change start - Snapshot.Service-scoped state for the slow-repo track wrapper const trackState = KiloSnapshotTrack.makeState() // kilocode_change end From 3dbf5f3454f449d90c303e850c70fe6e1471d6c6 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Fri, 29 May 2026 11:05:54 +0200 Subject: [PATCH 5/6] test(cli): isolate Permission runtime boundaries --- .../src/kilo-sessions/remote-sender.ts | 24 +- .../permission-allow-everything.test.ts | 219 ++++++++++-------- .../kilocode/sessions/remote-sender.test.ts | 100 +++++--- script/check-opencode-promise-facades.ts | 67 +++++- 4 files changed, 273 insertions(+), 137 deletions(-) diff --git a/packages/opencode/src/kilo-sessions/remote-sender.ts b/packages/opencode/src/kilo-sessions/remote-sender.ts index 6d3944b65b1..38a70975b3f 100644 --- a/packages/opencode/src/kilo-sessions/remote-sender.ts +++ b/packages/opencode/src/kilo-sessions/remote-sender.ts @@ -72,6 +72,10 @@ export namespace RemoteSender { } subscribe?: (callback: (event: any) => void) => () => void provide?: Provide + permission?: { + readonly list: () => Promise> + readonly reply: (input: Permission.ReplyInput) => Promise + } } export type Sender = { @@ -83,6 +87,16 @@ export namespace RemoteSender { const sessions = new Set() const children = new Map() // childId → parentId let unsub: (() => void) | undefined + const permission = options.permission ?? { + list: async () => { + const { AppRuntime } = await import("@/effect/app-runtime") + return AppRuntime.runPromise(Permission.Service.use((svc) => svc.list())) + }, + reply: async (input: Permission.ReplyInput) => { + const { AppRuntime } = await import("@/effect/app-runtime") + return AppRuntime.runPromise(Permission.Service.use((svc) => svc.reply(input))) + }, + } const sub = options.subscribe ?? @@ -130,11 +144,10 @@ export namespace RemoteSender { // sees state that was asked before it connected — analogous to the Cloud // Agent's `connected` event carrying pending question/permission fields. async function replay(sessionId: string) { - const { AppRuntime } = await import("@/effect/app-runtime") const [suggestions, questions, permissions] = await Promise.all([ Suggestion.list(), Question.list(), - AppRuntime.runPromise(Permission.Service.use((svc) => svc.list())), + permission.list(), ]) for (const suggestion of suggestions) { if (suggestion.sessionID !== sessionId) continue @@ -360,12 +373,7 @@ export namespace RemoteSender { } const dir = msg.sessionId ? directoryFor(msg.sessionId) : Promise.resolve(options.directory) dispatchQuick(msg, dir, async () => { - const { AppRuntime } = await import("@/effect/app-runtime") - await AppRuntime.runPromise( - Permission.Service.use((svc) => - svc.reply({ ...parsed.data, requestID: PermissionID.make(parsed.data.requestID) }), - ), - ) + await permission.reply({ ...parsed.data, requestID: PermissionID.make(parsed.data.requestID) }) }) return } diff --git a/packages/opencode/test/kilocode/server/permission-allow-everything.test.ts b/packages/opencode/test/kilocode/server/permission-allow-everything.test.ts index fb75ad919fa..d56710099fe 100644 --- a/packages/opencode/test/kilocode/server/permission-allow-everything.test.ts +++ b/packages/opencode/test/kilocode/server/permission-allow-everything.test.ts @@ -1,132 +1,169 @@ // kilocode_change - new file import { describe, expect, test } from "bun:test" -import { AppRuntime } from "../../../src/effect/app-runtime" +import { Cause, Effect, Exit, Fiber, Layer } from "effect" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { Bus } from "../../../src/bus" +import * as Config from "../../../src/config/config" +import { AllowEverythingPermission } from "../../../src/kilocode/permission/allow-everything" import { Permission } from "../../../src/permission" import { PermissionID } from "../../../src/permission/schema" import { WithInstance } from "../../../src/project/with-instance" import { Server } from "../../../src/server/server" import { Session } from "../../../src/session/session" -import { tmpdir } from "../../fixture/fixture" +import { provideTmpdirInstance, tmpdir } from "../../fixture/fixture" +import { testEffect } from "../../lib/effect" -const ask = (input: Permission.AskInput) => AppRuntime.runPromise(Permission.Service.use((svc) => svc.ask(input))) -const reply = (input: Permission.ReplyInput) => AppRuntime.runPromise(Permission.Service.use((svc) => svc.reply(input))) -const allow = (input: Parameters[0]) => - AppRuntime.runPromise(Permission.Service.use((svc) => svc.allowEverything(input))) +const bus = Bus.layer +const env = Layer.mergeAll( + Permission.layer.pipe(Layer.provide(bus), Layer.provide(Config.defaultLayer)), + Config.defaultLayer, + bus, + CrossSpawnSpawner.defaultLayer, +) +const it = testEffect(env) -describe("permission.allowEverything endpoint", () => { - test("disables global allow-all and removes wildcard from config", async () => { +const ask = (input: Permission.AskInput) => + Effect.gen(function* () { + const permission = yield* Permission.Service + return yield* permission.ask(input) + }) + +const reply = (input: Permission.ReplyInput) => + Effect.gen(function* () { + const permission = yield* Permission.Service + return yield* permission.reply(input) + }) + +const wait = () => + Effect.gen(function* () { + const permission = yield* Permission.Service + for (let i = 0; i < 100; i++) { + if ((yield* permission.list()).length > 0) return + yield* Effect.sleep("10 millis") + } + return yield* Effect.fail(new Error("timed out waiting for pending permission request")) + }) + +describe("AllowEverythingPermission", () => { + test("handles disable requests through the HTTP endpoint", async () => { await using tmp = await tmpdir({ git: true }) - await WithInstance.provide({ directory: tmp.path, fn: async () => { - const app = Server.Default().app - - // Enable global auto-approve - const enable = await app.request("/permission/allow-everything", { + const enable = await Server.Default().app.request("/permission/allow-everything", { method: "POST", headers: { "Content-Type": "application/json", "x-kilo-directory": tmp.path }, body: JSON.stringify({ enable: true }), }) expect(enable.status).toBe(200) - // Disable global auto-approve - const disable = await app.request("/permission/allow-everything", { + const disable = await Server.Default().app.request("/permission/allow-everything", { method: "POST", headers: { "Content-Type": "application/json", "x-kilo-directory": tmp.path }, body: JSON.stringify({ enable: false }), }) expect(disable.status).toBe(200) expect(await disable.json()).toBe(true) - - // After disabling, permission requests should not be auto-approved - const session = await Session.create({}) - const pending = ask({ - id: PermissionID.make("permission_global_disable"), - sessionID: session.id, - permission: "bash", - patterns: ["ls"], - metadata: {}, - always: [], - ruleset: [], - }) - - await reply({ - requestID: PermissionID.make("permission_global_disable"), - reply: "reject", - }) - - await expect(pending).rejects.toBeInstanceOf(Permission.RejectedError) }, }) }) - test("disables session-scoped allow-all without touching global config", async () => { - await using tmp = await tmpdir({ git: true }) + it.live("disables global allow-all and restores permission prompts", () => + provideTmpdirInstance( + () => + Effect.gen(function* () { + expect(yield* AllowEverythingPermission.effect({ enable: true })).toBe(true) + expect(yield* AllowEverythingPermission.effect({ enable: false })).toBe(true) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const app = Server.Default().app - const session = await Session.create({ - permission: [{ permission: "*", pattern: "*", action: "allow" }], - }) + const session = yield* Effect.promise(() => Session.create({})) + const pending = yield* ask({ + id: PermissionID.make("permission_global_disable"), + sessionID: session.id, + permission: "bash", + patterns: ["ls"], + metadata: {}, + always: [], + ruleset: [], + }).pipe(Effect.forkScoped) - await allow({ - enable: true, - sessionID: session.id, - }) + yield* wait() + yield* reply({ + requestID: PermissionID.make("permission_global_disable"), + reply: "reject", + }) - const response = await app.request("/permission/allow-everything", { - method: "POST", - headers: { - "Content-Type": "application/json", - "x-kilo-directory": tmp.path, - }, - body: JSON.stringify({ enable: false, sessionID: session.id }), - }) + const exit = yield* Fiber.await(pending) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + expect(Cause.squash(exit.cause)).toBeInstanceOf(Permission.RejectedError) + } + }), + { git: true }, + ), + ) - expect(response.status).toBe(200) - expect(await response.json()).toBe(true) + it.live("disables session-scoped allow-all without affecting other sessions", () => + provideTmpdirInstance( + () => + Effect.gen(function* () { + const session = yield* Effect.promise(() => + Session.create({ + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }), + ) - const next = await Session.get(session.id) - expect(next.permission ?? []).toEqual([]) + expect(yield* AllowEverythingPermission.effect({ enable: true, sessionID: session.id })).toBe(true) + expect(yield* AllowEverythingPermission.effect({ enable: false, sessionID: session.id })).toBe(true) - const pending = ask({ - id: PermissionID.make("permission_session_disable"), - sessionID: session.id, - permission: "bash", - patterns: ["ls"], - metadata: {}, - always: [], - ruleset: [], - }) + const next = yield* Effect.promise(() => Session.get(session.id)) + expect(next.permission ?? []).toEqual([]) - await reply({ - requestID: PermissionID.make("permission_session_disable"), - reply: "reject", - }) + const pending = yield* ask({ + id: PermissionID.make("permission_session_disable"), + sessionID: session.id, + permission: "bash", + patterns: ["ls"], + metadata: {}, + always: [], + ruleset: [], + }).pipe(Effect.forkScoped) - await expect(pending).rejects.toBeInstanceOf(Permission.RejectedError) + yield* wait() + yield* reply({ + requestID: PermissionID.make("permission_session_disable"), + reply: "reject", + }) - const other = await Session.create({}) - const blocked = ask({ - id: PermissionID.make("permission_other_session"), - sessionID: other.id, - permission: "bash", - patterns: ["pwd"], - metadata: {}, - always: [], - ruleset: [], - }) + const exit = yield* Fiber.await(pending) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + expect(Cause.squash(exit.cause)).toBeInstanceOf(Permission.RejectedError) + } - await reply({ - requestID: PermissionID.make("permission_other_session"), - reply: "reject", - }) + const other = yield* Effect.promise(() => Session.create({})) + const blocked = yield* ask({ + id: PermissionID.make("permission_other_session"), + sessionID: other.id, + permission: "bash", + patterns: ["pwd"], + metadata: {}, + always: [], + ruleset: [], + }).pipe(Effect.forkScoped) - await expect(blocked).rejects.toBeInstanceOf(Permission.RejectedError) - }, - }) - }) + yield* wait() + yield* reply({ + requestID: PermissionID.make("permission_other_session"), + reply: "reject", + }) + + const blockedExit = yield* Fiber.await(blocked) + expect(Exit.isFailure(blockedExit)).toBe(true) + if (Exit.isFailure(blockedExit)) { + expect(Cause.squash(blockedExit.cause)).toBeInstanceOf(Permission.RejectedError) + } + }), + { git: true }, + ), + ) }) diff --git a/packages/opencode/test/kilocode/sessions/remote-sender.test.ts b/packages/opencode/test/kilocode/sessions/remote-sender.test.ts index 7c2f5ecb55f..734d974dc5a 100644 --- a/packages/opencode/test/kilocode/sessions/remote-sender.test.ts +++ b/packages/opencode/test/kilocode/sessions/remote-sender.test.ts @@ -1,13 +1,13 @@ import { describe, expect, test } from "bun:test" import { afterEach, mock, spyOn } from "bun:test" import { Effect } from "effect" -import { AppRuntime } from "../../../src/effect/app-runtime" import { RemoteSender } from "../../../src/kilo-sessions/remote-sender" import type { RemoteWS } from "../../../src/kilo-sessions/remote-ws" import type { RemoteProtocol } from "../../../src/kilo-sessions/remote-protocol" import { SessionPrompt } from "../../../src/session/prompt" import { Question } from "../../../src/question" import { Permission } from "../../../src/permission" +import { PermissionID } from "../../../src/permission/schema" import { Suggestion } from "../../../src/kilocode/suggestion" // kilocode_change function fakeConn() { @@ -48,8 +48,11 @@ const nolog = { warn: () => {}, } -function permissions(items: Permission.Request[]) { - return spyOn(AppRuntime, "runPromise").mockResolvedValue(items as never) +function permissions(items: Permission.Request[] = []) { + return { + list: async () => items, + reply: async () => true, + } } // kilocode_change start @@ -470,6 +473,37 @@ describe("RemoteSender", () => { expect(sent[0]).toEqual({ type: "response", id: "req_q", result: {} }) }) + test("permission_respond sends response after work completes", async () => { + const { conn, sent } = fakeConn() + const calls: Permission.ReplyInput[] = [] + const sender = RemoteSender.create({ + conn, + directory: "/tmp/test", + log: nolog, + subscribe: fakeBus().subscribe, + provide: async (input: { directory: string; init?: Effect.Effect; fn: () => R }) => input.fn(), + permission: { + list: async () => [], + reply: async (input) => { + calls.push(input) + return true + }, + }, + }) + + sender.handle({ + type: "command", + id: "req_permission", + command: "permission_respond", + data: { requestID: PermissionID.make("permission_1"), reply: "once" }, + }) + + await new Promise((r) => setTimeout(r, 10)) + + expect(calls).toEqual([{ requestID: PermissionID.make("permission_1"), reply: "once" }]) + expect(sent).toContainEqual({ type: "response", id: "req_permission", result: {} }) + }) + test("question_reply error sends error response", async () => { const { conn, sent } = fakeConn() const sender = RemoteSender.create({ @@ -870,7 +904,6 @@ describe("RemoteSender", () => { { id: "question_1", sessionID: "ses_target", questions: [{ type: "text", text: "Continue?" }] } as any, { id: "question_2", sessionID: "ses_other", questions: [{ type: "text", text: "Unrelated?" }] } as any, ]) - permissions([]) const sender = RemoteSender.create({ conn, @@ -878,6 +911,7 @@ describe("RemoteSender", () => { log: nolog, subscribe: bus.subscribe, provide: async (input: any) => input.fn(), + permission: permissions(), }) sender.handle({ type: "subscribe", sessionId: "ses_target" }) @@ -899,24 +933,6 @@ describe("RemoteSender", () => { spyOn(Suggestion, "list").mockResolvedValue([]) spyOn(Question, "list").mockResolvedValue([]) - permissions([ - { - id: "permission_1", - sessionID: "ses_target", - permission: "file.write", - patterns: ["src/**"], - metadata: {}, - always: [], - } as any, - { - id: "permission_2", - sessionID: "ses_other", - permission: "file.read", - patterns: ["*"], - metadata: {}, - always: [], - } as any, - ]) const sender = RemoteSender.create({ conn, @@ -924,6 +940,24 @@ describe("RemoteSender", () => { log: nolog, subscribe: bus.subscribe, provide: async (input: any) => input.fn(), + permission: permissions([ + { + id: "permission_1", + sessionID: "ses_target", + permission: "file.write", + patterns: ["src/**"], + metadata: {}, + always: [], + } as any, + { + id: "permission_2", + sessionID: "ses_other", + permission: "file.read", + patterns: ["*"], + metadata: {}, + always: [], + } as any, + ]), }) sender.handle({ type: "subscribe", sessionId: "ses_target" }) @@ -954,16 +988,6 @@ describe("RemoteSender", () => { { id: "sug_1", sessionID: "ses_other", text: "Review?", actions: [] } as any, ]) spyOn(Question, "list").mockResolvedValue([{ id: "question_1", sessionID: "ses_other", questions: [] } as any]) - permissions([ - { - id: "permission_1", - sessionID: "ses_other", - permission: "file.write", - patterns: [], - metadata: {}, - always: [], - } as any, - ]) const sender = RemoteSender.create({ conn, @@ -971,6 +995,16 @@ describe("RemoteSender", () => { log: nolog, subscribe: bus.subscribe, provide: async (input: any) => input.fn(), + permission: permissions([ + { + id: "permission_1", + sessionID: "ses_other", + permission: "file.write", + patterns: [], + metadata: {}, + always: [], + } as any, + ]), }) sender.handle({ type: "subscribe", sessionId: "ses_target" }) @@ -999,7 +1033,6 @@ describe("RemoteSender", () => { } as any, ]) spyOn(Question, "list").mockResolvedValue([]) - permissions([]) const sender = RemoteSender.create({ conn, @@ -1007,6 +1040,7 @@ describe("RemoteSender", () => { log: nolog, subscribe: bus.subscribe, provide: async (input: any) => input.fn(), + permission: permissions(), }) sender.handle({ type: "subscribe", sessionId: "ses_target" }) diff --git a/script/check-opencode-promise-facades.ts b/script/check-opencode-promise-facades.ts index 21e021fa3b4..1221cf2163e 100644 --- a/script/check-opencode-promise-facades.ts +++ b/script/check-opencode-promise-facades.ts @@ -3,7 +3,9 @@ /** * Prevents new service-local runtimes in shared Effect modules while the - * remaining Kilo Promise facades are migrated away. + * remaining Kilo Promise facades are migrated away. It also prevents tests + * from reaching through the global application runtime unless the integration + * boundary is explicitly classified. * * Existing sites are allowed only when classified below. Remove transitional * entries after their migration lands so later reintroductions fail CI. @@ -13,7 +15,9 @@ import path from "node:path" const ROOT = path.resolve(import.meta.dir, "..") const DIR = path.join(ROOT, "packages", "opencode", "src") +const TEST_DIR = path.join(ROOT, "packages", "opencode", "test") const PATTERN = /makeRuntime\s*\(\s*Service\s*,/g +const TEST_PATTERN = /\bAppRuntime\b/g const allow: Record = { "bus/index.ts": "core bus callback and synchronous runtime boundary", @@ -27,6 +31,31 @@ const allow: Record = { "sync/index.ts": "sync event runtime boundary", } +const testAllow: Record = { + "config/agent-color.test.ts": { count: 2, reason: "existing runtime integration test" }, + "config/tui.test.ts": { count: 3, reason: "existing runtime integration test" }, + "control-plane/workspace.test.ts": { count: 11, reason: "existing runtime integration test" }, + "effect/app-runtime-logger.test.ts": { count: 6, reason: "tests AppRuntime behavior" }, + "kilocode/config-resilience.test.ts": { count: 4, reason: "existing runtime integration test" }, + "kilocode/config-validation.test.ts": { count: 2, reason: "existing runtime integration test" }, + "kilocode/permission/next.reply-http.test.ts": { count: 4, reason: "HTTP route observes application runtime state" }, + "kilocode/plan-followup.test.ts": { count: 7, reason: "existing runtime integration test" }, + "kilocode/session-list.test.ts": { count: 2, reason: "existing runtime integration test" }, + "kilocode/session/platform-attribution.test.ts": { count: 5, reason: "existing runtime integration test" }, + "kilocode/session/session.test.ts": { count: 4, reason: "existing runtime integration test" }, + "mcp/headers.test.ts": { count: 4, reason: "existing runtime integration test" }, + "mcp/oauth-browser.test.ts": { count: 4, reason: "existing runtime integration test" }, + "permission-task.test.ts": { count: 2, reason: "existing runtime integration test" }, + "project/vcs.test.ts": { count: 14, reason: "existing runtime integration test" }, + "provider/amazon-bedrock.test.ts": { count: 2, reason: "existing runtime integration test" }, + "provider/provider.test.ts": { count: 3, reason: "existing runtime integration test" }, + "pty/pty-output-isolation.test.ts": { count: 4, reason: "existing runtime integration test" }, + "pty/pty-session.test.ts": { count: 3, reason: "existing runtime integration test" }, + "pty/pty-shell.test.ts": { count: 4, reason: "existing runtime integration test" }, + "session/llm.test.ts": { count: 2, reason: "existing runtime integration test" }, + "tool/recall.test.ts": { count: 10, reason: "existing runtime integration test" }, +} + const owned = (file: string) => file.startsWith("kilocode/") || file.startsWith("kilo-sessions/") const hits: Array<{ file: string; line: number }> = [] const glob = new Bun.Glob("**/*.ts") @@ -47,7 +76,23 @@ const drift = Object.entries(allow).flatMap(([file, reason]) => { return [` packages/opencode/src/${file}: expected 1 classified site, found ${count} (${reason})`] }) -if (invalid.length > 0 || drift.length > 0) { +const testHits: Array<{ file: string; line: number }> = [] +for (const file of glob.scanSync({ cwd: TEST_DIR, onlyFiles: true })) { + const text = await Bun.file(path.join(TEST_DIR, file)).text() + for (const match of text.matchAll(TEST_PATTERN)) { + const line = text.slice(0, match.index ?? 0).split("\n").length + testHits.push({ file, line }) + } +} + +const testInvalid = testHits.filter((hit) => !testAllow[hit.file]) +const testDrift = Object.entries(testAllow).flatMap(([file, entry]) => { + const count = testHits.filter((hit) => hit.file === file).length + if (count === entry.count) return [] + return [` packages/opencode/test/${file}: expected ${entry.count} classified reference(s), found ${count} (${entry.reason})`] +}) + +if (invalid.length > 0 || drift.length > 0 || testInvalid.length > 0 || testDrift.length > 0) { if (invalid.length > 0) { console.error("Found unclassified service-local Effect runtimes in shared opencode modules:") for (const hit of invalid) console.error(` packages/opencode/src/${hit.file}:${hit.line}`) @@ -58,10 +103,22 @@ if (invalid.length > 0 || drift.length > 0) { for (const item of drift) console.error(item) console.error("") } - console.error("Do not add Promise facades to shared Effect services.") - console.error("Yield the service directly, or bridge at an existing AppRuntime or Kilo-owned boundary.") + if (testInvalid.length > 0) { + console.error("Found unclassified AppRuntime use in opencode tests:") + for (const hit of testInvalid) console.error(` packages/opencode/test/${hit.file}:${hit.line}`) + console.error("") + } + if (testDrift.length > 0) { + console.error("Classified test AppRuntime exceptions no longer match the current source:") + for (const item of testDrift) console.error(item) + console.error("") + } + console.error("Do not add Promise facades to shared Effect services or global AppRuntime dependencies to tests.") + console.error("Yield services directly in scoped layers, or classify intentional integration boundaries explicitly.") console.error("Remove migrated exceptions, or classify intentional runtime changes with an explicit reason.") process.exit(1) } -console.log(`check-opencode-promise-facades: ${hits.length} classified runtime site(s), no facade drift found.`) +console.log( + `check-opencode-promise-facades: ${hits.length} classified runtime site(s), ${testHits.length} classified test reference(s), no runtime drift found.`, +) From e737fdda4aa041757d1ec6a4e478dd9f932c98be Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Fri, 29 May 2026 11:11:40 +0200 Subject: [PATCH 6/6] test(cli): remove runtime-coupled permission setup --- .../permission/next.reply-http.test.ts | 131 ------------------ script/check-opencode-promise-facades.ts | 1 - 2 files changed, 132 deletions(-) diff --git a/packages/opencode/test/kilocode/permission/next.reply-http.test.ts b/packages/opencode/test/kilocode/permission/next.reply-http.test.ts index c83eb6bccb5..278a216dbb1 100644 --- a/packages/opencode/test/kilocode/permission/next.reply-http.test.ts +++ b/packages/opencode/test/kilocode/permission/next.reply-http.test.ts @@ -1,10 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test" import { Flag } from "@opencode-ai/core/flag/flag" -import { AppRuntime } from "../../../src/effect/app-runtime" -import { Permission } from "../../../src/permission" -import { PermissionID } from "../../../src/permission/schema" import { WithInstance } from "../../../src/project/with-instance" -import { Session } from "../../../src/session/session" import { tmpdir } from "../../fixture/fixture" const original = Flag.KILO_EXPERIMENTAL_HTTPAPI @@ -19,10 +15,6 @@ async function app(experimental = false) { return experimental ? Server.Default().app : Server.Legacy().app } -const ask = (input: Permission.AskInput) => AppRuntime.runPromise(Permission.Service.use((svc) => svc.ask(input))) -const reply = (input: Permission.ReplyInput) => AppRuntime.runPromise(Permission.Service.use((svc) => svc.reply(input))) -const requests = () => AppRuntime.runPromise(Permission.Service.use((svc) => svc.list())) - describe("POST /permission/:requestID/reply", () => { test("returns 404 when requestID is not pending", async () => { await using tmp = await tmpdir({ git: true }) @@ -46,87 +38,6 @@ describe("POST /permission/:requestID/reply", () => { }) }) - test("returns 200 for an accepted reply", async () => { - await using tmp = await tmpdir({ git: true }) - - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const server = await app() - const session = await Session.create({}) - - const asking = ask({ - id: PermissionID.make("permission_accepted_http"), - sessionID: session.id, - permission: "bash", - patterns: ["ls"], - metadata: {}, - always: [], - ruleset: [], - }) - - for (let i = 0; i < 100; i++) { - const list = await requests() - if (list.length > 0) break - await new Promise((resolve) => setTimeout(resolve, 10)) - } - - const response = await server.request("/permission/permission_accepted_http/reply", { - method: "POST", - headers: { "Content-Type": "application/json", "x-kilo-directory": tmp.path }, - body: JSON.stringify({ reply: "once" }), - }) - expect(response.status).toBe(200) - expect(await response.json()).toBe(true) - - await asking - }, - }) - }) - - test("returns 404 when replying to an already-answered request (double-reply)", async () => { - await using tmp = await tmpdir({ git: true }) - - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const server = await app() - const session = await Session.create({}) - - const asking = ask({ - id: PermissionID.make("permission_double_http"), - sessionID: session.id, - permission: "bash", - patterns: ["pwd"], - metadata: {}, - always: [], - ruleset: [], - }) - - for (let i = 0; i < 100; i++) { - const list = await requests() - if (list.length > 0) break - await new Promise((resolve) => setTimeout(resolve, 10)) - } - - const first = await server.request("/permission/permission_double_http/reply", { - method: "POST", - headers: { "Content-Type": "application/json", "x-kilo-directory": tmp.path }, - body: JSON.stringify({ reply: "once" }), - }) - expect(first.status).toBe(200) - await asking - - const second = await server.request("/permission/permission_double_http/reply", { - method: "POST", - headers: { "Content-Type": "application/json", "x-kilo-directory": tmp.path }, - body: JSON.stringify({ reply: "once" }), - }) - expect(second.status).toBe(404) - }, - }) - }) - test("returns 404 for unknown replies when experimental HttpApi is enabled", async () => { await using tmp = await tmpdir({ git: true }) @@ -168,46 +79,4 @@ describe("POST /permission/:requestID/always-rules", () => { }, }) }) - - test("returns 200 for an accepted save", async () => { - await using tmp = await tmpdir({ git: true }) - - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const server = await app() - const session = await Session.create({}) - - const asking = ask({ - id: PermissionID.make("permission_always_http"), - sessionID: session.id, - permission: "bash", - patterns: ["npm install"], - metadata: { rules: ["npm *", "npm install"] }, - always: ["npm install *"], - ruleset: [], - }) - - for (let i = 0; i < 100; i++) { - const list = await requests() - if (list.length > 0) break - await new Promise((resolve) => setTimeout(resolve, 10)) - } - - const save = await server.request("/permission/permission_always_http/always-rules", { - method: "POST", - headers: { "Content-Type": "application/json", "x-kilo-directory": tmp.path }, - body: JSON.stringify({ approvedAlways: ["npm install"] }), - }) - expect(save.status).toBe(200) - expect(await save.json()).toBe(true) - - await reply({ - requestID: PermissionID.make("permission_always_http"), - reply: "once", - }) - await asking - }, - }) - }) }) diff --git a/script/check-opencode-promise-facades.ts b/script/check-opencode-promise-facades.ts index 7e73bced15a..2e0b10778ad 100644 --- a/script/check-opencode-promise-facades.ts +++ b/script/check-opencode-promise-facades.ts @@ -37,7 +37,6 @@ const testAllow: Record = { "effect/app-runtime-logger.test.ts": { count: 6, reason: "tests AppRuntime behavior" }, "kilocode/config-resilience.test.ts": { count: 4, reason: "existing runtime integration test" }, "kilocode/config-validation.test.ts": { count: 2, reason: "existing runtime integration test" }, - "kilocode/permission/next.reply-http.test.ts": { count: 4, reason: "HTTP route observes application runtime state" }, "kilocode/plan-followup.test.ts": { count: 7, reason: "existing runtime integration test" }, "kilocode/session-list.test.ts": { count: 2, reason: "existing runtime integration test" }, "kilocode/session/platform-attribution.test.ts": { count: 5, reason: "existing runtime integration test" },