Merge branch 'main' into fix-cli-subprocess-timeouts

This commit is contained in:
Marius
2026-07-24 18:55:11 +02:00
committed by GitHub
144 changed files with 2946 additions and 418 deletions
@@ -0,0 +1,74 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import path from "path"
import fs from "fs/promises"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Global } from "@opencode-ai/core/global"
import { Skill } from "../../src/skill"
import { Discovery } from "../../src/skill/discovery"
import { RuntimeFlags } from "../../src/effect/runtime-flags"
import { EventV2Bridge } from "../../src/event-v2-bridge"
import { Config } from "../../src/config/config"
import { Git } from "../../src/git"
import { provideInstance, testInstanceStoreLayer, tmpdir } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const skills = (home: string) =>
Skill.layer.pipe(
Layer.provide(Git.defaultLayer),
Layer.provide(Discovery.defaultLayer),
Layer.provide(Config.defaultLayer),
Layer.provide(EventV2Bridge.defaultLayer),
Layer.provide(FSUtil.defaultLayer),
Layer.provide(Global.layerWith({ home })),
Layer.provide(RuntimeFlags.layer({ disableExternalSkills: false, disableClaudeCodeSkills: false })),
)
const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, testInstanceStoreLayer))
describe("non-Git global skills", () => {
it.live("loads global skills when the project is below the home directory", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
const project = path.join(tmp.path, "projects", "plain")
const roots = [".agents", ".claude"] as const
yield* Effect.promise(async () => {
await fs.mkdir(project, { recursive: true })
await Promise.all(
roots.map(async (root) => {
const name = `${root.slice(1)}-global`
const dir = path.join(tmp.path, root, "skills", name)
await fs.mkdir(dir, { recursive: true })
await Bun.write(
path.join(dir, "SKILL.md"),
`---
name: ${name}
description: Global ${root} skill.
---
# Global skill
`,
)
}),
)
})
yield* Effect.gen(function* () {
const skill = yield* Skill.Service
const list = yield* skill.all()
for (const root of roots) {
const name = `${root.slice(1)}-global`
expect(list.find((item) => item.name === name)?.location).toBe(
path.join(tmp.path, root, "skills", name, "SKILL.md"),
)
}
}).pipe(Effect.provide(skills(tmp.path)), provideInstance(project))
}),
)
})
@@ -115,7 +115,7 @@ describe("saveAlwaysRules", () => {
always: [],
ruleset: [],
})
expect(result).toBeUndefined()
expect(result.manual).toBe(false)
}),
),
)
@@ -204,7 +204,7 @@ describe("saveAlwaysRules", () => {
always: [],
ruleset: [],
})
expect(result).toBeUndefined()
expect(result.manual).toBe(false)
// curl was NOT in rules — still requires permission
const curlFiber = yield* ask({
@@ -255,7 +255,7 @@ describe("saveAlwaysRules", () => {
always: [],
ruleset: [],
})
expect(result).toBeUndefined()
expect(result.manual).toBe(false)
}),
),
)
@@ -325,7 +325,7 @@ describe("saveAlwaysRules", () => {
{ permission: "bash", pattern: "gh *", action: "ask" },
],
})
expect(result).toBeUndefined()
expect(result.manual).toBe(false)
}),
),
)
@@ -350,7 +350,7 @@ describe("saveAlwaysRules", () => {
ruleset,
hardRuleset: ruleset,
})
expect(result).toBeUndefined()
expect(result.manual).toBe(false)
}),
),
)
@@ -386,7 +386,7 @@ describe("saveAlwaysRules", () => {
],
hardRuleset: [{ permission: "*", pattern: "*", action: "deny" }],
})
expect(result).toBeUndefined()
expect(result.manual).toBe(false)
}),
),
)
@@ -448,7 +448,7 @@ describe("saveAlwaysRules", () => {
always: [],
ruleset: [],
})
expect(result).toBeUndefined()
expect(result.manual).toBe(false)
}),
),
)
@@ -486,7 +486,7 @@ describe("saveAlwaysRules", () => {
always: [],
ruleset: [],
})
expect(result).toBeUndefined()
expect(result.manual).toBe(false)
}),
),
)
@@ -522,7 +522,7 @@ describe("saveAlwaysRules", () => {
always: [],
ruleset: [],
})
expect(allowed).toBeUndefined()
expect(allowed.manual).toBe(false)
// "git status" should be denied (only matches broad deny)
const exit = yield* ask({
@@ -0,0 +1,84 @@
// kilocode_change - new file
// Verifies that Config.permission_origins attributes each permission key to the scope
// (global XDG vs local project) that last set it, which drives auto-approval provenance.
import { expect, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Effect, Layer, Option } from "effect"
import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { Config } from "../../../src/config/config"
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
import { Npm } from "@opencode-ai/core/npm"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Env } from "../../../src/env"
import { Git } from "../../../src/git"
import { Auth } from "../../../src/auth"
import { Account } from "../../../src/account/account"
import { provideTestInstance } from "../../fixture/fixture"
import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner"
import { HttpClient } from "effect/unstable/http"
import { tmpdir } from "../../fixture/fixture"
const infra = CrossSpawnSpawner.defaultLayer.pipe(
Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)),
)
const emptyAccount = Layer.mock(Account.Service)({
active: () => Effect.succeed(Option.none()),
activeOrg: () => Effect.succeed(Option.none()),
})
const emptyAuth = Layer.mock(Auth.Service)({ all: () => Effect.succeed({}) })
const noopNpm = Layer.mock(Npm.Service)({
install: () => Effect.void,
add: () => Effect.die("not implemented"),
which: () => Effect.succeed(Option.none()),
})
const unexpectedHttp = HttpClient.make((request) => Effect.die(`unexpected http request: ${request.method} ${request.url}`))
const testLayer = Config.layer.pipe(
Layer.provide(Git.defaultLayer),
Layer.provide(EffectFlock.defaultLayer),
Layer.provide(FSUtil.defaultLayer),
Layer.provide(Env.defaultLayer),
Layer.provide(emptyAuth),
Layer.provide(emptyAccount),
Layer.provideMerge(infra),
Layer.provide(noopNpm),
Layer.provide(Layer.succeed(HttpClient.HttpClient, unexpectedHttp)),
)
test("project config permission keys are attributed to the local scope", async () => {
await using tmp = await tmpdir()
const dir = path.join(tmp.path, "a")
const kilo = path.join(dir, ".kilo")
await fs.mkdir(kilo, { recursive: true })
await Bun.write(path.join(kilo, "kilo.json"), JSON.stringify({ permission: { bash: { "echo *": "allow" } } }))
await provideTestInstance({
directory: dir,
fn: async () => {
const cfg = await Effect.runPromise(
Config.Service.use((svc) => svc.get()).pipe(Effect.scoped, Effect.provide(testLayer)),
)
expect(cfg.permission?.bash).toEqual({ "echo *": "allow" })
expect(cfg.permission_origins?.bash).toEqual({ "echo *": "local" })
},
})
})
test("a scalar project bash permission maps to the '*' pattern under the local scope", async () => {
await using tmp = await tmpdir()
const dir = path.join(tmp.path, "a")
const kilo = path.join(dir, ".kilo")
await fs.mkdir(kilo, { recursive: true })
await Bun.write(path.join(kilo, "kilo.json"), JSON.stringify({ permission: { bash: "allow" } }))
await provideTestInstance({
directory: dir,
fn: async () => {
const cfg = await Effect.runPromise(
Config.Service.use((svc) => svc.get()).pipe(Effect.scoped, Effect.provide(testLayer)),
)
expect(cfg.permission_origins?.bash).toEqual({ "*": "local" })
},
})
})
@@ -0,0 +1,228 @@
import { test, expect, describe } from "bun:test"
import { Effect, Layer } from "effect"
import { Agent } from "../../../src/agent/agent"
import { Session } from "../../../src/session/session"
import { Permission } from "../../../src/permission"
import { PermissionProvenance } from "../../../src/kilocode/permission/provenance"
import { KiloSessionPrompt } from "../../../src/kilocode/session/prompt"
import { SessionID } from "../../../src/session/schema"
describe("PermissionProvenance", () => {
test("configSource maps the scope of a permission + pattern", () => {
expect(PermissionProvenance.configSource("edit", "*", { edit: { "*": "global" } })).toBe("global")
expect(PermissionProvenance.configSource("edit", "*", { edit: { "*": "local" } })).toBe("project")
expect(PermissionProvenance.configSource("edit", "*", undefined)).toBe("agent")
// Different patterns under one key can come from different scopes.
const mixed = { bash: { "git status": "global" as const, "npm test": "local" as const } }
expect(PermissionProvenance.configSource("bash", "git status", mixed)).toBe("global")
expect(PermissionProvenance.configSource("bash", "npm test", mixed)).toBe("project")
// A pattern not present under the key falls back to the agent default.
expect(PermissionProvenance.configSource("bash", "rm -rf", mixed)).toBe("agent")
})
test("evaluate returns the winning rule object, preserving its source tag", () => {
// The last matching rule wins; the returned object still carries the source we attached.
const ruleset: PermissionProvenance.SourcedRule[] = [
{ permission: "edit", pattern: "*", action: "ask", source: "agent" },
{ permission: "edit", pattern: "src/*", action: "allow", source: "global" },
]
const winner = Permission.evaluate("edit", "src/index.ts", ruleset)
expect((winner as PermissionProvenance.SourcedRule).source).toBe("global")
})
test("classify reads a tagged rule's source and carries the agent name", () => {
const rule = { permission: "edit", pattern: "*", action: "allow" as const, source: "agent" as const }
expect(PermissionProvenance.classify({ rule, agent: "build", origins: undefined })).toEqual({
source: "agent",
agent: "build",
rule: { permission: "edit", pattern: "*", action: "allow" },
})
})
test("classify treats an untagged broad allow as yolo", () => {
const out = PermissionProvenance.classify({
rule: { permission: "*", pattern: "*", action: "allow" },
agent: "build",
origins: undefined,
})
expect(out.source).toBe("yolo")
})
test("classify falls back to config origins for an untagged rule", () => {
const out = PermissionProvenance.classify({
rule: { permission: "edit", pattern: "src/*", action: "allow" },
agent: "build",
origins: { edit: { "src/*": "local" } },
})
expect(out.source).toBe("project")
})
test("classify without a rule reports the ask fallback", () => {
expect(PermissionProvenance.classify({ agent: "build", origins: undefined })).toEqual({ source: "default" })
})
test("tagAgent stamps each rule by permission + pattern, defaulting to agent", () => {
const tagged = PermissionProvenance.tagAgent(
[
{ permission: "bash", pattern: "git status", action: "allow" },
{ permission: "bash", pattern: "npm test", action: "allow" },
{ permission: "edit", pattern: "*", action: "allow" },
],
// Global and project each contribute a different pattern under the same bash key.
{ bash: { "git status": "global", "npm test": "local" } },
)
expect(tagged.map((r) => r.source)).toEqual(["global", "project", "agent"])
})
test("tagSession marks the broad allow as yolo and other rules as session", () => {
const tagged = PermissionProvenance.tagSession([
{ permission: "*", pattern: "*", action: "allow" },
{ permission: "bash", pattern: "git *", action: "allow" },
])
expect(tagged.map((r) => r.source)).toEqual(["yolo", "session"])
})
test("a tagged agent rule wins over an untagged duplicate and is not misread as yolo", () => {
// Regression: guardPermissions re-appends agent rules for ask/plan/architect; every rule that
// reaches evaluate must be tagged so the broad agent allow is not mistaken for YOLO mode.
const agent = PermissionProvenance.tagAgent([{ permission: "*", pattern: "*", action: "allow" }], undefined)
const session = PermissionProvenance.tagSession([])
const ruleset = [...agent, ...session, ...agent] // mirrors merge(tagged, guardPermissions(...)) for a mode
const winner = Permission.evaluate("bash", "echo hi", ruleset)
expect(PermissionProvenance.classify({ rule: winner, agent: "plan", origins: undefined })).toEqual({
source: "agent",
agent: "plan",
rule: { permission: "*", pattern: "*", action: "allow" },
})
})
})
describe("PermissionProvenance.carryApproval", () => {
const approval = { source: "agent" as const, agent: "build" }
test("carries a prior approval onto a replacement that omits it", () => {
// The tool overwrites metadata during execution; the approval written during ask() must survive.
expect(PermissionProvenance.carryApproval({ approval }, { command: "echo hi" })).toEqual({
command: "echo hi",
approval,
})
})
test("does not override an approval the replacement sets itself", () => {
const next = { approval: { source: "yolo" as const } }
expect(PermissionProvenance.carryApproval({ approval }, next)).toBe(next)
})
test("leaves the replacement untouched when there is no prior approval", () => {
const next = { command: "echo hi" }
expect(PermissionProvenance.carryApproval({ command: "old" }, next)).toBe(next)
})
test("returns the replacement as-is when it is undefined", () => {
expect(PermissionProvenance.carryApproval({ approval }, undefined)).toBeUndefined()
})
})
describe("askPermission returns provenance", () => {
const sessionID = SessionID.make("ses_prov")
const agent: Agent.Info = {
name: "build",
mode: "primary",
permission: Permission.fromConfig({ edit: "allow" }),
options: {},
}
const session = { id: sessionID, permission: [] } as unknown as Session.Info
const run = (outcome: Permission.AskOutcome, origins?: PermissionProvenance.Origins) =>
Effect.gen(function* () {
return yield* KiloSessionPrompt.askPermission({
permission: yield* Permission.Service,
agents: yield* Agent.Service,
sessions: yield* Session.Service,
origins,
agent,
session,
request: { sessionID, permission: "edit", patterns: ["src/index.ts"], always: [], metadata: {} },
})
}).pipe(
Effect.provide(
Layer.mergeAll(
Layer.mock(Permission.Service)({ ask: () => Effect.succeed(outcome) }),
Layer.mock(Agent.Service)({ get: () => Effect.succeed(agent) }),
Layer.mock(Session.Service)({ get: () => Effect.succeed(session) }),
),
),
Effect.runPromise,
)
test("manual reply reports the manual source", async () => {
expect(await run({ manual: true })).toEqual({ source: "manual" })
})
test("agent-default rule classifies as agent with its name", async () => {
const rule = { permission: "edit", pattern: "*", action: "allow" as const, source: "agent" as const }
expect(await run({ manual: false, rule })).toEqual({
source: "agent",
agent: "build",
rule: { permission: "edit", pattern: "*", action: "allow" },
})
})
test("untagged rule falls back to config origins", async () => {
const out = await run(
{ manual: false, rule: { permission: "edit", pattern: "src/*", action: "allow" } },
{ edit: { "src/*": "local" } },
)
expect(out.source).toBe("project")
})
test("global and project patterns under the same key are attributed independently", async () => {
// global: bash "git status" allow; project: bash "npm test" allow -> both live under bash.
const origins = { bash: { "git status": "global" as const, "npm test": "local" as const } }
const fromGlobal = await run({ manual: false, rule: { permission: "bash", pattern: "git status", action: "allow" } }, origins)
expect(fromGlobal.source).toBe("global")
const fromProject = await run({ manual: false, rule: { permission: "bash", pattern: "npm test", action: "allow" } }, origins)
expect(fromProject.source).toBe("project")
})
test("every rule passed to ask is tagged, even the guardPermissions re-append for modes", async () => {
// Regression guard: a plan/ask/architect agent's rules are duplicated by guardPermissions.
// Capture the ruleset askPermission builds and confirm no rule reaches evaluate untagged.
const captured: Permission.Ruleset[] = []
const planAgent: Agent.Info = {
name: "plan",
mode: "primary",
permission: Permission.fromConfig({ bash: "allow" }),
options: {},
}
const planSession = { id: sessionID, permission: [{ permission: "edit", pattern: "*", action: "deny" }] } as unknown as Session.Info
await Effect.gen(function* () {
yield* KiloSessionPrompt.askPermission({
permission: yield* Permission.Service,
agents: yield* Agent.Service,
sessions: yield* Session.Service,
agent: planAgent,
session: planSession,
request: { sessionID, permission: "bash", patterns: ["echo hi"], always: [], metadata: {} },
})
}).pipe(
Effect.provide(
Layer.mergeAll(
Layer.mock(Permission.Service)({
ask: (req) =>
Effect.sync(() => {
captured.push(req.ruleset)
return { manual: false } as const
}),
}),
Layer.mock(Agent.Service)({ get: () => Effect.succeed(planAgent) }),
Layer.mock(Session.Service)({ get: () => Effect.succeed(planSession) }),
),
),
Effect.runPromise,
)
const ruleset = captured[0]
expect(ruleset.length).toBeGreaterThan(0)
expect(ruleset.every((rule) => (rule as PermissionProvenance.SourcedRule).source !== undefined)).toBe(true)
})
})
@@ -103,6 +103,7 @@ const permission = Layer.mock(Permission.Service)({
ask: (input) =>
Effect.sync(() => {
approvals.push(input)
return { manual: false } as const
}),
})
const plugin = Layer.mock(Plugin.Service)({
@@ -0,0 +1,23 @@
import { describe, expect, test } from "bun:test"
import { KiloSessionProcessor } from "../../src/kilocode/session/processor"
describe("session generation id", () => {
test("extracts a bounded Gateway generation id", () => {
expect(
KiloSessionProcessor.generationID({
gateway: {
generationId: " gen_test-123 ",
routing: { finalProvider: "novita" },
marketCost: "0.1",
},
}),
).toBe("gen_test-123")
})
test("rejects arbitrary or oversized metadata values", () => {
expect(KiloSessionProcessor.generationID({ gateway: { generationId: "request-secret" } })).toBeUndefined()
expect(KiloSessionProcessor.generationID({ gateway: { generationId: `gen_${"a".repeat(201)}` } })).toBeUndefined()
expect(KiloSessionProcessor.generationID({ gateway: { generationId: 42 } })).toBeUndefined()
expect(KiloSessionProcessor.generationID({ openai: { responseId: "gen_response" } })).toBeUndefined()
})
})
@@ -575,11 +575,19 @@ describe("session processor empty tool-calls", () => {
LLMEvent.stepStart({ index: 0 }),
LLMEvent.stepFinish({
index: 0,
reason: "stop",
reason: "other",
usage: usage(),
providerMetadata: { kilocode: { routedModelID: "openai/gpt-5.5-20260423" } },
providerMetadata: {
kilocode: { routedModelID: "openai/gpt-5.5-20260423" },
kilo: { vercelID: "fra1::test" },
gateway: {
generationId: "gen_test",
routing: { finalProvider: "openai" },
marketCost: "0.1",
},
},
}),
LLMEvent.finish({ reason: "stop", usage: usage() }),
LLMEvent.finish({ reason: "other", usage: usage() }),
)
const chat = yield* session.create({})
@@ -632,6 +640,10 @@ describe("session processor empty tool-calls", () => {
providerID: selection.providerID,
modelID: ModelV2.ID.make("openai/gpt-5.5-20260423"),
})
expect(part?.generationID).toBe("gen_test")
expect(part?.vercelID).toBe("fra1::test")
expect(part).not.toHaveProperty("providerMetadata")
expect(part).not.toHaveProperty("gateway")
}),
{ git: true },
),
@@ -36,4 +36,15 @@ describe("session response metadata", () => {
test("does not add metadata when the header is absent", () => {
expect(KiloResponseMetadata.write(undefined, { server: "vercel" })).toBeUndefined()
})
test("normalizes valid Vercel IDs", () => {
const metadata = KiloResponseMetadata.write(undefined, { "x-vercel-id": " fra1::abc-123_test " })
expect(KiloResponseMetadata.read(metadata)).toBe("fra1::abc-123_test")
})
test("rejects unsafe or oversized Vercel IDs", () => {
expect(KiloResponseMetadata.write(undefined, { "x-vercel-id": "fra1::<script>" })).toBeUndefined()
expect(KiloResponseMetadata.write(undefined, { "x-vercel-id": "x".repeat(201) })).toBeUndefined()
expect(KiloResponseMetadata.read({ kilo: { vercelID: "fra1::abc\nsecret" } })).toBeUndefined()
})
})
@@ -571,7 +571,7 @@ it.instance(
always: [],
ruleset: [{ permission: "bash", pattern: "*", action: "allow" }],
})
expect(result).toBeUndefined()
expect(result).toEqual({ manual: false, rule: { permission: "bash", pattern: "*", action: "allow" } }) // kilocode_change - ask returns the auto-approval decision instead of void
}),
{ git: true },
)
@@ -803,7 +803,7 @@ it.instance(
always: [],
ruleset: [],
})
expect(result).toBeUndefined()
expect(result).toEqual({ manual: false, rule: { permission: "bash", pattern: "ls", action: "allow" } }) // kilocode_change - the persisted "always" rule auto-approves; ask reports that decision
}),
{ git: true },
)
@@ -1118,7 +1118,7 @@ it.instance(
always: [],
ruleset: [{ permission: "bash", pattern: "*", action: "allow" }],
})
expect(result).toBeUndefined()
expect(result).toEqual({ manual: false, rule: { permission: "bash", pattern: "*", action: "allow" } }) // kilocode_change - ask returns the auto-approval decision instead of void
}),
{ git: true },
)
+2 -2
View File
@@ -281,7 +281,7 @@ describe("session.llm.ai-sdk adapter", () => {
{
type: "step-finish",
index: 0,
reason: "unknown",
reason: "other", // kilocode_change
usage: {
inputTokens: 10,
outputTokens: 5,
@@ -294,7 +294,7 @@ describe("session.llm.ai-sdk adapter", () => {
},
{
type: "finish",
reason: "unknown",
reason: "other", // kilocode_change
usage: {
inputTokens: 11,
outputTokens: 6,