mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-21 05:52:35 +08:00
Merge branch 'main' into reproduce-and-fix-revert-file-rollback-bug
This commit is contained in:
@@ -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" })
|
||||
})
|
||||
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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("<script>alert("xss" & 'more')</script>")
|
||||
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("</div><script>alert("xss" & 'more')</script>")
|
||||
expect(html).not.toContain(error)
|
||||
})
|
||||
|
||||
describe("parseJwtClaims", () => {
|
||||
test("parses valid JWT with claims", () => {
|
||||
const payload = { email: "test@example.com", chatgpt_account_id: "acc-123" }
|
||||
|
||||
@@ -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(
|
||||
"</div><script>alert(1)</script><div class="x">",
|
||||
)
|
||||
expect(escapeHtml("a & b")).toBe("a & b")
|
||||
expect(escapeHtml("it's fine")).toBe("it's fine")
|
||||
expect(escapeHtml("invalid_grant")).toBe("invalid_grant")
|
||||
expect(escapeHtml("")).toBe("")
|
||||
expect(escapeHtml("&<")).toBe("&<")
|
||||
})
|
||||
})
|
||||
|
||||
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
|
||||
)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
"</div><script>alert(1)</script><div class="x">",
|
||||
)
|
||||
expect(escapeHtml("a & b")).toBe("a & b")
|
||||
expect(escapeHtml("it's fine")).toBe("it's fine")
|
||||
expect(escapeHtml("invalid_grant")).toBe("invalid_grant")
|
||||
expect(escapeHtml("")).toBe("")
|
||||
expect(escapeHtml("&<")).toBe("&<")
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user