Merge branch 'main' into abalone-bactrosaurus

This commit is contained in:
Marius
2026-07-28 11:28:15 +02:00
committed by GitHub
301 changed files with 11584 additions and 4010 deletions
+9 -2
View File
@@ -517,7 +517,7 @@ describe("acp event routing", () => {
expect(harness.updates).toHaveLength(0)
})
it("emits synthetic pending before the first running tool update", async () => {
it("exposes the shell command on the synthetic pending tool call", async () => {
const harness = createHarness()
await Effect.runPromise(harness.session.create({ id: "ses_tool", cwd: "/workspace" }))
@@ -527,7 +527,14 @@ describe("acp event routing", () => {
"tool_call",
"tool_call_update",
])
expect(harness.updates[0]?.update).toMatchObject({ status: "pending", toolCallId: "call_1" })
expect(harness.updates[0]?.update).toMatchObject({
status: "pending",
toolCallId: "call_1",
title: "printf hello",
kind: "execute",
locations: [{ path: "/workspace" }],
rawInput: { cmd: "printf hello", cwd: "/workspace" },
})
expect(harness.updates[1]?.update).toMatchObject({ status: "in_progress", toolCallId: "call_1" })
})
+8 -1
View File
@@ -1,3 +1,4 @@
import { resolve } from "path"
import { describe, expect, test } from "bun:test"
import {
completedToolContent,
@@ -37,7 +38,13 @@ describe("acp tool conversion", () => {
expect(toLocations("external_directory", { directories: ["/tmp/outside"], patterns: ["/tmp/outside/*"] })).toEqual([
{ path: "/tmp/outside" },
])
expect(toLocations("bash", { filePath: "/tmp/nope.ts", path: "/tmp" })).toEqual([])
expect(toLocations("bash", { cmd: "pwd" }, "/workspace")).toEqual([{ path: "/workspace" }])
// Relative workdir resolves against cwd via the platform path resolver (backslashes on Windows).
expect(toLocations("bash", { command: "pwd", workdir: "subdir" }, "/workspace")).toEqual([
{ path: resolve("/workspace", "subdir") },
])
expect(toLocations("bash", { command: "pwd", workdir: "/abs/dir" }, "/workspace")).toEqual([{ path: "/abs/dir" }])
expect(toLocations("bash", { command: "printf hello" })).toEqual([])
expect(toLocations("read", { path: "/tmp/missing-file-path.ts" })).toEqual([])
})
@@ -2,7 +2,7 @@ import { describe, expect } from "bun:test"
import { Bus } from "@/bus"
import { BackgroundProcess } from "@/kilocode/background-process"
import { SessionID } from "@/session/schema"
import { Shell } from "@/shell/shell"
import { Shell } from "@opencode-ai/core/shell"
import { Filesystem } from "@/util/filesystem"
import { Global } from "@opencode-ai/core/global"
import { Hash } from "@opencode-ai/core/util/hash"
@@ -4,7 +4,7 @@ import { Effect, Layer, ManagedRuntime } from "effect"
import { ShellTool } from "../../src/tool/shell"
import { provideTestInstance } from "../fixture/fixture"
import { tmpdir } from "../fixture/fixture"
import { Shell } from "../../src/shell/shell"
import { Shell } from "@opencode-ai/core/shell"
import { SessionID, MessageID } from "../../src/session/schema"
import type { Permission } from "../../src/permission"
import { Agent } from "../../src/agent/agent"
@@ -11,7 +11,7 @@ import { Plugin } from "@/plugin"
import { Truncate } from "@/tool/truncate"
import { Config } from "@/config/config"
import { Agent } from "@/agent/agent"
import { Shell } from "@/shell/shell"
import { Shell } from "@opencode-ai/core/shell"
import { MessageID, SessionID } from "@/session/schema"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { testEffect } from "../lib/effect"
@@ -0,0 +1,30 @@
// Plugin used by the issue #8656 regression tests.
//
// It attaches the simulated socket from stall-transport.ts to the `mock`
// provider through the plugin `config` hook. This is the supported injection
// point: src/provider/provider.ts loads plugins before reading `cfg.provider`
// exactly so hooks can add options such as `fetch`. Injecting here keeps the
// simulation scoped to the test's own instance instead of replacing
// `globalThis.fetch` for the whole test process.
import { createStallTransport } from "./stall-transport"
type Options = { state?: unknown; answer?: unknown; provider?: unknown }
type Draft = {
provider?: Record<string, { options?: Record<string, unknown> } | undefined>
}
export default async (_input: unknown, options?: Options) => ({
config: async (cfg: Draft) => {
const id = typeof options?.provider === "string" ? options.provider : "mock"
const provider = cfg.provider?.[id]
const state = typeof options?.state === "string" ? options.state : undefined
if (!provider || !state) return
provider.options ??= {}
provider.options["fetch"] = createStallTransport({
state,
answer: typeof options?.answer === "string" ? options.answer : undefined,
})
},
})
@@ -0,0 +1,119 @@
// Simulated provider socket for the issue #8656 regression tests.
//
// Only the socket is simulated. The transport is injected as the provider's
// `fetch` option, so Kilo's own fetch wrapper (connection timeout, first-byte
// guard, SSE chunk watchdog), the openai-compatible SDK, SSE parsing, the
// session processor and the agent loop are all the production ones.
//
// Request script:
// 1. title request -> short text answer
// 2. no tool result in the messages -> a bash tool call
// 3. first request carrying a tool result -> SSE headers, body never sends a
// byte (the stall reported in #8656)
// 4. later requests carrying a tool result -> final text answer, so a bounded
// stall can recover through the normal retry path
//
// Progress is mirrored to a JSON file so tests can assert what the provider saw
// without sharing module state with the plugin that loads this file.
export type StallState = { calls: number; stalls: number; recovered: number }
const HEAD = { id: "chatcmpl-stall", object: "chat.completion.chunk", created: 0, model: "mock-model" }
const chunk = (obj: unknown) => `data: ${JSON.stringify(obj)}\n\n`
const usage = () =>
chunk({ ...HEAD, choices: [], usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 } })
function sse(body: BodyInit | null) {
return new Response(body, { status: 200, headers: { "content-type": "text/event-stream" } })
}
function answer(value: string) {
return sse(
[
chunk({ ...HEAD, choices: [{ index: 0, delta: { role: "assistant", content: value }, finish_reason: null }] }),
chunk({ ...HEAD, choices: [{ index: 0, delta: {}, finish_reason: "stop" }] }),
usage(),
"data: [DONE]\n\n",
].join(""),
)
}
function toolCall(command: string) {
return sse(
[
chunk({
...HEAD,
choices: [
{
index: 0,
delta: {
role: "assistant",
tool_calls: [
{
index: 0,
id: "call_1",
type: "function",
function: { name: "bash", arguments: JSON.stringify({ command }) },
},
],
},
finish_reason: null,
},
],
}),
chunk({ ...HEAD, choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }] }),
usage(),
"data: [DONE]\n\n",
].join(""),
)
}
/** Response headers arrive, then the body never produces a byte. */
function stalling() {
return sse(
new ReadableStream<Uint8Array>({
start() {},
cancel() {
// the first-byte guard cancels this reader when it gives up
},
}),
)
}
export function createStallTransport(input: { state: string; answer?: string; command?: string }) {
const state: StallState = { calls: 0, stalls: 0, recovered: 0 }
const save = () => Bun.write(input.state, JSON.stringify(state))
return async (_input: unknown, init?: { body?: unknown }) => {
const body = typeof init?.body === "string" ? init.body : ""
state.calls++
if (body.includes("Generate a title")) {
await save()
return answer("Stall repro")
}
if (!body.includes('"role":"tool"')) {
await save()
return toolCall(input.command ?? "echo repro-8656")
}
if (state.stalls === 0) {
state.stalls++
await save()
return stalling()
}
state.recovered++
await save()
return answer(input.answer ?? "recovered after the stall")
}
}
export async function readStallState(file: string): Promise<StallState> {
const handle = Bun.file(file)
if (!(await handle.exists())) return { calls: 0, stalls: 0, recovered: 0 }
return JSON.parse(await handle.text()) as StallState
}
@@ -9,7 +9,7 @@ import { InteractiveTerminalTool } from "@/kilocode/tool/interactive-terminal"
import { Plugin } from "@/plugin"
import type { Permission } from "@/permission"
import { MessageID, SessionID } from "@/session/schema"
import { Shell } from "@/shell/shell"
import { Shell } from "@opencode-ai/core/shell"
import { Truncate } from "@/tool/truncate"
import type { Tool } from "@/tool/tool"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
@@ -0,0 +1,180 @@
import { describe, expect, test } from "bun:test"
import path from "node:path"
import { pathToFileURL } from "node:url"
import { provideTestInstance, tmpdir } from "../fixture/fixture"
import { readStallState } from "./fixture/stall-transport"
import { Server } from "../../src/server/server"
// Regression coverage for https://github.com/Kilo-Org/kilocode/issues/8656
//
// Reported symptom: after a tool call finished, `step-finish:tool-calls` was
// recorded and the next `step-start` never arrived, leaving the session busy
// with no error while the HTTP server stayed responsive.
//
// One transport state produces exactly that symptom: the follow-up request that
// carries the tool result gets response headers and then never receives a byte
// of body. The connection-phase timeout is cleared as soon as headers arrive, so
// before the fix nothing bounded that wait.
//
// The simulated socket is injected as the provider's `fetch` through the plugin
// `config` hook (see ../fixture/stall-plugin.ts), so the SDK, Kilo's fetch
// wrapper, SSE parsing, the processor and the agent loop stay production code
// and nothing global is patched.
const PLUGIN = pathToFileURL(path.join(import.meta.dir, "fixture", "stall-plugin.ts")).href
const ANSWER = "recovered after the stall"
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
function settings(state: string, timeout: number | false) {
return {
$schema: "https://app.kilo.ai/config.json",
model: "mock/mock-model",
plugin: [[PLUGIN, { state, answer: ANSWER }]],
provider: {
mock: {
npm: "@ai-sdk/openai-compatible",
name: "Mock",
options: { baseURL: "http://127.0.0.1:1/v1", apiKey: "test", timeout },
models: {
"mock-model": {
name: "Mock Model",
tool_call: true,
limit: { context: 128000, output: 8192 },
cost: { input: 0, output: 0 },
},
},
},
},
permission: { bash: "allow" },
}
}
function project(timeout: number | false) {
return tmpdir<{ state: string }>({
init: async (dir) => {
const state = path.join(dir, "stall-state.json")
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify(settings(state, timeout), null, 2))
return { state }
},
})
}
type Part = Record<string, any>
type Message = { info: Record<string, any>; parts: Part[] }
function session(dir: string) {
const app = Server.Default().app
const headers = { "Content-Type": "application/json", "x-kilo-directory": dir }
const query = `directory=${encodeURIComponent(dir)}`
const json = async (route: string, init?: RequestInit) => {
const res = await app.request(route, { headers, ...init })
return await res.json()
}
return {
create: async () => ((await json("/session", { method: "POST", body: "{}" })) as { id: string }).id,
prompt: (id: string, text: string) =>
app.request(`/session/${id}/prompt_async`, {
method: "POST",
headers,
body: JSON.stringify({ parts: [{ type: "text", text }] }),
}),
abort: (id: string) => app.request(`/session/${id}/abort`, { method: "POST", headers }),
messages: (id: string) => json(`/session/${id}/message?${query}`) as Promise<Message[]>,
status: async (id: string) => {
const all = (await json(`/session/status?${query}`)) as Record<string, { type: string }>
return all[id]?.type ?? "idle"
},
}
}
const timeline = (messages: Message[]) =>
messages
.flatMap((m) => m.parts)
.map((p) =>
p.type === "tool" ? `tool:${p.tool}:${p.state?.status}` : `${p.type}${p.reason ? `:${p.reason}` : ""}`,
)
.join(" | ")
async function until(check: () => Promise<boolean>, budget: number) {
const deadline = Date.now() + budget
while (Date.now() < deadline) {
if (await check()) return true
await sleep(200)
}
return false
}
/** Runs a prompt and waits until the provider has stalled the follow-up request. */
async function stalled(api: ReturnType<typeof session>, state: string) {
const id = await api.create()
await api.prompt(id, "run the echo command")
const ready = await until(async () => {
const stalls = (await readStallState(state)).stalls
const parts = timeline(await api.messages(id))
return stalls > 0 && parts.includes("step-finish:tool-calls")
}, 30_000)
expect(ready).toBe(true)
return id
}
describe("issue #8656: provider stalls after a tool call", () => {
test("recovers instead of freezing once the stall is bounded", async () => {
await using tmp = await project(2_000)
await provideTestInstance({
directory: tmp.path,
fn: async () => {
const api = session(tmp.path)
const id = await stalled(api, tmp.extra.state)
const done = await until(async () => (await api.status(id)) === "idle", 40_000)
const messages = await api.messages(id)
const assistant = messages.findLast((m) => m.info.role === "assistant")
const text = (assistant?.parts ?? []).find((p) => p.type === "text")?.text
const state = await readStallState(tmp.extra.state)
console.log("[repro] bounded ->", JSON.stringify({ timeline: timeline(messages), text, state }))
// the turn finishes on its own: the stalled request was aborted, retried
// and answered, so the agent loop never sits frozen
expect(done).toBe(true)
expect(timeline(messages)).toContain("tool:bash:completed")
expect(state.stalls).toBe(1)
expect(state.recovered).toBeGreaterThan(0)
expect(text).toContain(ANSWER)
expect(assistant?.info.error).toBeUndefined()
},
})
}, 120_000)
test("still hangs while the provider holds the connection open and timeout is disabled", async () => {
await using tmp = await project(false)
await provideTestInstance({
directory: tmp.path,
fn: async () => {
const api = session(tmp.path)
const id = await stalled(api, tmp.extra.state)
try {
await sleep(5_000)
const messages = await api.messages(id)
const parts = timeline(messages)
const assistant = messages.findLast((m) => m.info.role === "assistant")
console.log("[repro] timeout:false ->", JSON.stringify({ timeline: parts, status: await api.status(id) }))
// the reported freeze, kept reachable only through the documented opt-out
expect(parts.endsWith("step-finish:tool-calls")).toBe(true)
expect(await api.status(id)).toBe("busy")
expect(assistant?.info.error).toBeUndefined()
// the server itself stays responsive during the freeze
expect((await Server.Default().app.request("/global/health")).status).toBe(200)
} finally {
// never leave a wedged turn behind for fixture teardown
expect((await api.abort(id)).status).toBe(200)
expect(await until(async () => (await api.status(id)) === "idle", 15_000)).toBe(true)
}
},
})
}, 120_000)
})
@@ -14,7 +14,7 @@ import { PermissionV1 } from "@opencode-ai/core/v1/permission"
import { Database } from "@opencode-ai/core/database/database"
import { provideTestInstance } from "../../fixture/fixture"
import { MessageID, SessionID } from "../../../src/session/schema"
import { Shell } from "../../../src/shell/shell"
import { Shell } from "@opencode-ai/core/shell"
import { Truncate } from "../../../src/tool/truncate"
import { ShellTool } from "../../../src/tool/shell"
import { Plugin } from "../../../src/plugin"
@@ -30,19 +30,16 @@ describe("prompt.ts Kilo-specific invariants", () => {
expect(content).toContain("Suggestion.dismissAll")
})
test("dismissAll for suggestions and questions runs before enqueue, without cancelling the in-flight fiber", () => {
test("enqueue reserves the follow-up before dismissing blockers, without cancelling the in-flight fiber", () => {
const content = fs.readFileSync(PROMPT_FILE, "utf-8")
// dismissAll for both suggestions and questions must precede the enqueue so
// an in-flight handle.process blocked on a pending tool prompt can return.
// Critically, the block must NOT call state.cancel or KiloSessionPromptQueue.reserve —
// either of those would abort the running streamText mid-tokens, which was
// the #9332 regression. Order: dismissAll(Suggestion), question.dismissAll, enqueue.
// Register the queued follow-up before dismissing blockers so the old turn
// observes hasFollowup when the question resumes. The enqueue reservation
// runs both dismissals before waiting for the prior queue tail.
const block = content.match(
/kilocode_change start[^\n]*unblock tools[\s\S]*?Suggestion\.dismissAll[\s\S]*?question\.dismissAll[\s\S]*?KiloSessionPromptQueue\.enqueue/,
/kilocode_change start[^\n]*register the queued follow-up[\s\S]*?Suggestion\.dismissAll[\s\S]*?question\.dismissAll[\s\S]*?KiloSessionPromptQueue\.enqueue\([\s\S]*?dismiss/,
)
expect(block).not.toBeNull()
expect(content).not.toMatch(/state\.cancel\(input\.sessionID\)/)
expect(content).not.toMatch(/KiloSessionPromptQueue\.reserve/)
})
test("runLoop breaks out between LLM steps when a newer prompt was enqueued", () => {
@@ -0,0 +1,78 @@
import { expect } from "bun:test"
import { Effect } from "effect"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Provider } from "../../src/provider/provider"
import { testEffect } from "../lib/effect"
const it = testEffect(Provider.defaultLayer)
const auth = <A, E, R>(value: Record<string, unknown>, effect: Effect.Effect<A, E, R>) =>
Effect.acquireUseRelease(
Effect.sync(() => {
const previous = process.env.KILO_AUTH_CONTENT
process.env.KILO_AUTH_CONTENT = JSON.stringify(value)
return previous
}),
() => effect,
(previous) =>
Effect.sync(() => {
if (previous === undefined) delete process.env.KILO_AUTH_CONTENT
else process.env.KILO_AUTH_CONTENT = previous
}),
)
it.instance(
"uses saved Azure resource metadata",
() =>
auth(
{ azure: { type: "api", key: "azure-key", metadata: { resourceName: "saved-resource" } } },
Effect.gen(function* () {
const provider = yield* Provider.Service
const item = (yield* provider.list())[ProviderV2.ID.make("azure")]
expect(item.key).toBe("azure-key")
expect(item.options.resourceName).toBe("saved-resource")
}),
),
{ config: {} },
)
it.instance(
"uses saved GitLab OAuth access",
() =>
auth(
{ gitlab: { type: "oauth", refresh: "refresh", access: "oauth-access", expires: Date.now() + 60_000 } },
Effect.gen(function* () {
const provider = yield* Provider.Service
const item = (yield* provider.list())[ProviderV2.ID.make("gitlab")]
expect(item.options.apiKey).toBe("oauth-access")
}),
),
{ config: {} },
)
it.instance(
"uses saved Cloudflare Workers AI account metadata",
() =>
auth(
{
"cloudflare-workers-ai": {
type: "api",
key: "cloudflare-key",
metadata: { accountId: "saved-account" },
},
},
Effect.gen(function* () {
const provider = yield* Provider.Service
const item = (yield* provider.list())[ProviderV2.ID.make("cloudflare-workers-ai")]
expect(item.key).toBe("cloudflare-key")
expect(item.options.apiKey).toBe("cloudflare-key")
const model = Object.values(item.models)[0]
const language = yield* provider.getLanguage(model)
const url = (
language as unknown as { config: { url: (input: { path: string; modelId: string }) => string } }
).config.url({ path: "/chat/completions", modelId: model.id })
expect(url).toBe("https://api.cloudflare.com/client/v4/accounts/saved-account/ai/v1/chat/completions")
}),
),
{ config: {} },
)
@@ -0,0 +1,84 @@
import { describe, expect, test } from "bun:test"
import { requestTimeout, wrapFirstByte, REQUEST_TIMEOUT_MS } from "../../../src/kilocode/provider/provider"
import { ProviderError } from "../../../src/provider/error"
const sse = (body: BodyInit | null) =>
new Response(body, { headers: { "content-type": "text/event-stream" }, status: 200 })
// A body that delivers `head` immediately, then stays silent forever.
const stalling = (head?: string) =>
new ReadableStream<Uint8Array>({
start(ctrl) {
if (head) ctrl.enqueue(new TextEncoder().encode(head))
},
})
const drain = async (res: Response) => {
const reader = res.body!.getReader()
const out: string[] = []
while (true) {
const part = await reader.read()
if (part.done) return out.join("")
out.push(new TextDecoder().decode(part.value))
}
}
describe("requestTimeout", () => {
test("defaults to the shared request timeout", () => {
expect(requestTimeout({})).toBe(REQUEST_TIMEOUT_MS)
})
test("honours an explicit value, disables only on false, and bounds invalid input", () => {
expect(requestTimeout({ timeout: 1234 })).toBe(1234)
expect(requestTimeout({ timeout: false })).toBeUndefined()
// invalid/unset values fall back to the default so the wait is always bounded
expect(requestTimeout({ timeout: 0 })).toBe(REQUEST_TIMEOUT_MS)
expect(requestTimeout({ timeout: -1 })).toBe(REQUEST_TIMEOUT_MS)
expect(requestTimeout({ timeout: "nope" })).toBe(REQUEST_TIMEOUT_MS)
expect(requestTimeout({ timeout: null })).toBe(REQUEST_TIMEOUT_MS)
})
})
describe("wrapFirstByte", () => {
test("fails when the body never produces a byte", async () => {
const ctl = new AbortController()
const res = wrapFirstByte(sse(stalling()), 100, ctl)
await expect(drain(res)).rejects.toBeInstanceOf(ProviderError.ResponseStreamError)
expect(ctl.signal.aborted).toBe(true)
expect((ctl.signal.reason as Error).message).toContain("no response data within 100ms")
})
test("stays a passthrough once the first byte arrived, even if the stream then stalls", async () => {
const ctl = new AbortController()
const res = wrapFirstByte(sse(stalling("data: hello\n\n")), 100, ctl)
const reader = res.body!.getReader()
const first = await reader.read()
expect(new TextDecoder().decode(first.value)).toBe("data: hello\n\n")
// the guard must not arm again: idle gaps mid-stream stay opt-in (chunkTimeout)
const next = await Promise.race([
reader.read().then(() => "chunk"),
new Promise((resolve) => setTimeout(() => resolve("still-waiting"), 300)),
])
expect(next).toBe("still-waiting")
expect(ctl.signal.aborted).toBe(false)
await reader.cancel("done")
})
test("passes a complete body through untouched", async () => {
const ctl = new AbortController()
const res = wrapFirstByte(sse("data: one\n\ndata: two\n\n"), 1_000, ctl)
expect(await drain(res)).toBe("data: one\n\ndata: two\n\n")
expect(ctl.signal.aborted).toBe(false)
})
test("is a no-op without a body or when disabled", () => {
const ctl = new AbortController()
const empty = new Response(null, { status: 204 })
expect(wrapFirstByte(empty, 100, ctl)).toBe(empty)
const res = sse("data: x\n\n")
expect(wrapFirstByte(res, 0, ctl)).toBe(res)
})
})
@@ -20,7 +20,7 @@ import { SandboxStore } from "@/kilocode/sandbox/store"
import type { SessionID } from "@/session/schema"
import { Session } from "@/session/session"
import { SessionStatus } from "@/session/status"
import { Shell } from "@/shell/shell"
import { Shell } from "@opencode-ai/core/shell"
import { Storage } from "@/storage/storage"
import { SyncEvent } from "@/sync"
import { provideInstance, testInstanceStoreLayer, tmpdirScoped } from "../../fixture/fixture"
@@ -0,0 +1,209 @@
import path from "path"
import { afterAll, beforeAll, expect, test } from "bun:test"
import fs from "fs/promises"
import os from "os"
import { Effect } from "effect"
import { Flag } from "@opencode-ai/core/flag/flag"
import { AppRuntime } from "../../src/effect/app-runtime"
import { MessageV2 } from "../../src/session/message-v2"
import { Session } from "../../src/session/session"
import { SessionPrompt } from "../../src/session/prompt"
import { SessionID } from "../../src/session/schema"
import {
provideTestInstance,
disposeTestRuntime,
provideInstance,
testInstanceStoreLayer,
tmpdir,
} from "../fixture/fixture"
import { remove as cleanup } from "./cleanup"
const previous = Flag.KILO_DB
const dbfile = path.join(os.tmpdir(), `kilo-prompt-steering-${process.pid}-${crypto.randomUUID()}.db`)
beforeAll(async () => {
await fs.rm(dbfile, { force: true })
Flag.KILO_DB = dbfile
})
afterAll(async () => {
await AppRuntime.dispose()
await disposeTestRuntime()
Flag.KILO_DB = previous
await Promise.all([dbfile, `${dbfile}-wal`, `${dbfile}-shm`].map(cleanup))
})
function line(input: unknown) {
return `data: ${JSON.stringify(input)}\n\n`
}
function chunk(input: { delta?: Record<string, unknown>; finish?: string }) {
return {
id: "chatcmpl-steering-test",
object: "chat.completion.chunk",
choices: [{ delta: input.delta ?? {}, ...(input.finish ? { finish_reason: input.finish } : {}) }],
}
}
function response(input: string) {
return new ReadableStream<Uint8Array>({
start(ctrl) {
ctrl.enqueue(
new TextEncoder().encode(
[
line(chunk({ delta: { role: "assistant" } })),
line(chunk({ delta: { content: input } })),
line(chunk({ finish: "stop" })),
"data: [DONE]\n\n",
].join(""),
),
)
ctrl.close()
},
})
}
function question() {
const args = JSON.stringify({
questions: [
{
header: "Redirect",
question: "Continue the old task?",
options: [{ label: "Yes", description: "Continue" }],
},
],
})
return new ReadableStream<Uint8Array>({
start(ctrl) {
ctrl.enqueue(
new TextEncoder().encode(
[
line(
chunk({
delta: {
role: "assistant",
tool_calls: [
{
index: 0,
id: "call-question",
type: "function",
function: { name: "question", arguments: args },
},
],
},
}),
),
line(chunk({ finish: "tool_calls" })),
"data: [DONE]\n\n",
].join(""),
),
)
ctrl.close()
},
})
}
const sessions = {
create: (input: Parameters<Session.Interface["create"]>[0]) =>
Effect.runPromise(Session.Service.use((svc) => svc.create(input)).pipe(Effect.provide(Session.defaultLayer))),
messages: (sessionID: SessionID) =>
Effect.runPromise(
Session.Service.use((svc) => svc.messages({ sessionID })).pipe(Effect.provide(Session.defaultLayer)),
),
}
async function wait(sessionID: SessionID) {
const deadline = Date.now() + 30_000
while (Date.now() < deadline) {
const msgs = await sessions.messages(sessionID)
if (
msgs.some((msg) =>
msg.parts.some((part) => part.type === "tool" && part.tool === "question" && part.state.status === "running"),
)
)
return
await Bun.sleep(20)
}
throw new Error("question tool did not become pending")
}
function scoped<T>(dir: string, fn: (prompt: SessionPrompt.Interface) => Promise<T>) {
return Effect.runPromise(
SessionPrompt.Service.use((prompt) => Effect.promise(() => fn(prompt))).pipe(
Effect.provide(SessionPrompt.defaultLayer),
provideInstance(dir),
Effect.provide(testInstanceStoreLayer),
Effect.scoped,
),
)
}
function tail(body: Record<string, unknown>): { role: string; content: unknown } | undefined {
const msgs = Array.isArray(body.messages) ? (body.messages as Array<Record<string, unknown>>) : []
const item = msgs.findLast((msg) => msg.role !== "system")
if (!item || typeof item.role !== "string") return
return { role: item.role, content: item.content }
}
test("runs queued steering before resuming a dismissed question turn", async () => {
const calls: Array<Record<string, unknown>> = []
const server = Bun.serve({
port: 0,
async fetch(req) {
if (!new URL(req.url).pathname.endsWith("/chat/completions")) return new Response("not found", { status: 404 })
calls.push((await req.json()) as Record<string, unknown>)
return new Response(calls.length === 1 ? question() : response("steering acknowledged"), {
status: 200,
headers: { "Content-Type": "text/event-stream" },
})
},
})
try {
await using tmp = await tmpdir({
git: true,
init: async (dir) =>
Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
enabled_providers: ["alibaba"],
provider: { alibaba: { options: { apiKey: "test-key", baseURL: `${server.url.origin}/v1` } } },
agent: { code: { model: "alibaba/qwen-plus" } },
}),
),
})
await provideTestInstance({
directory: tmp.path,
fn: () =>
scoped(tmp.path, async (prompt) => {
const session = await sessions.create({ title: "Queued steering regression" })
const first = Effect.runPromise(
prompt.prompt({
sessionID: session.id,
agent: "code",
parts: [{ type: "text", text: "perform the old task" }],
}),
)
await wait(session.id)
const second = Effect.runPromise(
prompt.prompt({
sessionID: session.id,
agent: "code",
parts: [{ type: "text", text: "stop the old task and inspect the failing test" }],
}),
)
await first
const result = await second
expect(result.parts.some((part) => part.type === "text" && part.text.includes("steering acknowledged"))).toBe(
true,
)
expect(calls).toHaveLength(2)
expect(tail(calls[1]!)?.role).toBe("user")
expect(JSON.stringify(tail(calls[1]!)?.content)).toContain("stop the old task and inspect the failing test")
expect(JSON.stringify(tail(calls[1]!)?.content)).not.toContain("<system-reminder>")
}),
})
} finally {
server.stop(true)
}
}, 60_000)
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import * as PowerShell from "@/kilocode/shell/shell"
import { Shell } from "@/shell/shell"
import { Shell } from "@opencode-ai/core/shell"
const command = `Write-Output "こんにちは 😀"; Write-Output '$value'; Write-Output \`tick\`
Write-Output "done"`
@@ -14,7 +14,7 @@ import { MessageV2 } from "../../src/session/message-v2"
import type { SessionPrompt } from "../../src/session/prompt"
import { MessageID, PartID, SessionID } from "../../src/session/schema"
import { BackgroundProcess } from "../../src/kilocode/background-process"
import { Shell } from "../../src/shell/shell"
import { Shell } from "@opencode-ai/core/shell"
import path from "path"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
@@ -12,7 +12,7 @@ describe("test profiles", () => {
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.files.length).toBeGreaterThan(20)
expect(result.files).toContain("pty/pty-shell.test.ts")
expect(result.files).toContain("server/httpapi-v2-pty.test.ts")
expect(result.files).toContain("kilocode/cli/install-artifact.test.ts")
expect(result.files).toContain("kilocode/cli/tui/thread.test.ts")
expect(result.files).toContain("kilocode/sandbox/macos-confinement.test.ts")
@@ -48,7 +48,7 @@ describe("test profiles", () => {
)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.files).toContain("pty/pty-shell.test.ts")
expect(result.files).toContain("server/httpapi-v2-pty.test.ts")
expect(result.files.some((file) => file.includes("\\"))).toBe(false)
})
@@ -0,0 +1,232 @@
// Regression tests for Kilo-Org/kilocode#12326.
//
// tree-sitter-powershell dropped commands containing a bare `--` (for example
// `git checkout -- <file>`) into ERROR nodes instead of command nodes, so the
// shell permission scanner collected zero patterns and the command executed
// with no permission evaluation at all, bypassing every bash rule including
// `"git *": "deny"` and `"*": "deny"`. The scanner now fails closed: failed
// command text is recovered from ERROR nodes, and any parse with errors that
// recovered nothing falls back to the raw command text (also covering ERROR
// chunks without a command_name descendant, such as backtick escapes).
import { describe, expect, test } from "bun:test"
import { Cause, Effect, Exit, Layer } from "effect"
import path from "path"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { FSUtil } from "@opencode-ai/core/fs-util"
import type { PermissionV1 } from "@opencode-ai/core/v1/permission"
import { Permission } from "../../../src/permission"
import { ShellPermission } from "../../../src/tool/shell"
import { ShellTool } from "../../../src/tool/shell"
import { Shell } from "@opencode-ai/core/shell"
import { Config } from "../../../src/config/config"
import { Agent } from "../../../src/agent/agent"
import { Plugin } from "../../../src/plugin"
import { Truncate } from "../../../src/tool/truncate"
import { RuntimeFlags } from "../../../src/effect/runtime-flags"
import { SessionID, MessageID } from "../../../src/session/schema"
import { disposeAllInstances, provideInstance, testInstanceStoreLayer, tmpdir } from "../../fixture/fixture"
import { afterEach } from "bun:test"
const layer = Layer.mergeAll(CrossSpawnSpawner.defaultLayer, FSUtil.defaultLayer, testInstanceStoreLayer)
type ScanRequest = Omit<PermissionV1.Request, "id" | "sessionID" | "tool">
async function scan(dir: string, command: string, shell: string) {
const requests: ScanRequest[] = []
const ctx = {
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make("msg_test"),
callID: "",
agent: "code",
abort: AbortSignal.any([]),
messages: [],
metadata: () => Effect.void,
ask: (req: ScanRequest) =>
Effect.sync(() => {
requests.push(req)
}),
}
await Effect.runPromise(
provideInstance(dir)(
Effect.gen(function* () {
const permission = yield* ShellPermission
yield* permission.ask(ctx, { command, cwd: dir, shell, description: "test" })
}),
).pipe(Effect.provide(layer)),
)
return requests
}
function patterns(requests: ScanRequest[]) {
return requests.filter((req) => req.permission === "bash").flatMap((req) => req.patterns)
}
const deny = Permission.fromConfig({
"*": "ask",
bash: {
"*": "ask",
"git *": "deny",
},
})
function action(pattern: string) {
return Permission.evaluate("bash", pattern, deny).action
}
afterEach(async () => {
await disposeAllInstances()
})
describe("shell permission scanner fails closed on unparsed commands", () => {
test("pwsh: bare '--' git commands now produce a denied pattern", async () => {
await using tmp = await tmpdir()
for (const command of ["git checkout -- file", "git restore -- file", "git log -- file", "git checkout -- ."]) {
const found = patterns(await scan(tmp.path, command, "pwsh"))
expect(found.length).toBeGreaterThan(0)
expect(found.map(action)).toContain("deny")
}
})
test("pwsh: bare '--' in a chained command no longer vanishes from the check", async () => {
await using tmp = await tmpdir()
const found = patterns(await scan(tmp.path, "git checkout -- file; git status", "pwsh"))
expect(found).toContain("git status")
expect(found.some((pattern) => pattern.includes("git checkout -- file"))).toBe(true)
expect(found.map(action)).toContain("deny")
})
test("pwsh: bare '--' in non-git commands produces a pattern that falls back to ask", async () => {
await using tmp = await tmpdir()
for (const command of ["npm run build -- --watch", "echo -- hi", "rm -rf -- file"]) {
const found = patterns(await scan(tmp.path, command, "pwsh"))
expect(found.length).toBeGreaterThan(0)
expect(found.map(action)).toContain("ask")
}
})
test("pwsh: valid commands are unchanged (no extra patterns, no new prompts)", async () => {
await using tmp = await tmpdir()
expect(patterns(await scan(tmp.path, "git status", "pwsh"))).toEqual(["git status"])
expect(patterns(await scan(tmp.path, 'git checkout "--" file', "pwsh"))).toEqual(['git checkout "--" file'])
const found = patterns(await scan(tmp.path, "Write-Host foo; if ($?) { Write-Host bar }", "pwsh"))
expect(found).toContain("Write-Host foo")
expect(found).toContain("Write-Host bar")
expect(found.length).toBe(2)
})
test("pwsh: whitespace stays silent, comment-only input is checked instead of trusted", async () => {
await using tmp = await tmpdir()
expect(patterns(await scan(tmp.path, " ", "pwsh"))).toEqual([])
expect(patterns(await scan(tmp.path, "# comment only", "pwsh"))).toEqual(["# comment only"])
})
test("bash grammar: behavior is unchanged for direct, chained, and location commands", async () => {
await using tmp = await tmpdir()
expect(patterns(await scan(tmp.path, "git checkout -- file", "bash"))).toEqual(["git checkout -- file"])
const chained = patterns(await scan(tmp.path, `cd ${tmp.path} && git checkout -- file`, "bash"))
expect(chained).toEqual(["git checkout -- file"])
expect(patterns(await scan(tmp.path, `cd ${tmp.path}`, "bash"))).toEqual([])
})
test("cmd-kind: bare '--' git commands still produce a denied pattern", async () => {
await using tmp = await tmpdir()
const found = patterns(await scan(tmp.path, "git checkout -- file", "cmd"))
expect(found).toEqual(["git checkout -- file"])
expect(found.map(action)).toContain("deny")
})
test("pwsh: runnable text in an ERROR node without command_name falls back to the raw check", async () => {
await using tmp = await tmpdir()
// PowerShell interprets `n as a newline escape, so this input executes
// `git checkout -- file`, but the grammar drops that segment into an ERROR
// node with no command_name descendant while `echo ok` parses cleanly.
const found = patterns(await scan(tmp.path, "echo ok; `ngit checkout -- file", "pwsh"))
expect(found).toContain("echo ok; `ngit checkout -- file")
expect(found.map(action)).toContain("ask")
})
test("pwsh: partially parsed pipelines still fail closed with the raw text", async () => {
await using tmp = await tmpdir()
const found = patterns(await scan(tmp.path, "git checkout -- file | cat", "pwsh"))
expect(found).toContain("git checkout -- file | cat")
expect(found.map(action)).toContain("deny")
})
})
const execLayer = Layer.mergeAll(
CrossSpawnSpawner.defaultLayer,
FSUtil.defaultLayer,
Plugin.defaultLayer,
Truncate.defaultLayer,
Config.defaultLayer,
Agent.defaultLayer,
RuntimeFlags.defaultLayer,
testInstanceStoreLayer,
)
const powershells =
process.platform === "win32"
? [Bun.which("pwsh"), Bun.which("powershell")].filter((shell): shell is string => Boolean(shell))
: []
async function withShell<R>(shell: string, fn: () => Promise<R>) {
const prev = process.env.SHELL
process.env.SHELL = shell
Shell.acceptable.reset()
Shell.preferred.reset()
try {
return await fn()
} finally {
if (prev === undefined) delete process.env.SHELL
if (prev !== undefined) process.env.SHELL = prev
Shell.acceptable.reset()
Shell.preferred.reset()
}
}
// End-to-end coverage through the real shell tool and a real PowerShell binary.
// Runs only on the Windows CI runners, where pwsh/powershell exist.
describe("full tool execution through real powershell (windows only)", () => {
for (const shell of powershells) {
test(`asks for permission on a bare double dash command [${path.basename(shell, ".exe")}]`, async () => {
await using tmp = await tmpdir()
const requests: ScanRequest[] = []
const stop = new Error("stop after permission")
await withShell(shell, () =>
Effect.runPromise(
provideInstance(tmp.path)(
Effect.gen(function* () {
const info = yield* ShellTool
const tool = yield* info.init()
const exit = yield* tool
.execute(
{ command: "git checkout -- file", description: "Restore a file from git" },
{
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make("msg_test"),
callID: "",
agent: "code",
abort: AbortSignal.any([]),
messages: [],
metadata: () => Effect.void,
ask: (req: ScanRequest) =>
Effect.sync(() => {
requests.push(req)
throw stop
}),
},
)
.pipe(Effect.exit)
const err = Exit.isFailure(exit) ? Cause.squash(exit.cause) : undefined
expect(err instanceof Error && err.message).toBe(stop.message)
}),
).pipe(Effect.provide(execLayer)),
),
)
const req = requests.find((r) => r.permission === "bash")
expect(req).toBeDefined()
expect(req!.patterns).toContain("git checkout -- file")
})
}
})
+34 -2
View File
@@ -1,6 +1,7 @@
import path from "node:path"
import { pathToFileURL } from "node:url"
import { expect, mock, beforeEach } from "bun:test"
import { ToolListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js"
import { ListRootsRequestSchema, ToolListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js"
import { Cause, Effect, Exit } from "effect"
import type { MCP as MCPNS } from "../../src/mcp/index"
import { testEffect } from "../lib/effect"
@@ -40,6 +41,8 @@ interface MockClientState {
{ resources: Array<{ name: string; uri: string; description?: string }>; nextCursor?: string }
>
closed: boolean
clientOptions?: { capabilities?: { roots?: { listChanged?: boolean } } }
requestHandlers: Map<unknown, (...args: any[]) => Promise<any>>
notificationHandlers: Map<unknown, (...args: any[]) => any>
}
@@ -77,6 +80,7 @@ function getOrCreateClientState(name?: string): MockClientState {
promptPages: {},
resourcePages: {},
closed: false,
requestHandlers: new Map(),
notificationHandlers: new Map(),
}
clientStates.set(key, state)
@@ -151,8 +155,10 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({
_state!: MockClientState
transport: any
constructor(_opts: any) {
constructor(_info: any, options?: MockClientState["clientOptions"]) {
clientCreateCount++
this._state = getOrCreateClientState(lastCreatedClientName)
this._state.clientOptions = options
}
async connect(transport: { start: () => Promise<void> }) {
@@ -162,6 +168,10 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({
this._state = getOrCreateClientState(lastCreatedClientName)
}
setRequestHandler(schema: unknown, handler: (...args: any[]) => Promise<any>) {
this._state.requestHandlers.set(schema, handler)
}
setNotificationHandler(schema: unknown, handler: (...args: any[]) => any) {
this._state?.notificationHandlers.set(schema, handler)
}
@@ -319,6 +329,28 @@ it.instance(
)
// kilocode_change end
it.instance(
"advertises and lists the instance directory as its root",
() =>
MCP.Service.use((mcp: MCPNS.Interface) =>
Effect.gen(function* () {
const { directory } = yield* TestInstance
lastCreatedClientName = "roots"
yield* mcp.add("roots", { type: "local", command: ["echo", "test"] })
const state = getOrCreateClientState("roots")
expect(state.clientOptions?.capabilities?.roots).toEqual({})
expect(state.clientOptions?.capabilities?.roots?.listChanged).toBeUndefined()
const handler = state.requestHandlers.get(ListRootsRequestSchema)
expect(handler).toBeDefined()
const result = yield* Effect.promise(() => handler?.() ?? Promise.reject(new Error("roots handler missing")))
expect(result).toEqual({ roots: [{ uri: pathToFileURL(directory).href }] })
}),
),
{ config: { mcp: {} } },
)
it.instance(
"local mcp cwd resolves relative paths against instance directory",
() =>
@@ -87,6 +87,8 @@ void mock.module("@modelcontextprotocol/sdk/client/sse.js", () => ({
// Mock the MCP SDK Client
void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({
Client: class MockClient {
setRequestHandler() {}
async connect(transport: { start: () => Promise<void> }) {
await transport.start()
}
@@ -95,6 +95,8 @@ void mock.module("@modelcontextprotocol/sdk/client/sse.js", () => ({
// Mock the MCP SDK Client to trigger OAuth flow
void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({
Client: class MockClient {
setRequestHandler() {}
async connect(transport: { start: () => Promise<void> }) {
await transport.start()
}
@@ -31,4 +31,42 @@ describe("McpOAuthCallback.ensureRunning", () => {
await McpOAuthCallback.ensureRunning("http://127.0.0.1:18000/custom/callback")
expect(McpOAuthCallback.isRunning()).toBe(true)
})
test("stops after the callback completes", async () => {
const redirectUri = "http://127.0.0.1:18003/custom/callback"
await McpOAuthCallback.ensureRunning(redirectUri)
const callback = McpOAuthCallback.waitForCallback("success")
const response = await fetch(`${redirectUri}?code=code&state=success`)
expect(response.status).toBe(200)
expect(await callback).toBe("code")
expect(McpOAuthCallback.isRunning()).toBe(false)
})
test("escapes provider error markup in callback HTML", async () => {
const redirectUri = "http://127.0.0.1:18001/custom/callback"
await McpOAuthCallback.ensureRunning(redirectUri)
const error = `<script>alert("xss" & 'more')</script>`
const response = await fetch(
`${redirectUri}?state=test&error=access_denied&error_description=${encodeURIComponent(error)}`,
)
const body = await response.text()
expect(response.headers.get("content-type")).toBe("text/html; charset=utf-8")
expect(body).toContain("&lt;script&gt;alert(&quot;xss&quot; &amp; &#39;more&#39;)&lt;/script&gt;")
expect(body).not.toContain(error)
})
test("keeps normal provider errors readable", async () => {
const redirectUri = "http://127.0.0.1:18002/custom/callback"
await McpOAuthCallback.ensureRunning(redirectUri)
const response = await fetch(
`${redirectUri}?state=test&error=access_denied&error_description=${encodeURIComponent("The user denied access")}`,
)
expect(await response.text()).toContain('<div class="error">The user denied access</div>')
})
})
@@ -4,6 +4,7 @@ import {
parseJwtClaims,
extractAccountIdFromClaims,
extractAccountId,
renderOAuthError,
type IdTokenClaims,
} from "../../src/plugin/openai/codex"
@@ -14,6 +15,14 @@ function createTestJwt(payload: object): string {
}
describe("plugin.codex", () => {
test("escapes provider errors in callback HTML", () => {
const error = `</div><script>alert("xss" & 'more')</script>`
const html = renderOAuthError(error)
expect(html).toContain("&lt;/div&gt;&lt;script&gt;alert(&quot;xss&quot; &amp; &#39;more&#39;)&lt;/script&gt;")
expect(html).not.toContain(error)
})
describe("parseJwtClaims", () => {
test("parses valid JWT with claims", () => {
const payload = { email: "test@example.com", chatgpt_account_id: "acc-123" }
-14
View File
@@ -2,7 +2,6 @@ import { describe, expect, test } from "bun:test"
import {
accessTokenIsExpiring,
buildAuthorizeUrl,
escapeHtml,
pollDeviceCodeToken,
requestDeviceCode,
XaiAuthPlugin,
@@ -103,19 +102,6 @@ describe("plugin.xai", () => {
})
})
describe("escapeHtml", () => {
test("escapes HTML metacharacters", () => {
expect(escapeHtml(`</div><script>alert(1)</script><div class="x">`)).toBe(
"&lt;/div&gt;&lt;script&gt;alert(1)&lt;/script&gt;&lt;div class=&quot;x&quot;&gt;",
)
expect(escapeHtml("a & b")).toBe("a &amp; b")
expect(escapeHtml("it's fine")).toBe("it&#39;s fine")
expect(escapeHtml("invalid_grant")).toBe("invalid_grant")
expect(escapeHtml("")).toBe("")
expect(escapeHtml("&<")).toBe("&amp;&lt;")
})
})
describe("loader", () => {
test("returns no options unless stored auth is OAuth and exposes methods in order", async () => {
const hooks = await XaiAuthPlugin({} as any)
@@ -1253,6 +1253,207 @@ describe("ProviderTransform.schema - gemini non-object properties removal", () =
})
})
describe("ProviderTransform.schema - openai supported schema subset", () => {
const openaiModel = {
providerID: "openai",
api: {
id: "gpt-4.1",
npm: "@ai-sdk/openai",
},
} as any
test("removes unsupported JSON Schema keywords recursively", () => {
const result = ProviderTransform.schema(openaiModel, {
$schema: "https://json-schema.org/draft/2020-12/schema",
title: "Search",
type: "object",
properties: {
query: {
type: "string",
description: "Search query",
format: "uri",
pattern: "^https://",
minLength: 1,
maxLength: 100,
default: "https://example.com",
},
count: {
type: "integer",
minimum: 1,
maximum: 10,
multipleOf: 1,
},
createdAt: {
format: "date-time",
},
mode: {
const: "fast",
},
tags: {
type: "array",
minItems: 1,
maxItems: 3,
uniqueItems: true,
},
tuple: {
type: "array",
items: [
{ type: "number", minimum: 0 },
{ type: "string", pattern: "^ok$" },
],
},
metadata: {
type: "object",
patternProperties: {
"^x-": { type: "string" },
},
additionalProperties: {
type: "string",
pattern: "^safe$",
},
},
},
patternProperties: {
"^extra": { type: "string" },
},
required: ["query"],
additionalProperties: false,
} as any) as any
expect(result).toEqual({
type: "object",
properties: {
query: {
type: "string",
description: "Search query",
},
count: {
type: "integer",
},
createdAt: {
type: "string",
},
mode: {
enum: ["fast"],
type: "string",
},
tags: {
type: "array",
items: { type: "string" },
},
tuple: {
type: "array",
items: [{ type: "number" }, { type: "string" }],
},
metadata: {
type: "object",
properties: {},
additionalProperties: {
type: "string",
},
},
},
required: ["query"],
additionalProperties: false,
})
})
test("keeps local references and sanitizes definitions", () => {
const result = ProviderTransform.schema(openaiModel, {
type: "object",
properties: {
value: {
$ref: "#/$defs/Value",
description: "Referenced value",
examples: ["ignored"],
},
},
$defs: {
Value: {
type: "string",
pattern: "^value$",
description: "Definition description",
},
Unused: {
type: "number",
minimum: 0,
},
},
} as any) as any
expect(result.properties.value).toEqual({
$ref: "#/$defs/Value",
description: "Referenced value",
})
expect(result.$defs).toEqual({
Value: {
type: "string",
description: "Definition description",
},
Unused: {
type: "number",
},
})
})
test("does not sanitize non-openai providers", () => {
const result = ProviderTransform.schema(
{
providerID: "anthropic",
api: {
id: "claude-sonnet-4",
npm: "@ai-sdk/anthropic",
},
} as any,
{
type: "object",
properties: {
query: {
type: "string",
pattern: "^https://",
},
},
} as any,
) as any
expect(result.properties.query.pattern).toBe("^https://")
})
test.each([
["opencode", "@ai-sdk/openai"],
["custom-openai-compatible", "@ai-sdk/openai"],
["azure", "@ai-sdk/azure"],
])("sanitizes %s models using %s", (providerID, npm) => {
expect(
ProviderTransform.schema(
{
providerID,
api: {
id: "custom-model",
npm,
},
} as any,
{
type: "object",
properties: {
query: {
type: "string",
pattern: "^https://",
},
},
} as any,
),
).toEqual({
type: "object",
properties: {
query: {
type: "string",
},
},
})
})
})
describe("ProviderTransform.schema - moonshot $ref siblings", () => {
const moonshotModel = {
providerID: "moonshotai",
@@ -2945,6 +3146,102 @@ describe("ProviderTransform.variants", () => {
})
})
test("glm-5.2 returns native effort variants for openai-compatible providers", () => {
const model = createMockModel({
id: "zhipuai/glm-5.2",
providerID: "zhipuai",
api: {
id: "glm-5.2",
url: "https://open.bigmodel.cn/api/paas/v4",
npm: "@ai-sdk/openai-compatible",
},
})
expect(ProviderTransform.variants(model)).toEqual({
high: { reasoningEffort: "high" },
max: { reasoningEffort: "max" },
})
})
test("recognizes GLM-5.2 provider model IDs", () => {
for (const id of ["accounts/fireworks/models/glm-5p2", "zai-org-glm-5-2", "umans-glm-5.2"]) {
const model = createMockModel({
id: `test/${id}`,
api: {
id,
url: "https://api.test.com",
npm: "@ai-sdk/openai-compatible",
},
})
expect(ProviderTransform.variants(model)).toEqual({
high: { reasoningEffort: "high" },
max: { reasoningEffort: "max" },
})
}
})
test("recognizes GLM-5.2 from the API ID when the configured model ID is an alias", () => {
const model = createMockModel({
id: "custom/my-glm",
api: {
id: "accounts/fireworks/models/glm-5p2",
url: "https://api.fireworks.ai/inference/v1",
npm: "@ai-sdk/openai-compatible",
},
})
expect(ProviderTransform.variants(model)).toEqual({
high: { reasoningEffort: "high" },
max: { reasoningEffort: "max" },
})
})
test("glm-5.2 returns openrouter effort variants for openrouter", () => {
const model = createMockModel({
id: "openrouter/z-ai/glm-5.2",
providerID: "openrouter",
api: {
id: "z-ai/glm-5.2",
url: "https://openrouter.ai/api/v1",
npm: "@openrouter/ai-sdk-provider",
},
})
expect(ProviderTransform.variants(model)).toEqual({
high: { reasoning: { effort: "high" } },
xhigh: { reasoning: { effort: "xhigh" } },
})
})
test("glm-5.2 returns effort variants for anthropic-compatible providers", () => {
const model = createMockModel({
id: "zai-coding-plan/glm-5.2",
providerID: "zai-coding-plan",
api: {
id: "glm-5.2",
url: "https://api.z.ai/api/anthropic",
npm: "@ai-sdk/anthropic",
},
})
expect(ProviderTransform.variants(model)).toEqual({
high: { effort: "high" },
max: { effort: "max" },
})
})
test("glm-5.2 falls back to provider defaults for other packages", () => {
const model = createMockModel({
id: "test/glm-5.2",
api: {
id: "glm-5.2",
url: "https://api.test.com",
npm: "@ai-sdk/amazon-bedrock",
},
})
expect(ProviderTransform.variants(model)).toEqual({
low: { reasoningConfig: { type: "enabled", maxReasoningEffort: "low" } },
medium: { reasoningConfig: { type: "enabled", maxReasoningEffort: "medium" } },
high: { reasoningConfig: { type: "enabled", maxReasoningEffort: "high" } },
})
})
test("mistral models with reasoning support return variants", () => {
const model = createMockModel({
id: "mistral/mistral-small-latest",
@@ -1,102 +0,0 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { Config } from "../../src/config/config"
import { Plugin } from "../../src/plugin"
import { PtyPreparation } from "../../src/pty-preparation"
import { Pty } from "@opencode-ai/core/pty"
import { Shell } from "../../src/shell/shell"
import { testEffect } from "../lib/effect"
Shell.preferred.reset()
const it = testEffect(Layer.mergeAll(Config.defaultLayer, Plugin.defaultLayer))
const preparationIt = testEffect(
Layer.mergeAll(
Layer.mock(Config.Service)({ get: () => Effect.succeed({}) }),
Layer.mock(Plugin.Service)({
trigger: <Name extends string, Input, Output>(_name: Name, _input: Input, output: Output) =>
Effect.sync(() => {
const result = output as { env: Record<string, string> }
result.env.INPUT = "plugin"
result.env.FROM_PLUGIN = "plugin"
result.env.TERM = "plugin"
return output
}),
list: () => Effect.succeed([]),
init: () => Effect.void,
}),
),
)
const preparePty = (input: Pty.CreateInput) => PtyPreparation.prepareCreate(input)
describe("pty shell args", () => {
if (process.platform !== "win32") return
const ps = Bun.which("pwsh") || Bun.which("powershell")
if (ps) {
it.instance(
"does not add login args to pwsh",
() =>
Effect.gen(function* () {
const info = yield* preparePty({ command: ps, title: "pwsh" })
expect(info.args).toEqual([])
}),
{ timeout: 30000 },
)
}
const bash = (() => {
const shell = Shell.preferred()
if (Shell.name(shell) === "bash") return shell
return Shell.gitbash()
})()
if (bash) {
it.instance(
"adds login args to bash",
() =>
Effect.gen(function* () {
const info = yield* preparePty({ command: bash, title: "bash" })
expect(info.args).toEqual(["-l"])
}),
{ timeout: 30000 },
)
}
})
describe("pty configured shell", () => {
const configured = process.platform === "win32" ? Bun.which("pwsh") || Bun.which("powershell") : Bun.which("bash")
it.instance(
"uses configured shell for default PTY command",
() =>
Effect.gen(function* () {
if (!configured) return
const info = yield* preparePty({ title: "configured" })
if (process.platform === "win32") {
expect(info.command.toLowerCase()).toBe(configured.toLowerCase())
} else {
expect(info.command).toBe(configured)
}
expect(info.args).toEqual(process.platform === "win32" ? [] : ["-l"])
}),
configured ? { config: { shell: Shell.name(configured) } } : undefined,
{ timeout: 30000 },
)
})
describe("pty environment preparation", () => {
preparationIt.instance("merges plugin environment before forced PTY values", () =>
Effect.gen(function* () {
const input = { command: "/bin/sh", args: [] as string[], env: { INPUT: "caller" } }
const prepared = yield* preparePty(input)
expect(input.args).toEqual([])
expect(prepared.env.INPUT).toBe("plugin")
expect(prepared.env.FROM_PLUGIN).toBe("plugin")
expect(prepared.env.TERM).toBe("xterm-256color")
expect(prepared.env.KILO_TERMINAL).toBe("1")
}),
)
})
@@ -5,8 +5,6 @@ import { EventPaths } from "../../src/server/routes/instance/httpapi/groups/even
// kilocode_change start - verify transformed EventV2 values at the legacy SSE boundary
import { Catalog } from "@opencode-ai/core/catalog"
import { EventV2 } from "@opencode-ai/core/event"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { Prompt } from "@opencode-ai/core/session/prompt"
import { DateTime, Fiber } from "effect"
@@ -199,26 +197,21 @@ describe("event HttpApi", () => {
expect(yield* readGlobal(reader)).toMatchObject({ payload: { type: "server.connected", properties: {} } })
yield* ready(count)
const events = yield* EventV2Bridge.Service
const released = DateTime.makeUnsafe(1_750_000_000_123)
const model = new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("test"), ModelV2.ID.make("model")),
time: { released },
})
const catalogID = EventV2.ID.create()
const catalog = yield* readGlobalUntil(reader, (event) => event.payload.id === catalogID).pipe(
Effect.forkChild({ startImmediately: true }),
)
const catalogDomain = yield* events.publish(Catalog.Event.ModelUpdated, { model }, { id: catalogID })
const catalogDomain = yield* events.publish(Catalog.Event.Updated, {}, { id: catalogID })
expect(DateTime.isDateTime(catalogDomain.data.model.time.released)).toBe(true)
expect(properties(yield* Fiber.join(catalog)).model.time.released).toBe(1_750_000_000_123)
expect(catalogDomain.data).toEqual({})
expect(properties(yield* Fiber.join(catalog))).toEqual({})
const globalID = EventV2.ID.create()
const global = yield* readGlobalUntil(reader, (event) => event.payload.id === globalID).pipe(
Effect.forkChild({ startImmediately: true }),
)
yield* events
.publish(Catalog.Event.ModelUpdated, { model }, { id: globalID })
.publish(Catalog.Event.Updated, {}, { id: globalID })
.pipe(Effect.provideService(InstanceRef, undefined))
expect((yield* Fiber.join(global)).directory).toBe("global")
@@ -13,6 +13,7 @@ process.env.XDG_CACHE_HOME = path.join(exerciseGlobalRoot, "cache")
process.env.KILO_DISABLE_SHARE = "true"
process.env.KILO_DISABLE_SESSION_INGEST = "true" // kilocode_change - isolate the exerciser from async Kilo session sync
process.env.KILO_DISABLE_PRESENCE = "1" // kilocode_change - presence now has a default Event Service URL; never open real sockets from the exerciser
process.env.KILO_DISABLE_CODEBASE_INDEXING = "vscode-no-workspace" // kilocode_change - route scenarios do not need an indexing worker per temp project
export const exerciseConfigDirectory = path.join(exerciseGlobalRoot, "config", "opencode")
export const exerciseDataDirectory = path.join(exerciseGlobalRoot, "data", "kilo") // kilocode_change
@@ -17,7 +17,7 @@
* - `.json(...)` / `.jsonEffect(...)` assert response shape and optional side effects.
* - `.mutating()` tells the runner to reset isolated state after destructive routes.
*/
import { Effect } from "effect"
import { Effect, Layer } from "effect" // kilocode_change
import { OpenApi } from "effect/unstable/httpapi"
import { TestLLMServer } from "../../lib/llm-server"
import path from "path"
@@ -623,6 +623,10 @@ const scenarios: Scenario[] = [
.get("/experimental/session", "experimental.session.list")
.at((ctx) => ({ path: "/experimental/session?roots=false&archived=false", headers: ctx.headers() }))
.json(200, array),
http.protected.get("/experimental/capabilities", "experimental.capabilities.get").json(200, (body) => {
check(typeof body === "object" && body !== null, "capabilities should be an object")
check("backgroundSubagents" in body, "capabilities should report background subagents")
}),
http.protected
.post("/experimental/session/{sessionID}/background", "experimental.session.background")
.mutating()
@@ -802,6 +806,44 @@ const scenarios: Scenario[] = [
.seeded((ctx) => ctx.file("hello.txt", "hello\n"))
.at((ctx) => ({ path: "/api/fs/find?query=hello&type=file", headers: ctx.headers() }))
.json(200, locationData(array)),
http.protected.get("/api/pty", "v2.pty.list").json(200, locationData(array)),
http.protected
.post("/api/pty", "v2.pty.create")
.mutating()
.at((ctx) => ({ path: "/api/pty", headers: ctx.headers(), body: controlledPtyInput("HTTP API V2 PTY") }))
.json(200, locationData(object)),
http.protected
.get("/api/pty/{ptyID}", "v2.pty.get")
.at((ctx) => ({ path: route("/api/pty/{ptyID}", { ptyID: "pty_httpapi_missing" }), headers: ctx.headers() }))
.json(404, object, "status"),
http.protected
.put("/api/pty/{ptyID}", "v2.pty.update")
.mutating()
.at((ctx) => ({
path: route("/api/pty/{ptyID}", { ptyID: "pty_httpapi_missing" }),
headers: ctx.headers(),
body: { title: "missing" },
}))
.json(404, object, "status"),
http.protected
.delete("/api/pty/{ptyID}", "v2.pty.remove")
.mutating()
.at((ctx) => ({ path: route("/api/pty/{ptyID}", { ptyID: "pty_httpapi_missing" }), headers: ctx.headers() }))
.json(404, object, "status"),
http.protected
.post("/api/pty/{ptyID}/connect-token", "v2.pty.connectToken")
.at((ctx) => ({
path: route("/api/pty/{ptyID}/connect-token", { ptyID: "pty_httpapi_missing" }),
headers: { ...ctx.headers(), "x-kilo-ticket": "1" },
}))
.json(404, object, "status"),
http.protected
.get("/api/pty/{ptyID}/connect", "v2.pty.connect")
.at((ctx) => ({
path: route("/api/pty/{ptyID}/connect", { ptyID: "pty_httpapi_missing" }),
headers: ctx.headers(),
}))
.status(404, undefined, "none"),
http.protected.get("/api/reference", "v2.reference.list").json(200, object),
http.protected
.get("/api/provider/{providerID}", "v2.provider.get")
@@ -1628,7 +1670,16 @@ const llmScenarios = new Set([
])
const main = Effect.gen(function* () {
yield* Effect.addFinalizer(() => Effect.promise(() => disposeApps()).pipe(Effect.andThen(cleanupExercisePaths)))
// kilocode_change start - dispose final non-mutating instances so shared test scopes can close
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
const modules = yield* Effect.promise(() => runtime())
yield* Effect.promise(() => modules.disposeAllInstances())
yield* Effect.promise(() => disposeApps())
yield* cleanupExercisePaths
}),
)
// kilocode_change end
const options = parseOptions(Bun.argv.slice(2))
const modules = yield* Effect.promise(() => runtime())
const effectRoutes = routeKeys(OpenApi.fromApi(modules.PublicApi))
@@ -1670,10 +1721,17 @@ const main = Effect.gen(function* () {
return undefined
})
Effect.runPromise(main.pipe(Effect.provide(TestLLMServer.layer), Effect.scoped)).then(
// kilocode_change start - route-only coverage must not acquire a listening fake LLM server
const llm =
parseOptions(Bun.argv.slice(2)).mode === "coverage"
? Layer.mock(TestLLMServer)({ url: "http://coverage.invalid" })
: TestLLMServer.layer
Effect.runPromise(main.pipe(Effect.provide(llm), Effect.scoped)).then(
() => process.exit(0),
(error: unknown) => {
console.error(`${color.red}${message(error)}${color.reset}`)
process.exit(1)
},
)
// kilocode_change end
@@ -1,5 +1,7 @@
import { afterEach, describe, expect, test } from "bun:test"
import net from "node:net"
import path from "node:path"
import { pathToFileURL } from "node:url"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Server } from "../../src/server/server"
import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
@@ -308,6 +310,57 @@ describe("HttpApi Server.listen", () => {
expect(output).not.toContain("Sent HTTP response")
})
test("plugin client requests reuse the listening server instance", async () => {
await using tmp = await tmpdir({
init: async (directory) => {
const plugin = path.join(directory, "plugin.ts")
const initialized = path.join(directory, "initialized.txt")
const completed = path.join(directory, "completed.txt")
await Bun.write(
plugin,
[
"export default async function plugin(input) {",
` await Bun.write(${JSON.stringify(initialized)}, (await Bun.file(${JSON.stringify(initialized)}).text().catch(() => "")) + "initialized\\n")`,
" setTimeout(async () => {",
" await input.client.config.get()",
` await Bun.write(${JSON.stringify(completed)}, "completed")`,
" }, 50)",
" return {}",
"}",
"",
].join("\n"),
)
await Bun.write(
path.join(directory, "opencode.json"),
JSON.stringify({ formatter: false, lsp: false, plugin: [pathToFileURL(plugin).href] }),
)
return { initialized, completed }
},
})
const previous = process.env.KILO_DISABLE_DEFAULT_PLUGINS
process.env.KILO_DISABLE_DEFAULT_PLUGINS = "1"
let listener: Awaited<ReturnType<typeof startListener>> | undefined
try {
listener = await startListener()
const response = await fetch(new URL("/config", listener.url), {
headers: { authorization: authorization(), "x-kilo-directory": tmp.path },
})
expect(response.status).toBe(200)
await withTimeout(
(async () => {
while (!(await Bun.file(tmp.extra.completed).exists())) await Bun.sleep(10)
})(),
5_000,
"timed out waiting for plugin client request",
)
expect(await Bun.file(tmp.extra.initialized).text()).toBe("initialized\n")
} finally {
if (listener) await stop(listener, "timed out cleaning up plugin client listener").catch(() => undefined)
if (previous === undefined) delete process.env.KILO_DISABLE_DEFAULT_PLUGINS
else process.env.KILO_DISABLE_DEFAULT_PLUGINS = previous
}
})
test("port 0 prefers 4096 when free", async () => {
if (!(await isPortFree(4096))) return
const listener = await startListener()
@@ -136,6 +136,33 @@ describe("pty HttpApi bridge", () => {
})
})
testPty("hides exited sessions on the legacy surface", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const headers = { "x-kilo-directory": tmp.path }
const created = await app().request(PtyPaths.create, {
method: "POST",
headers: { ...headers, "content-type": "application/json" },
body: JSON.stringify({ command: "/usr/bin/env", args: ["sh", "-c", "exit 0"] }),
})
expect(created.status).toBe(200)
const info = await created.json()
// Exited sessions are retained by core for the canonical surface, but the legacy
// routes preserve pre-retention behavior: exited sessions are invisible here.
const deadline = Date.now() + 5_000
while (Date.now() < deadline) {
const found = await app().request(PtyPaths.get.replace(":ptyID", info.id), { headers })
if (found.status === 404) break
await new Promise((resolve) => setTimeout(resolve, 50))
}
const found = await app().request(PtyPaths.get.replace(":ptyID", info.id), { headers })
expect(found.status).toBe(404)
const list = await app().request(PtyPaths.list, { headers })
expect(list.status).toBe(200)
expect(await list.json()).toEqual([])
})
testPty("disposes PTY sessions with their legacy instance", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const headers = { "x-kilo-directory": tmp.path }
@@ -0,0 +1,284 @@
import { afterEach, describe, expect, test } from "bun:test"
import { Context, Config as EffectConfig, Effect, Layer, Queue, Schema } from "effect"
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http"
import * as Socket from "effect/unstable/socket/Socket"
import path from "path"
import { pathToFileURL } from "url"
import { mkdir } from "fs/promises"
import { Location } from "@opencode-ai/core/location"
import { Pty } from "@opencode-ai/core/pty"
import { PtyTicket } from "@opencode-ai/core/pty/ticket"
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const context = Context.empty() as Context.Context<unknown>
const testPty = process.platform === "win32" ? test.skip : test
function request(route: string, directory: string, init: RequestInit = {}) {
const headers = new Headers(init.headers)
headers.set("x-kilo-directory", directory)
return HttpApiApp.webHandler().handler(
new Request(`http://localhost${route}`, {
...init,
headers,
}),
context,
)
}
const testStateLayer = Layer.effectDiscard(
Effect.gen(function* () {
yield* Effect.promise(() => resetDatabase())
yield* Effect.addFinalizer(() => Effect.promise(() => resetDatabase()))
}),
)
const servedRoutes: Layer.Layer<never, EffectConfig.ConfigError, HttpServer.HttpServer> = HttpRouter.serve(
HttpApiApp.routes,
{ disableListenLog: true, disableLogger: true },
)
const effectIt = testEffect(
Layer.mergeAll(
testStateLayer,
Socket.layerWebSocketConstructorGlobal,
servedRoutes.pipe(
Layer.provide(Socket.layerWebSocketConstructorGlobal),
Layer.provideMerge(NodeHttpServer.layerTest),
Layer.provideMerge(NodeServices.layer),
),
),
)
const directoryHeader = (dir: string) => HttpClientRequest.setHeader("x-kilo-directory", dir)
const serverUrl = () => HttpServer.HttpServer.use((server) => Effect.succeed(HttpServer.formatAddress(server.address)))
afterEach(async () => {
await disposeAllInstances()
await resetDatabase()
})
describe("v2 pty HttpApi", () => {
testPty("serves location-wrapped PTY routes and retains exited sessions", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const empty = await request("/api/pty", tmp.path)
expect(empty.status).toBe(200)
expect(Schema.decodeUnknownSync(Location.response(Schema.Array(Pty.Info)))(await empty.json()).data).toEqual([])
const created = await request("/api/pty", tmp.path, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ command: "/usr/bin/env", args: ["sh", "-c", "exit 4"], title: "v2" }),
})
expect(created.status).toBe(200)
const body = Schema.decodeUnknownSync(Location.response(Pty.Info))(await created.json())
expect(String(body.location.directory)).toBe(tmp.path)
expect(body.data.title).toBe("v2")
// The canonical surface keeps exited sessions observable with their exit code.
const deadline = Date.now() + 5_000
let info: { status: string; exitCode?: number } | undefined
while (Date.now() < deadline) {
const found = await request(`/api/pty/${body.data.id}`, tmp.path)
expect(found.status).toBe(200)
info = Schema.decodeUnknownSync(Location.response(Pty.Info))(await found.json()).data
if (info.status === "exited") break
await new Promise((resolve) => setTimeout(resolve, 50))
}
expect(info).toMatchObject({ status: "exited", exitCode: 4 })
const removed = await request(`/api/pty/${body.data.id}`, tmp.path, { method: "DELETE" })
expect(removed.status).toBe(204)
const missing = await request(`/api/pty/${body.data.id}`, tmp.path)
expect(missing.status).toBe(404)
expect(await missing.json()).toMatchObject({ _tag: "PtyNotFoundError", ptyID: body.data.id })
})
testPty("rejects connect tokens without the CSRF header and connects with a valid ticket", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const created = await request("/api/pty", tmp.path, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ command: "/usr/bin/env", args: ["sh", "-c", "sleep 5"] }),
})
expect(created.status).toBe(200)
const info = Schema.decodeUnknownSync(Location.response(Pty.Info))(await created.json()).data
try {
const forbidden = await request(`/api/pty/${info.id}/connect-token`, tmp.path, { method: "POST" })
expect(forbidden.status).toBe(403)
expect(await forbidden.json()).toMatchObject({ _tag: "ForbiddenError" })
const token = await request(`/api/pty/${info.id}/connect-token`, tmp.path, {
method: "POST",
headers: { "x-kilo-ticket": "1" },
})
expect(token.status).toBe(200)
const ticket = Schema.decodeUnknownSync(Location.response(PtyTicket.ConnectToken))(await token.json()).data.ticket
expect(ticket).toBeTruthy()
const invalid = await request(`/api/pty/${info.id}/connect?ticket=not-a-ticket`, tmp.path)
expect(invalid.status).toBe(403)
} finally {
await request(`/api/pty/${info.id}`, tmp.path, { method: "DELETE" })
}
})
;(process.platform === "win32" ? effectIt.live.skip : effectIt.live)(
"serves PTY websocket output and input through the canonical route",
() =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true, config: { formatter: false, lsp: false } })
const created = yield* HttpClientRequest.post("/api/pty").pipe(
directoryHeader(dir),
HttpClientRequest.bodyJson({ command: "/bin/cat", title: "v2-websocket" }),
Effect.flatMap(HttpClient.execute),
)
expect(created.status).toBe(200)
const body = yield* Schema.decodeUnknownEffect(Location.response(Pty.Info))(yield* created.json)
const info = body.data
const socket = yield* Socket.makeWebSocket(
`${(yield* serverUrl()).replace(/^http/, "ws")}/api/pty/${info.id}/connect?cursor=-1&location[directory]=${encodeURIComponent(dir)}`,
{ closeCodeIsError: () => false },
)
const messages = yield* Queue.unbounded<string>()
yield* socket
.runRaw((message) =>
Queue.offer(messages, typeof message === "string" ? message : new TextDecoder().decode(message)),
)
.pipe(Effect.catch(() => Effect.void))
.pipe(Effect.forkScoped)
const write = yield* socket.writer
const takeUntil = (expected: string, seen = ""): Effect.Effect<string, unknown> =>
Effect.gen(function* () {
const next = seen + (yield* Queue.take(messages).pipe(Effect.timeout("5 seconds")))
if (next.includes(expected)) return next
return yield* takeUntil(expected, next)
})
yield* write("ping-v2\n")
expect(yield* takeUntil("ping-v2")).toContain("ping-v2")
yield* write(new Socket.CloseEvent(1000, "done")).pipe(Effect.catch(() => Effect.void))
const removed = yield* HttpClientRequest.delete(`/api/pty/${info.id}`).pipe(
directoryHeader(dir),
HttpClient.execute,
)
expect(removed.status).toBe(204)
}),
)
;(process.platform === "win32" ? effectIt.live.skip : effectIt.live)(
"applies plugin shell environment before forced PTY values",
() =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true, config: { formatter: false, lsp: false } })
// kilocode_change start - verify child env precedence and credential stripping through the canonical PTY route
const previous = {
password: process.env.KILO_SERVER_PASSWORD,
username: process.env.KILO_SERVER_USERNAME,
}
yield* Effect.acquireRelease(
Effect.sync(() => {
process.env.KILO_SERVER_PASSWORD = "host-password"
process.env.KILO_SERVER_USERNAME = "host-username"
}),
() =>
Effect.sync(() => {
if (previous.password === undefined) delete process.env.KILO_SERVER_PASSWORD
else process.env.KILO_SERVER_PASSWORD = previous.password
if (previous.username === undefined) delete process.env.KILO_SERVER_USERNAME
else process.env.KILO_SERVER_USERNAME = previous.username
}),
)
const plugin = path.join(dir, "plugin.ts")
const cwd = path.join(dir, "child")
yield* Effect.promise(() => mkdir(cwd))
yield* Effect.promise(() =>
Bun.write(
plugin,
[
"export default async () => ({",
' "shell.env": (input, output) => {',
' output.env.SHARED = "plugin"',
' output.env.PLUGIN = "plugin"',
' output.env.TERM = "plugin"',
' output.env.KILO_TERMINAL = "plugin"',
' output.env.KILO_PTY_ID = "plugin"',
' output.env.KILO_SERVER_PASSWORD = "plugin-password"',
' output.env.KILO_SERVER_USERNAME = "plugin-username"',
" output.env.HOOK_CWD = input.cwd",
" },",
"})",
"",
].join("\n"),
),
)
yield* Effect.promise(() =>
Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({ plugin: [pathToFileURL(plugin).href], formatter: false, lsp: false }),
),
)
const created = yield* HttpClientRequest.post("/api/pty").pipe(
directoryHeader(dir),
HttpClientRequest.bodyJson({
command: "/bin/sh",
args: [
"-c",
'printf "%s|%s|%s|%s|%s|%s|%s|%s|%s\\n" "$CALLER" "$SHARED" "$PLUGIN" "$TERM" "$KILO_TERMINAL" "$KILO_PTY_ID" "${KILO_SERVER_PASSWORD-unset}" "${KILO_SERVER_USERNAME-unset}" "$HOOK_CWD"; sleep 5',
],
cwd,
env: {
CALLER: "caller",
SHARED: "caller",
TERM: "caller",
KILO_TERMINAL: "caller",
KILO_PTY_ID: "caller",
KILO_SERVER_PASSWORD: "caller-password",
KILO_SERVER_USERNAME: "caller-username",
},
}),
Effect.flatMap(HttpClient.execute),
)
expect(created.status).toBe(200)
const info = (yield* Schema.decodeUnknownEffect(Location.response(Pty.Info))(yield* created.json)).data
const socket = yield* Socket.makeWebSocket(
`${(yield* serverUrl()).replace(/^http/, "ws")}/api/pty/${info.id}/connect?cursor=0&location[directory]=${encodeURIComponent(dir)}`,
{ closeCodeIsError: () => false },
)
const messages = yield* Queue.unbounded<string>()
yield* socket
.runRaw((message) =>
Queue.offer(messages, typeof message === "string" ? message : new TextDecoder().decode(message)),
)
.pipe(
Effect.catch(() => Effect.void),
Effect.forkScoped,
)
const write = yield* socket.writer
const takeUntil = (expected: string, seen = ""): Effect.Effect<string, unknown> =>
Effect.gen(function* () {
const next = seen + (yield* Queue.take(messages).pipe(Effect.timeout("5 seconds")))
if (next.includes(expected)) return next
return yield* takeUntil(expected, next)
})
const output = yield* takeUntil("caller|plugin|plugin|xterm-256color")
expect(output).toContain(`caller|plugin|plugin|xterm-256color|1|${info.id}|||${cwd}`)
// kilocode_change end
yield* write(new Socket.CloseEvent(1000, "done")).pipe(Effect.catch(() => Effect.void))
yield* HttpClientRequest.delete(`/api/pty/${info.id}`).pipe(directoryHeader(dir), HttpClient.execute)
}),
30_000, // kilocode_change - external plugin loading and websocket setup can exceed Bun's 5s default
)
})
+10 -3
View File
@@ -50,7 +50,7 @@ import { SessionV2 } from "@opencode-ai/core/session"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { Skill } from "../../src/skill"
import { SystemPrompt } from "../../src/session/system"
import { Shell } from "../../src/shell/shell"
import { Shell } from "@opencode-ai/core/shell"
import { Snapshot } from "../../src/snapshot"
import { ToolRegistry } from "@/tool/registry"
import { Truncate } from "@/tool/truncate"
@@ -1527,7 +1527,7 @@ it.instance(
}
}),
{ git: true },
10_000,
30_000, // kilocode_change - isolated suite load can delay queued live-loop cancellation
)
// Queue semantics
@@ -1639,7 +1639,14 @@ it.instance(
const inputs = yield* llm.inputs
expect(inputs).toHaveLength(2)
expect(JSON.stringify(inputs.at(-1)?.messages)).toContain("second")
const messages = inputs.at(-1)?.messages
if (!Array.isArray(messages)) throw new Error("expected LLM messages")
// kilocode_change start - Kilo appends environment details to queued user prompts
expect(messages.at(-1)).toMatchObject({
role: "user",
content: expect.arrayContaining([{ type: "text", text: "second" }]),
})
// kilocode_change end
}),
10_000,
)
@@ -17,7 +17,7 @@ import { Database } from "@opencode-ai/core/database/database"
import { eq } from "drizzle-orm"
import { provideTmpdirInstance } from "../fixture/fixture"
import { resetDatabase } from "../fixture/db"
import { pollWithTimeout, testEffect } from "../lib/effect" // kilocode_change
import { pollWithTimeout, testEffect } from "../lib/effect"
const env = LayerNode.buildLayer(CrossSpawnSpawner.node)
const it = testEffect(env)
@@ -140,10 +140,10 @@ describe("ShareNext", () => {
it.live("create posts share, persists it, and returns the result", () =>
provideTmpdirInstance(
() => {
const seen: HttpClientRequest.HttpClientRequest[] = []
const createRequests: HttpClientRequest.HttpClientRequest[] = []
const client = HttpClient.make((req) => {
seen.push(req)
if (req.url.endsWith("/api/share")) {
createRequests.push(req)
return Effect.succeed(
json(req, {
id: "shr_abc",
@@ -168,9 +168,9 @@ describe("ShareNext", () => {
expect(row?.url).toBe("https://legacy-share.example.com/share/abc")
expect(row?.secret).toBe("sec_123")
expect(seen).toHaveLength(1)
expect(seen[0].method).toBe("POST")
expect(seen[0].url).toBe("https://legacy-share.example.com/api/share")
expect(createRequests).toHaveLength(1)
expect(createRequests[0].method).toBe("POST")
expect(createRequests[0].url).toBe("https://legacy-share.example.com/api/share")
}).pipe(Effect.provide(integrationLayer(client)))
},
{ config: { enterprise: { url: "https://legacy-share.example.com" } } },
@@ -304,13 +304,13 @@ describe("ShareNext", () => {
deletions: 0,
status: "modified",
},
], // kilocode_change
],
})
const sync = yield* pollWithTimeout(
Effect.sync(() => seen[0]),
"share sync was not sent",
"3 seconds",
) // kilocode_change
)
expect(seen).toHaveLength(1)
expect(sync.url).toBe("https://legacy-share.example.com/api/share/shr_abc/sync") // kilocode_change
@@ -1,99 +0,0 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import { Shell } from "../../src/shell/shell"
import { Filesystem } from "@/util/filesystem"
import { which } from "@opencode-ai/core/util/which"
const withShell = async (shell: string | undefined, fn: () => void | Promise<void>) => {
const prev = process.env.SHELL
if (shell === undefined) delete process.env.SHELL
else process.env.SHELL = shell
Shell.acceptable.reset()
Shell.preferred.reset()
try {
await fn()
} finally {
if (prev === undefined) delete process.env.SHELL
else process.env.SHELL = prev
Shell.acceptable.reset()
Shell.preferred.reset()
}
}
describe("shell", () => {
test("normalizes shell names", () => {
expect(Shell.name("/bin/bash")).toBe("bash")
if (process.platform === "win32") {
expect(Shell.name("C:/tools/NU.EXE")).toBe("nu")
expect(Shell.name("C:/tools/PWSH.EXE")).toBe("pwsh")
}
})
test("detects login shells", () => {
expect(Shell.login("/bin/bash")).toBe(true)
expect(Shell.login("C:/tools/pwsh.exe")).toBe(false)
})
test("detects posix shells", () => {
expect(Shell.posix("/bin/bash")).toBe(true)
expect(Shell.posix("/bin/fish")).toBe(false)
expect(Shell.posix("C:/tools/pwsh.exe")).toBe(false)
})
test("falls back when configured shell cannot be resolved", async () => {
await withShell(undefined, async () => {
const preferred = Shell.preferred()
const acceptable = Shell.acceptable()
expect(Shell.preferred("opencode-missing-shell")).toBe(preferred)
expect(Shell.acceptable("opencode-missing-shell")).toBe(acceptable)
})
})
test("falls back for terminal-only acceptable shells", () => {
expect(Shell.name(Shell.acceptable("fish"))).not.toBe("fish")
expect(Shell.name(Shell.acceptable("nu"))).not.toBe("nu")
})
if (process.platform === "win32") {
test("rejects blacklisted shells case-insensitively", async () => {
await withShell("NU.EXE", async () => {
expect(Shell.name(Shell.acceptable())).not.toBe("nu")
})
})
test("normalizes Git Bash shell paths from env", async () => {
const shell = "/cygdrive/c/Program Files/Git/bin/bash.exe"
await withShell(shell, async () => {
expect(Shell.preferred()).toBe(Filesystem.windowsPath(shell))
})
})
test("resolves /usr/bin/bash from env to Git Bash", async () => {
const bash = Shell.gitbash()
if (!bash) return
await withShell("/usr/bin/bash", async () => {
expect(Shell.acceptable()).toBe(bash)
expect(Shell.preferred()).toBe(bash)
})
})
test("resolves bare bash to Git Bash before PATH", async () => {
const bash = Shell.gitbash()
if (!bash) return
expect(Shell.acceptable("bash")).toBe(bash)
expect(Shell.preferred("bash")).toBe(bash)
await withShell("bash", async () => {
expect(Shell.acceptable()).toBe(bash)
expect(Shell.preferred()).toBe(bash)
})
})
test("resolves bare PowerShell shells", async () => {
const shell = which("pwsh") || which("powershell")
if (!shell) return
await withShell(path.win32.basename(shell), async () => {
expect(Shell.preferred()).toBe(shell)
})
})
}
})
+1 -1
View File
@@ -5,7 +5,7 @@ import type * as Scope from "effect/Scope"
import os from "os"
import path from "path"
import { Config } from "@/config/config"
import { Shell } from "../../src/shell/shell"
import { Shell } from "@opencode-ai/core/shell"
import { ShellTool } from "../../src/tool/shell"
import { Filesystem } from "@/util/filesystem"
import { provideInstance, testInstanceStoreLayer, tmpdirScoped } from "../fixture/fixture"
+15
View File
@@ -0,0 +1,15 @@
import { describe, expect, test } from "bun:test"
import { escapeHtml } from "../../src/util/html"
describe("escapeHtml", () => {
test("escapes HTML metacharacters", () => {
expect(escapeHtml(`</div><script>alert(1)</script><div class="x">`)).toBe(
"&lt;/div&gt;&lt;script&gt;alert(1)&lt;/script&gt;&lt;div class=&quot;x&quot;&gt;",
)
expect(escapeHtml("a & b")).toBe("a &amp; b")
expect(escapeHtml("it's fine")).toBe("it&#39;s fine")
expect(escapeHtml("invalid_grant")).toBe("invalid_grant")
expect(escapeHtml("")).toBe("")
expect(escapeHtml("&<")).toBe("&amp;&lt;")
})
})