diff --git a/.github/releases/v1.0.64.md b/.github/releases/v1.0.64.md index 25581895c..04e71504f 100644 --- a/.github/releases/v1.0.64.md +++ b/.github/releases/v1.0.64.md @@ -31,6 +31,9 @@ Stable release from `main` branch. Repair independently confirmed defects in the - The HTTP API exerciser runs its auth probes and global scenarios inside its temporary root and fails if the invoking checkout gains worktrees. Earlier runs could create `opencode/*` worktrees and branches in the developer's checkout. - Every finding has a regression test converted from its failing reproduction. The integrated change passed an independent review; its findings were repaired with tests. +- The opencode test preload serves the plugin SDK locally. Every `.opencode` directory installs the SDK in the background and custom tools wait for it; under bun that install reached the public npm registry, and the context-folding tests that load custom tools timed out together in CI. +- The remaining code scanning alerts are resolved: the Markdown sanitizer test also rejects `data:` and `vbscript:` links, a test-generated tool no longer splices a stringified value into code, and the MCP HTTP fixture no longer returns error text. +- New regression tests cover the per-session organizer ceiling and interrupted organizer calls in both hosts, oversized reasoning in the core runner, and hook-forced asks on MCP tools; each was checked against a mutation of the code it guards. --- @@ -38,8 +41,8 @@ Stable release from `main` branch. Repair independently confirmed defects in the ```text workspace typecheck tasks: 31/31 passed -opencode tests: 5332 passed; 9 prompt.test.ts cases hit the 30s timeout under host load, 158/158 on an isolated rerun -core tests: 1568 passed, 0 failed +opencode tests: 5345 passed, 0 failed +core tests: 1570 passed, 0 failed tui tests: 282 passed, 0 failed app unit tests: 456 passed, 0 failed HTTP exerciser scenarios: 239 passed (coverage, auth, effect) diff --git a/packages/core/test/session/reasoning-distillation-runner.test.ts b/packages/core/test/session/reasoning-distillation-runner.test.ts index b837ac960..810b6dbc7 100644 --- a/packages/core/test/session/reasoning-distillation-runner.test.ts +++ b/packages/core/test/session/reasoning-distillation-runner.test.ts @@ -15,7 +15,11 @@ import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" import { SessionMessage } from "@opencode-ai/core/session/message" import * as CoreReasoningDistillation from "@opencode-ai/core/session/runner/reasoning-distillation" -import { COVERAGE_CONTRACT, DENOISING_CONTRACT } from "@opencode-ai/core/session/reasoning-distillation" +import { + COVERAGE_CONTRACT, + DENOISING_CONTRACT, + ReasoningDistillationPolicy, +} from "@opencode-ai/core/session/reasoning-distillation" import { toLLMMessagesWithBindings } from "@opencode-ai/core/session/runner/to-llm-message" import { Hash } from "@opencode-ai/core/util/hash" import { DateTime, Deferred, Duration, Effect, Fiber, Stream } from "effect" @@ -736,14 +740,16 @@ describe("Core runner reasoning distillation adapter", () => { }), ) - const turnSource = (index: number): SessionMessage.Assistant => ({ + const turnSource = (index: number, text?: string): SessionMessage.Assistant => ({ ...history(), id: SessionMessage.ID.make(`msg_turn_${index}`), content: [ { type: "reasoning", id: `reasoning-turn-${index}`, - text: `第 ${index} 轮:先猜测原因是缓存,再看看日志。确认真实原因是权限不足,最终决定改用安全路径并保留回滚步骤。`, + text: + text ?? + `第 ${index} 轮:先猜测原因是缓存,再看看日志。确认真实原因是权限不足,最终决定改用安全路径并保留回滚步骤。`, }, ], }) @@ -753,9 +759,10 @@ describe("Core runner reasoning distillation adapter", () => { sessionID: string, index: number, auxiliaryModel: Model = model, + text?: string, ) => Effect.gen(function* () { - const source = turnSource(index) + const source = turnSource(index, text) const conversion = toLLMMessagesWithBindings([source], model) const request = LLM.request({ model, messages: conversion.messages }) return yield* adapter.distill({ @@ -843,6 +850,51 @@ describe("Core runner reasoning distillation adapter", () => { }), ) + it.effect("stops calling once reported usage fills the per-session reservation ceiling", () => + Effect.gen(function* () { + let calls = 0 + const adapter = CoreReasoningDistillation.make({ + prepare: mockPrepare, + stream: () => Stream.empty, + generate: () => + Effect.sync(() => { + calls++ + // Reported usage equal to the output allowance never exceeds a reservation, so it is reconciled + // rather than paused; the session ceiling alone must stop admission. + return response("决定改用安全路径。", ReasoningDistillationPolicy.tokens.maxOutputTokens) + }), + }) + const results = [] + for (let index = 1; index <= 15; index++) results.push(yield* distillTurn(adapter, "ses_ceiling", index)) + const exhausted = results.findIndex((result) => result.skipReason === "call-budget-exhausted") + expect(exhausted).toBeGreaterThan(0) + expect(results.slice(0, exhausted).every((result) => result.applied)).toBe(true) + expect(results.slice(exhausted).every((result) => result.skipReason === "call-budget-exhausted")).toBe(true) + expect(results.at(-1)?.modelCalls).toBe(0) + expect(results.at(-1)?.usage?.paidAdmissionPaused).toBe(false) + expect(results.at(-1)?.usage?.reservedTokens).toBeLessThanOrEqual( + ReasoningDistillationPolicy.tokens.maxReservedTokensPerSession, + ) + expect(calls).toBe(exhausted) + }), + ) + + it.effect("skips reasoning too large for one organizer call without calling the model", () => + Effect.gen(function* () { + let calls = 0 + const adapter = CoreReasoningDistillation.make({ + prepare: mockPrepare, + stream: () => Stream.empty, + generate: () => Effect.sync(() => (calls++, response("决定改用安全路径。"))), + }) + const oversized = "确认权限不足后改用安全路径。".repeat(ReasoningDistillationPolicy.tokens.maxInputTokens / 4) + const result = yield* distillTurn(adapter, "ses_oversized", 1, model, oversized) + expect(result).toMatchObject({ applied: false, skipReason: "work-limit", modelCalls: 0 }) + expect(result.usage).toMatchObject({ reservedTokens: 0, paidAdmissionPaused: false }) + expect(calls).toBe(0) + }), + ) + it.effect("keeps a declared no-reasoning variant and uses the configured auxiliary timeout", () => Effect.gen(function* () { const generated: LLMRequest[] = [] diff --git a/packages/opencode/test/hook/hook-ask-ruleset.test.ts b/packages/opencode/test/hook/hook-ask-ruleset.test.ts index 5ef0e6d6b..5bce31a56 100644 --- a/packages/opencode/test/hook/hook-ask-ruleset.test.ts +++ b/packages/opencode/test/hook/hook-ask-ruleset.test.ts @@ -16,6 +16,7 @@ import type { Tool } from "@/tool/tool" import { TestInstance } from "../fixture/fixture" import { testEffect, pollWithTimeout } from "../lib/effect" import { ProviderTest } from "../fake/provider" +import { jsonSchema, tool } from "ai" // A PreToolUse `permissionDecision:"ask"` must always surface a dialog, even when // the agent's ruleset is a wildcard deny with pattern-scoped allows (plan agent @@ -232,3 +233,103 @@ describe("hook permissionDecision ask confirms the effective input once", () => }), ) }) + +// MCP tools take the same forced-ask path: the dialog shows the rewritten arguments, approval runs exactly those +// arguments without the tool's own second dialog, and rejection never reaches the MCP server. +const mcpCalls: unknown[] = [] +const withMcpEcho = (effect: Effect.Effect) => + Effect.gen(function* () { + const mcp = yield* MCP.Service + return yield* effect.pipe( + Effect.provideService(MCP.Service, { + ...mcp, + tools: () => + Effect.succeed({ + fixture_echo: tool({ + inputSchema: jsonSchema<{ text: string }>({ + type: "object", + properties: { text: { type: "string" } }, + required: ["text"], + }), + execute: async (args: { text: string }) => { + mcpCalls.push(args) + return { content: [{ type: "text" as const, text: `MCP_COMPLETE ${args.text}` }] } + }, + }), + }), + }), + Effect.provideService( + Truncate.Service, + Truncate.Service.of({ + cleanup: () => Effect.void, + write: (text) => Effect.succeed(text), + output: (text) => Effect.succeed({ content: text, truncated: false }), + limits: () => Effect.succeed({ maxLines: Number.MAX_SAFE_INTEGER, maxBytes: Number.MAX_SAFE_INTEGER }), + }), + ), + ) + }) + +const setupMcpRewrite = withMcpEcho( + setupWith( + { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "ask", + permissionDecisionReason: "rewritten arguments", + updatedInput: { text: "rewritten" }, + }, + }, + "build", + [{ permission: "fixture_echo", pattern: "*", action: "ask" }], + ), +).pipe(Effect.map((result) => result.tools)) + +const runMcpEcho = (tools: Record, reply: "once" | "reject") => + Effect.gen(function* () { + const permission = yield* Permission.Service + mcpCalls.length = 0 + const fiber = yield* Effect.tryPromise(() => + tools.fixture_echo.execute( + { text: "original" }, + { toolCallId: "call-mcp-echo", messages: [], abortSignal: new AbortController().signal }, + ), + ).pipe(Effect.forkScoped) + const pending = yield* pollWithTimeout( + permission.list().pipe(Effect.map((list) => (list.length > 0 ? list : undefined))), + "MCP hook ask did not surface a permission dialog", + ) + expect(pending).toHaveLength(1) + expect(pending[0].always).toEqual([]) + expect(pending[0].metadata).toMatchObject({ + hookAsk: true, + reason: "rewritten arguments", + input: { text: "rewritten" }, + }) + yield* permission.reply({ requestID: pending[0].id, reply }) + const exit = yield* Fiber.await(fiber) + return { exit, remaining: yield* permission.list() } + }) + +describe("hook permissionDecision ask on MCP tools", () => { + it.instance("confirms the rewritten MCP arguments once and runs them", () => + Effect.gen(function* () { + const { exit, remaining } = yield* runMcpEcho(yield* setupMcpRewrite, "once") + expect(Exit.isSuccess(exit)).toBe(true) + if (Exit.isSuccess(exit)) expect(JSON.stringify(exit.value)).toContain("MCP_COMPLETE rewritten") + expect(mcpCalls).toEqual([{ text: "rewritten" }]) + expect(remaining).toHaveLength(0) + }), + ) + + it.instance("a rejected MCP hook ask never calls the server", () => + Effect.gen(function* () { + const { exit, remaining } = yield* runMcpEcho(yield* setupMcpRewrite, "reject") + expect(Exit.isSuccess(exit)).toBe(true) + if (Exit.isSuccess(exit)) + expect(JSON.stringify(exit.value)).toContain("[Tool denied by hook] rewritten arguments") + expect(mcpCalls).toEqual([]) + expect(remaining).toHaveLength(0) + }), + ) +}) diff --git a/packages/opencode/test/mcp/fixtures/server-http.ts b/packages/opencode/test/mcp/fixtures/server-http.ts index 09aeb752d..69b2577e5 100644 --- a/packages/opencode/test/mcp/fixtures/server-http.ts +++ b/packages/opencode/test/mcp/fixtures/server-http.ts @@ -22,7 +22,7 @@ const server = Bun.serve({ return await handler.fetch(req) } catch (e) { console.error("[fixture]", e) - return new Response(JSON.stringify({ error: String(e) }), { status: 500 }) + return new Response(JSON.stringify({ error: "internal fixture error" }), { status: 500 }) } }, }) diff --git a/packages/opencode/test/preload.ts b/packages/opencode/test/preload.ts index 64ae780cc..93c00c3d7 100644 --- a/packages/opencode/test/preload.ts +++ b/packages/opencode/test/preload.ts @@ -54,6 +54,19 @@ process.env["OPENCODE_TEST_MANAGED_CONFIG_DIR"] = testManagedConfigDir // registry install that can hold the shared config dependency lock. await markPluginDependenciesReady(path.join(dir, "config", "opencode")) +// Every `.opencode` directory installs the plugin SDK in the background, and custom +// tools wait for it. Under bun the bundled SDK (next to the executable) is absent, so +// each install reached the public npm registry and custom-tool tests depended on its +// latency. Serve a local stub instead. Tests that need the real SDK place it in their +// own node_modules, which the install uses first. +const pluginSdkStub = path.join(dir, "plugin-sdk") +await fs.mkdir(pluginSdkStub, { recursive: true }) +await Bun.write( + path.join(pluginSdkStub, "package.json"), + JSON.stringify({ name: "@opencode-ai/plugin", version: "0.0.0" }), +) +process.env["OPENCODE_PLUGIN_SDK_PATH"] ??= pluginSdkStub + // Write the cache version file to prevent global/index.ts from clearing the cache const cacheDir = path.join(dir, "cache", "opencode") await fs.mkdir(cacheDir, { recursive: true }) diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index f1e7b3d44..f6dca7568 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -6,6 +6,7 @@ import path from "path" import { InvalidResponseDataError, tool, type ModelMessage } from "ai" import { Cause, Effect, Exit, Fiber, Layer, Stream } from "effect" import { InstanceRef } from "../../src/effect/instance-ref" +import { ReasoningDistillationPolicy } from "@opencode-ai/core/session/reasoning-distillation" import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import z from "zod" import { LLM, strictJSON } from "../../src/session/llm" @@ -1006,14 +1007,14 @@ const auxiliaryResponse = (content: unknown) => usage: { prompt_tokens: 10, completion_tokens: 10, total_tokens: 20 }, }) -const auxiliaryTextResponse = (text: string, finishReason = "stop") => +const auxiliaryTextResponse = (text: string, finishReason = "stop", totalTokens = 20) => Response.json({ id: "chatcmpl-organize", object: "chat.completion", created: 0, model: "deepseek-test-r1", choices: [{ index: 0, message: { role: "assistant", content: text }, finish_reason: finishReason }], - usage: { prompt_tokens: 10, completion_tokens: 10, total_tokens: 20 }, + usage: { prompt_tokens: totalTokens - 10, completion_tokens: 10, total_tokens: totalTokens }, }) const organizeInput = ( @@ -2564,6 +2565,92 @@ describe("session.llm.stream", () => { { config: () => distillationConfig("opencode-ai-sdk", { canonical: true }) }, ) + it.instance( + "stops organizing once reported usage fills the per-session reservation ceiling", + () => + Effect.gen(function* () { + const model = yield* Provider.use.getModel( + ProviderV2.ID.make("custom-provider"), + ModelV2.ID.make("deepseek-test-r1"), + ) + const sessionID = SessionID.make("session-test-organize-ceiling") + const adopted: unknown[][] = [] + // Usage equal to the output allowance never exceeds a reservation, so it is reconciled rather than + // paused; only the 262k session ceiling can stop later turns. + const turns = Array.from({ length: 12 }, (_, index) => index + 1) + for (const _ of turns) + void waitRequest( + "/chat/completions", + auxiliaryTextResponse("已确认方案A。", "stop", ReasoningDistillationPolicy.tokens.maxOutputTokens), + ) + yield* distillSequenceWith( + llmLayerWithExecutor(RequestExecutor.defaultLayer), + turns.map((turn) => turnInput(model, sessionID, turn, adopted)), + ) + expect(adopted.length).toBeGreaterThan(1) + expect(adopted.length).toBeLessThan(turns.length) + // No model call is made after the ceiling is reached. + expect(state.queue).toHaveLength(turns.length - adopted.length) + state.queue.length = 0 + }), + { config: () => distillationConfig("opencode-ai-sdk", { canonical: true }) }, + ) + + it.instance( + "aborts an interrupted organizer call and settles it before the next turn", + () => + Effect.gen(function* () { + const model = yield* Provider.use.getModel( + ProviderV2.ID.make("custom-provider"), + ModelV2.ID.make("deepseek-test-r1"), + ) + const sessionID = SessionID.make("session-test-organize-interrupted") + const adopted: unknown[][] = [] + const started = deferred() + // The first organizer response never completes. Leading JSON whitespace keeps the connection active + // (no server idle timeout), so only the client abort can settle the call. + state.queue.push({ + path: "/chat/completions", + response: (req) => { + let keepAlive: ReturnType | undefined + return new Response( + new ReadableStream({ + start(controller) { + keepAlive = setInterval(() => controller.enqueue(new TextEncoder().encode(" ")), 1_000) + req.signal.addEventListener("abort", () => clearInterval(keepAlive), { once: true }) + }, + cancel() { + clearInterval(keepAlive) + }, + }), + { headers: { "content-type": "application/json" } }, + ) + }, + resolve: started.resolve, + }) + void waitRequest("/chat/completions", auxiliaryTextResponse("已确认方案A。")) + const ctx = yield* InstanceRef + if (!ctx) throw new Error("InstanceRef not provided") + yield* Effect.promise(() => + Effect.runPromise( + Effect.gen(function* () { + const svc = yield* LLM.Service + const fiber = yield* svc.distill(turnInput(model, sessionID, 1, adopted)).pipe(Effect.forkChild) + yield* Effect.promise(() => started.promise) + yield* Fiber.interrupt(fiber) + yield* svc.distill(turnInput(model, sessionID, 2, adopted)) + }).pipe( + Effect.provide(llmLayerWithExecutor(RequestExecutor.defaultLayer)), + Effect.provideService(InstanceRef, ctx), + ), + ), + ) + expect(adopted).toHaveLength(1) + expect(state.queue).toHaveLength(0) + }), + { config: () => distillationConfig("opencode-ai-sdk", { canonical: true }) }, + ) + it.instance( "uses the configured English organizer instruction", () => diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 34c145c00..420368bc2 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -3518,7 +3518,7 @@ const contextFoldingOverrideFailsClosed = (sourceKind: "custom" | "mcp") => "export default {", " description: 'custom read override',", " args: {},", - ` execute: async () => ${JSON.stringify(`custom-context-folding-override-${"c".repeat(24_000)}`)},`, + " execute: async () => 'custom-context-folding-override-' + 'c'.repeat(24000),", "}", "", ].join("\n"), diff --git a/packages/web/test/sanitize-markdown.test.ts b/packages/web/test/sanitize-markdown.test.ts index 92d737ca1..e42545578 100644 --- a/packages/web/test/sanitize-markdown.test.ts +++ b/packages/web/test/sanitize-markdown.test.ts @@ -25,6 +25,8 @@ async function fixture(svg = false) { ...(svg ? [''] : []), '', "[execute](javascript:alert(1))", + 'data link', + 'vbscript link', 'safe link', ].map(async (value) => marked.parse(value)), ) @@ -56,7 +58,7 @@ browserTest( return { executed: Boolean(Reflect.get(window, "__shareXss")), executableElements: document.querySelectorAll("script,iframe,svg,[onerror],[onload]").length, - dangerousLinks: links.some((link) => link.protocol === "javascript:"), + dangerousLinks: links.some((link) => ["javascript:", "data:", "vbscript:"].includes(link.protocol)), safeLink: links.some((link) => link.href === "https://example.com/" && link.rel.includes("noopener")), code: document.querySelector("pre code")?.textContent, highlighted: Boolean(document.querySelector("pre code span[style]")),