mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
Merge remote-tracking branch 'origin/main' into valley-hawk
# Conflicts: # packages/opencode/src/snapshot/index.ts
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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" })
|
||||
|
||||
Reference in New Issue
Block a user