2026-07-17 12:59:41 +03:00
|
|
|
import { describe, expect, mock, test } from "bun:test"
|
|
|
|
|
import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
|
|
|
|
|
import { createVSCodePermissionAutoAcceptRuntime } from "./vscode-permission-auto-accept"
|
|
|
|
|
|
|
|
|
|
const permission = { id: "perm-1", sessionID: "child" } as PermissionRequest
|
|
|
|
|
const session = (id: string, parentID?: string) => ({ id, parentID }) as Session
|
|
|
|
|
|
|
|
|
|
describe("VS Code permission auto-accept runtime", () => {
|
|
|
|
|
test("loads missing child lineage and inherits the nearest enabled policy", async () => {
|
|
|
|
|
let replyCalls = 0
|
|
|
|
|
let getSessionCalls = 0
|
|
|
|
|
const reply = mock(async () => { replyCalls += 1 })
|
|
|
|
|
const getSession = mock(async (id: string) => {
|
|
|
|
|
getSessionCalls += 1
|
|
|
|
|
return session(id, id === "child" ? "root" : undefined)
|
|
|
|
|
})
|
|
|
|
|
const runtime = createVSCodePermissionAutoAcceptRuntime({
|
|
|
|
|
getPolicy: () => ({ root: true }),
|
|
|
|
|
getSessions: () => new Map(),
|
|
|
|
|
getSession,
|
|
|
|
|
listPendingPermissions: async () => [],
|
|
|
|
|
getPermissionState: async () => "ok",
|
|
|
|
|
reply,
|
|
|
|
|
wait: async () => undefined,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(await runtime.processPermission(permission, "/repo")).toBe(true)
|
|
|
|
|
expect(getSessionCalls).toBe(1)
|
|
|
|
|
expect(replyCalls).toBe(1)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("honors an explicit child disable over an enabled parent", async () => {
|
|
|
|
|
let replyCalls = 0
|
|
|
|
|
const reply = mock(async () => { replyCalls += 1 })
|
|
|
|
|
const runtime = createVSCodePermissionAutoAcceptRuntime({
|
|
|
|
|
getPolicy: () => ({ root: true, child: false }),
|
|
|
|
|
getSessions: () => new Map([["child", session("child", "root")]]),
|
|
|
|
|
getSession: async () => session("root"),
|
|
|
|
|
listPendingPermissions: async () => [],
|
|
|
|
|
getPermissionState: async () => "ok",
|
|
|
|
|
reply,
|
|
|
|
|
wait: async () => undefined,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(await runtime.processPermission(permission)).toBe(false)
|
|
|
|
|
expect(replyCalls).toBe(0)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("fails closed when lineage cannot be loaded", async () => {
|
|
|
|
|
let replyCalls = 0
|
|
|
|
|
const reply = mock(async () => { replyCalls += 1 })
|
|
|
|
|
const runtime = createVSCodePermissionAutoAcceptRuntime({
|
|
|
|
|
getPolicy: () => ({ root: true }),
|
|
|
|
|
getSessions: () => new Map(),
|
|
|
|
|
getSession: async () => { throw new Error("offline") },
|
|
|
|
|
listPendingPermissions: async () => [],
|
|
|
|
|
getPermissionState: async () => "ok",
|
|
|
|
|
reply,
|
|
|
|
|
wait: async () => undefined,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(await runtime.processPermission(permission)).toBe(false)
|
|
|
|
|
expect(replyCalls).toBe(0)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("deduplicates concurrent events and retries failed replies", async () => {
|
|
|
|
|
let attempts = 0
|
|
|
|
|
const reply = mock(async () => {
|
|
|
|
|
attempts += 1
|
|
|
|
|
if (attempts < 2) throw new Error("transient")
|
|
|
|
|
})
|
|
|
|
|
const runtime = createVSCodePermissionAutoAcceptRuntime({
|
|
|
|
|
getPolicy: () => ({ child: true }),
|
|
|
|
|
getSessions: () => new Map(),
|
|
|
|
|
getSession: async () => session("child"),
|
|
|
|
|
listPendingPermissions: async () => [],
|
|
|
|
|
getPermissionState: async () => "ok",
|
|
|
|
|
reply,
|
|
|
|
|
wait: async () => undefined,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const first = runtime.processPermission(permission)
|
|
|
|
|
const second = runtime.processPermission(permission)
|
|
|
|
|
expect(await first).toBe(true)
|
|
|
|
|
expect(await second).toBe(true)
|
|
|
|
|
expect(attempts).toBe(2)
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-01 03:14:59 +03:00
|
|
|
test("routes the state check and reply through the permission event directory", async () => {
|
|
|
|
|
const stateDirectories: Array<string | undefined> = []
|
|
|
|
|
const replyDirectories: Array<string | undefined> = []
|
|
|
|
|
const runtime = createVSCodePermissionAutoAcceptRuntime({
|
|
|
|
|
getPolicy: () => ({ child: true }),
|
|
|
|
|
getSessions: () => new Map(),
|
|
|
|
|
getSession: async () => session("child"),
|
|
|
|
|
listPendingPermissions: async () => [],
|
|
|
|
|
getPermissionState: async (_sessionId, _requestId, directory) => {
|
|
|
|
|
stateDirectories.push(directory)
|
|
|
|
|
return "ok"
|
|
|
|
|
},
|
|
|
|
|
reply: async (_sessionId, _requestId, directory) => { replyDirectories.push(directory) },
|
|
|
|
|
wait: async () => undefined,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(await runtime.processPermission(permission, "/permission/project")).toBe(true)
|
|
|
|
|
expect(stateDirectories).toEqual(["/permission/project"])
|
|
|
|
|
expect(replyDirectories).toEqual(["/permission/project"])
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-17 12:59:41 +03:00
|
|
|
test("reconciles existing pending permissions immediately after enablement", async () => {
|
|
|
|
|
const replied: string[] = []
|
|
|
|
|
const runtime = createVSCodePermissionAutoAcceptRuntime({
|
|
|
|
|
getPolicy: () => ({ root: true, disabled: false }),
|
|
|
|
|
getSessions: () => new Map([
|
|
|
|
|
["child", session("child", "root")],
|
|
|
|
|
["disabled", session("disabled", "root")],
|
|
|
|
|
]),
|
|
|
|
|
getSession: async (id) => session(id),
|
|
|
|
|
listPendingPermissions: async () => [
|
|
|
|
|
permission,
|
|
|
|
|
{ ...permission, id: "perm-disabled", sessionID: "disabled" },
|
|
|
|
|
],
|
|
|
|
|
getPermissionState: async () => "ok",
|
|
|
|
|
reply: async (_sessionId, requestId) => { replied.push(requestId) },
|
|
|
|
|
wait: async () => undefined,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await runtime.reconcilePending("/repo")
|
|
|
|
|
|
|
|
|
|
expect(replied).toEqual(["perm-1"])
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-01 03:14:59 +03:00
|
|
|
test("accepts visible pending permissions before a network reconciliation failure", async () => {
|
|
|
|
|
const replied: string[] = []
|
|
|
|
|
let stateChecks = 0
|
|
|
|
|
const runtime = createVSCodePermissionAutoAcceptRuntime({
|
|
|
|
|
getPolicy: () => ({ child: true }),
|
|
|
|
|
getSessions: () => new Map(),
|
|
|
|
|
getSession: async () => session("child"),
|
|
|
|
|
getKnownPendingPermissions: () => [permission],
|
|
|
|
|
listPendingPermissions: async () => { throw new Error("offline") },
|
|
|
|
|
getPermissionState: async () => {
|
|
|
|
|
stateChecks += 1
|
|
|
|
|
return "ok"
|
|
|
|
|
},
|
|
|
|
|
reply: async (_sessionId, requestId) => { replied.push(requestId) },
|
|
|
|
|
wait: async () => undefined,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await expect(runtime.reconcilePending("/repo")).rejects.toThrow("offline")
|
|
|
|
|
expect(stateChecks).toBe(0)
|
|
|
|
|
expect(replied).toEqual(["perm-1"])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("deduplicates visible and network pending permissions", async () => {
|
2026-07-17 12:59:41 +03:00
|
|
|
let replyCalls = 0
|
2026-08-01 03:14:59 +03:00
|
|
|
let stateChecks = 0
|
|
|
|
|
const runtime = createVSCodePermissionAutoAcceptRuntime({
|
|
|
|
|
getPolicy: () => ({ child: true }),
|
|
|
|
|
getSessions: () => new Map(),
|
|
|
|
|
getSession: async () => session("child"),
|
|
|
|
|
getKnownPendingPermissions: () => [permission],
|
|
|
|
|
listPendingPermissions: async () => [permission],
|
|
|
|
|
getPermissionState: async () => {
|
|
|
|
|
stateChecks += 1
|
|
|
|
|
return "ok"
|
|
|
|
|
},
|
|
|
|
|
reply: async () => { replyCalls += 1 },
|
|
|
|
|
wait: async () => undefined,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await runtime.reconcilePending("/repo")
|
|
|
|
|
expect(stateChecks).toBe(0)
|
|
|
|
|
expect(replyCalls).toBe(1)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("sends a live-event reply immediately without a permission-state preflight", async () => {
|
|
|
|
|
let stateChecks = 0
|
|
|
|
|
let replyStarted = false
|
2026-07-17 12:59:41 +03:00
|
|
|
const runtime = createVSCodePermissionAutoAcceptRuntime({
|
|
|
|
|
getPolicy: () => ({ child: true }),
|
|
|
|
|
getSessions: () => new Map(),
|
|
|
|
|
getSession: async () => session("child"),
|
|
|
|
|
listPendingPermissions: async () => [],
|
2026-08-01 03:14:59 +03:00
|
|
|
getPermissionState: async () => {
|
|
|
|
|
stateChecks += 1
|
|
|
|
|
return "ok"
|
|
|
|
|
},
|
|
|
|
|
reply: async () => { replyStarted = true },
|
|
|
|
|
wait: async () => undefined,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(await runtime.processPermission(
|
|
|
|
|
{ ...permission, id: "immediate" },
|
|
|
|
|
"/repo",
|
|
|
|
|
{ verifyPending: false },
|
|
|
|
|
)).toBe(true)
|
|
|
|
|
expect(stateChecks).toBe(0)
|
|
|
|
|
expect(replyStarted).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("keeps the permission-state preflight for refresh reconciliation", async () => {
|
|
|
|
|
let replyCalls = 0
|
|
|
|
|
const runtime = createVSCodePermissionAutoAcceptRuntime({
|
|
|
|
|
getPolicy: () => ({ child: true }),
|
|
|
|
|
getSessions: () => new Map(),
|
|
|
|
|
getSession: async () => session("child"),
|
|
|
|
|
listPendingPermissions: async () => [{ ...permission, id: "resolved" }],
|
2026-07-17 12:59:41 +03:00
|
|
|
getPermissionState: async () => "resolved",
|
|
|
|
|
reply: async () => { replyCalls += 1 },
|
|
|
|
|
wait: async () => undefined,
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-01 03:14:59 +03:00
|
|
|
await runtime.reconcilePending("/repo")
|
2026-07-17 12:59:41 +03:00
|
|
|
expect(replyCalls).toBe(0)
|
|
|
|
|
})
|
|
|
|
|
})
|