From fb76c32727cecf0b1f126930da86f588e740ab58 Mon Sep 17 00:00:00 2001 From: Lex Date: Thu, 8 Oct 2026 17:39:59 +0800 Subject: [PATCH 1/6] test: resolve open code scanning alerts in test fixtures - sanitize-markdown: treat data: and vbscript: links as dangerous, not only javascript: (js/incomplete-url-scheme-check) - prompt: build the custom read override output inside the generated tool instead of splicing a JSON-stringified value into code (js/bad-code-sanitization) - MCP HTTP fixture: log the handler error and return a generic 500 body instead of the error text (js/stack-trace-exposure) --- packages/opencode/test/mcp/fixtures/server-http.ts | 2 +- packages/opencode/test/session/prompt.test.ts | 2 +- packages/web/test/sanitize-markdown.test.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) 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/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..e70ba77c3 100644 --- a/packages/web/test/sanitize-markdown.test.ts +++ b/packages/web/test/sanitize-markdown.test.ts @@ -56,7 +56,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]")), From 9c81ddef3a81f932e061251a10c0a4c74d961971 Mon Sep 17 00:00:00 2001 From: Lex Date: Thu, 8 Oct 2026 17:40:24 +0800 Subject: [PATCH 2/6] test: cover distillation ceilings, interrupt settlement and MCP forced asks Close the remaining gaps in the 1.0.64 changes, each checked against a mutation of the guarded code: - core runner and opencode host stop admitting organizer calls once reported usage fills the per-session reservation ceiling - core runner skips reasoning too large for one organizer call without a model call - opencode host aborts an interrupted organizer call and settles it before the next turn (the fixture keeps the response alive, so only the abort can end it) - MCP tools: the forced dialog shows the hook-rewritten arguments, approval runs them once without a second dialog, rejection never calls the server --- .../reasoning-distillation-runner.test.ts | 60 +++++++++++- .../test/hook/hook-ask-ruleset.test.ts | 96 +++++++++++++++++++ packages/opencode/test/session/llm.test.ts | 91 +++++++++++++++++- 3 files changed, 241 insertions(+), 6 deletions(-) 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..97ff34f7f 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,98 @@ 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({ output: (text: string) => Effect.succeed({ content: text, truncated: false }) } as any), + ), + ) + }) + +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/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index f1e7b3d44..931e58d41 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) return yield* Effect.die("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", () => From 57b0e4f97d11eed3d253c5672933f5082dba57bb Mon Sep 17 00:00:00 2001 From: Lex Date: Thu, 8 Oct 2026 18:31:55 +0800 Subject: [PATCH 3/6] test: serve the plugin SDK locally so custom-tool tests never reach npm Every .opencode directory installs @opencode-ai/plugin in the background and custom tools wait for that install. Under bun the bundled SDK next to the executable is absent, so each install ran arborist against the public npm registry (about 1.8s per test in CI). The nine prompt.test.ts context-folding tests that load .opencode/tools timed out together in the post-merge run of #717 and in earlier PR runs, while the interleaved tests without custom tools passed. Point OPENCODE_PLUGIN_SDK_PATH at a local stub in the test preload, as the preload already does for the global config directory. A full local run made no registry installs for test directories (33 in the probed CI run before) and the folding tests drop from about 25s to 10s. --- packages/opencode/test/preload.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) 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 }) From a54a919f8463c8951a3efeddf6a9ce1de86de374 Mon Sep 17 00:00:00 2001 From: Lex Date: Thu, 8 Oct 2026 18:47:36 +0800 Subject: [PATCH 4/6] test: type the Truncate stub and keep one return style in the interrupt test Removes the two lint warnings the new tests added (4796, as on main). --- packages/opencode/test/hook/hook-ask-ruleset.test.ts | 7 ++++++- packages/opencode/test/session/llm.test.ts | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/opencode/test/hook/hook-ask-ruleset.test.ts b/packages/opencode/test/hook/hook-ask-ruleset.test.ts index 97ff34f7f..5bce31a56 100644 --- a/packages/opencode/test/hook/hook-ask-ruleset.test.ts +++ b/packages/opencode/test/hook/hook-ask-ruleset.test.ts @@ -260,7 +260,12 @@ const withMcpEcho = (effect: Effect.Effect) => }), Effect.provideService( Truncate.Service, - Truncate.Service.of({ output: (text: string) => Effect.succeed({ content: text, truncated: false }) } as any), + 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 }), + }), ), ) }) diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index 931e58d41..f6dca7568 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -2630,7 +2630,7 @@ describe("session.llm.stream", () => { }) void waitRequest("/chat/completions", auxiliaryTextResponse("已确认方案A。")) const ctx = yield* InstanceRef - if (!ctx) return yield* Effect.die("InstanceRef not provided") + if (!ctx) throw new Error("InstanceRef not provided") yield* Effect.promise(() => Effect.runPromise( Effect.gen(function* () { From 6c7dc337d8d1c64343640a31b028d110f074347f Mon Sep 17 00:00:00 2001 From: Lex Date: Thu, 8 Oct 2026 18:47:52 +0800 Subject: [PATCH 5/6] docs: record test-infrastructure, code scanning and coverage work in 1.0.64 notes --- .github/releases/v1.0.64.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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) From a3171ad2c03467e2765657dc73a62307be5dea98 Mon Sep 17 00:00:00 2001 From: Lex Date: Thu, 8 Oct 2026 19:01:27 +0800 Subject: [PATCH 6/6] test: render data: and vbscript: links in the sanitizer regression The protocol check covered data: and vbscript:, but the fixture only produced a javascript: link, so those cases could not fail. Add one link for each; allowing either scheme in DOMPurify now fails the test. --- packages/web/test/sanitize-markdown.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/web/test/sanitize-markdown.test.ts b/packages/web/test/sanitize-markdown.test.ts index e70ba77c3..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)), )