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
@@ -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" })