Merge remote-tracking branch 'origin/main' into valley-hawk

# Conflicts:
#	packages/opencode/src/snapshot/index.ts
This commit is contained in:
marius-kilocode
2026-05-29 11:49:43 +02:00
15 changed files with 446 additions and 386 deletions
+2
View File
@@ -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
@@ -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)" %}
+6 -5
View File
@@ -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.
+2
View File
@@ -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
@@ -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
@@ -354,7 +356,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 +725,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 {
@@ -72,6 +72,10 @@ export namespace RemoteSender {
}
subscribe?: (callback: (event: any) => void) => () => void
provide?: Provide
permission?: {
readonly list: () => Promise<ReadonlyArray<Permission.Request>>
readonly reply: (input: Permission.ReplyInput) => Promise<boolean>
}
}
export type Sender = {
@@ -83,6 +87,16 @@ export namespace RemoteSender {
const sessions = new Set<string>()
const children = new Map<string, string>() // 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 ??
@@ -133,7 +147,7 @@ export namespace RemoteSender {
const [suggestions, questions, permissions] = await Promise.all([
Suggestion.list(),
Question.list(),
Permission.list(),
permission.list(),
])
for (const suggestion of suggestions) {
if (suggestion.sessionID !== sessionId) continue
@@ -359,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
}
@@ -17,8 +17,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
@@ -26,8 +26,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`
@@ -124,11 +125,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
}
@@ -324,9 +325,9 @@ export namespace KiloSnapshotTrack {
}
// 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)
@@ -370,7 +371,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()
-13
View File
@@ -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<typeof SaveAlwaysRulesInput>) =>
runPromise((svc) => svc.saveAlwaysRules(input))
export const allowEverything = (input: z.infer<typeof AllowEverythingInput>) =>
runPromise((svc) => svc.allowEverything(input))
// kilocode_change end
export * as Permission from "."
-7
View File
@@ -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<Service, never, Git.Service | Bus.Service> = 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"
+13 -11
View File
@@ -59,7 +59,7 @@ const cache = new Map<string, Promise<FileDiff[]>>()
const max = 100
// kilocode_change end
type State = Omit<Interface, "init"> & { readonly trackState: KiloSnapshotTrack.State } // kilocode_change
type State = Omit<Interface, "init">
export interface Interface {
readonly init: () => Effect.Effect<void>
@@ -785,10 +785,14 @@ export const layer: Layer.Layer<
Effect.forkScoped,
)
return { cleanup, track, patch, restore, revert, diff, diffFull, trackState: KiloSnapshotTrack.makeState() } // kilocode_change - scope slow prompt state by worktree
return { cleanup, track, patch, restore, revert, diff, diffFull }
}),
)
// kilocode_change start - Snapshot.Service-scoped state for the slow-repo track wrapper
const trackState = KiloSnapshotTrack.makeState()
// kilocode_change end
return Service.of({
init: Effect.fn("Snapshot.init")(function* () {
yield* InstanceState.get(state)
@@ -798,15 +802,13 @@ export const layer: Layer.Layer<
}),
// kilocode_change start - timeout guard with interactive and managed wait policies
track: Effect.fn("Snapshot.track")(function* (opts) {
return yield* InstanceState.useEffect(state, (s) =>
KiloSnapshotTrack.wrap({
inner: s.track(),
state: s.trackState,
snapshotInitialization: opts?.snapshotInitialization,
sessionID: opts?.sessionID,
messageID: opts?.messageID,
}),
)
return yield* KiloSnapshotTrack.wrap({
inner: InstanceState.useEffect(state, (s) => s.track()),
state: trackState,
snapshotInitialization: opts?.snapshotInitialization,
sessionID: opts?.sessionID,
messageID: opts?.messageID,
})
// kilocode_change end
}),
patch: Effect.fn("Snapshot.patch")(function* (hash: string) {
@@ -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<Omit<Permission.Request, "id" | "sessionID" | "tool">>, stop?: Error) => ({
...ctx,
@@ -90,52 +117,46 @@ const withShell = (item: { shell: string }, fn: () => Promise<void>) => 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<void>) {
try {
await Promise.race([
pending,
new Promise<never>((_, 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<void, Permission.Error, Permission.Service>) =>
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", () => {
@@ -1,9 +1,6 @@
import { afterEach, describe, expect, test } from "bun:test"
import { Flag } from "@opencode-ai/core/flag/flag"
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
@@ -41,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 = Permission.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 Permission.list()
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 = Permission.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 Permission.list()
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 })
@@ -163,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 = Permission.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 Permission.list()
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 Permission.reply({
requestID: PermissionID.make("permission_always_http"),
reply: "once",
})
await asking
},
})
})
})
@@ -1,126 +1,169 @@
// kilocode_change - new file
import { describe, expect, test } from "bun:test"
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"
describe("permission.allowEverything endpoint", () => {
test("disables global allow-all and removes wildcard from config", async () => {
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)
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 = Permission.ask({
id: PermissionID.make("permission_global_disable"),
sessionID: session.id,
permission: "bash",
patterns: ["ls"],
metadata: {},
always: [],
ruleset: [],
})
await Permission.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 Permission.allowEverything({
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 = Permission.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 Permission.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 = Permission.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 Permission.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 },
),
)
})
@@ -7,6 +7,7 @@ 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() {
@@ -47,6 +48,13 @@ const nolog = {
warn: () => {},
}
function permissions(items: Permission.Request[] = []) {
return {
list: async () => items,
reply: async () => true,
}
}
// kilocode_change start
afterEach(() => {
mock.restore()
@@ -465,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 <R>(input: { directory: string; init?: Effect.Effect<void>; 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({
@@ -865,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,
])
spyOn(Permission, "list").mockResolvedValue([])
const sender = RemoteSender.create({
conn,
@@ -873,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" })
@@ -894,24 +933,6 @@ describe("RemoteSender", () => {
spyOn(Suggestion, "list").mockResolvedValue([])
spyOn(Question, "list").mockResolvedValue([])
spyOn(Permission, "list").mockResolvedValue([
{
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,
@@ -919,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" })
@@ -949,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])
spyOn(Permission, "list").mockResolvedValue([
{
id: "permission_1",
sessionID: "ses_other",
permission: "file.write",
patterns: [],
metadata: {},
always: [],
} as any,
])
const sender = RemoteSender.create({
conn,
@@ -966,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" })
@@ -994,7 +1033,6 @@ describe("RemoteSender", () => {
} as any,
])
spyOn(Question, "list").mockResolvedValue([])
spyOn(Permission, "list").mockResolvedValue([])
const sender = RemoteSender.create({
conn,
@@ -1002,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" })
+61 -7
View File
@@ -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,14 +15,14 @@ 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<string, string> = {
"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",
"question/index.ts": "transitional facade deferred for upstream reconciliation in #10655",
"session/compaction.ts": "existing compaction facade outside #10655",
"session/prompt.ts": "transitional facade tracked by #10655",
@@ -28,6 +30,30 @@ const allow: Record<string, string> = {
"sync/index.ts": "sync event runtime boundary",
}
const testAllow: Record<string, { count: number; reason: string }> = {
"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/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")
@@ -48,7 +74,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}`)
@@ -59,10 +101,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.`,
)