diff --git a/desktop/README.md b/desktop/README.md index 02e793a4766..f0891529b8d 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -60,6 +60,13 @@ bun run prepare-widget bunx tauri build --ci --bundles app,dmg ``` +Without the signing key, disable updater artifacts or the bundler errors out +(`A public key has been found, but no private key`): + +```sh +bunx tauri build --ci --bundles app,dmg --config '{"bundle":{"createUpdaterArtifacts":false}}' +``` + Release signing is supplied through environment variables: ```sh diff --git a/src/adapters/codebuddy/scaffold-guard.ts b/src/adapters/codebuddy/scaffold-guard.ts index 707f90ae1c0..6f913233e1e 100644 --- a/src/adapters/codebuddy/scaffold-guard.ts +++ b/src/adapters/codebuddy/scaffold-guard.ts @@ -28,9 +28,28 @@ interface ScanResult { lineStart: boolean; } +// Probe with ASCII-only case folding rather than toLowerCase(): Unicode lowercasing can expand +// a code point (İ, ʼn, ligatures), shifting folded-text offsets away from `text` positions and +// silently disabling detection. `expected` must already be lowercase. +function asciiFold(code: number): number { + return code >= 0x41 && code <= 0x5a ? code + 0x20 : code; +} + +function startsWithFolded(text: string, index: number, expected: string): boolean { + if (index + expected.length > text.length) return false; + for (let i = 0; i < expected.length; i++) { + if (asciiFold(text.charCodeAt(index + i)) !== expected.charCodeAt(i)) return false; + } + return true; +} + function prefixAtEnd(text: string, at: number, expected: string): boolean { - const rest = text.slice(at).toLowerCase(); - return rest.length < expected.length && expected.startsWith(rest); + const remaining = text.length - at; + if (remaining > expected.length) return false; + for (let i = 0; i < remaining; i++) { + if (asciiFold(text.charCodeAt(at + i)) !== expected.charCodeAt(i)) return false; + } + return true; } /** @@ -65,8 +84,7 @@ function scan( } if (!fence) { - const lowered = text.slice(index).toLowerCase(); - if (lowered.startsWith(DSML_CALLS_LINE)) { + if (startsWithFolded(text, index, DSML_CALLS_LINE)) { const afterCalls = index + DSML_CALLS_LINE.length; let invokeAt = -1; if (text[afterCalls] === "\n") invokeAt = afterCalls + 1; @@ -76,12 +94,11 @@ function scan( } if (invokeAt >= 0) { - const invokeRest = text.slice(invokeAt).toLowerCase(); - const invokeNameStart = invokeRest[DSML_INVOKE_PREFIX.length]; - if (invokeRest.startsWith(DSML_INVOKE_PREFIX) && invokeNameStart && !/[\s"]/.test(invokeNameStart)) { + const invokeNameStart = text[invokeAt + DSML_INVOKE_PREFIX.length]; + if (startsWithFolded(text, invokeAt, DSML_INVOKE_PREFIX) && invokeNameStart && !/[\s"]/.test(invokeNameStart)) { return { safe: text.slice(0, index), held: "", fail: true, fence, lineStart }; } - if (invokeRest.length === 0 || DSML_INVOKE_PREFIX.startsWith(invokeRest)) { + if (invokeAt === text.length || prefixAtEnd(text, invokeAt, DSML_INVOKE_PREFIX)) { return { safe: text.slice(0, index), held: text.slice(index), fail: false, fence, lineStart }; } } diff --git a/tests/providers/codebuddy-adapter.test.ts b/tests/providers/codebuddy-adapter.test.ts index f68322752c5..f7c0be7a25d 100644 --- a/tests/providers/codebuddy-adapter.test.ts +++ b/tests/providers/codebuddy-adapter.test.ts @@ -378,6 +378,39 @@ describe("codebuddy runTurn streams a headless turn", () => { ]); }); + test("scans a large multiline delta in time linear in its length", () => { + const run = (lines: number): { events: AdapterEvent[]; elapsed: number } => { + const events: AdapterEvent[] = []; + const guarded = guardCodeBuddyScaffolding(event => events.push(event)); + const answer = "a\n".repeat(lines); + const startedAt = performance.now(); + guarded({ type: "text_delta", text: answer }); + return { events, elapsed: performance.now() - startedAt }; + }; + + const baseline = run(40_000); + const scaled = run(160_000); + + // Four times the input must stay near 4x cost; a scan re-walking its suffix could not fit. + expect(scaled.elapsed).toBeLessThan(Math.max(baseline.elapsed * 8, 250)); + expect(baseline.events).toEqual([{ type: "text_delta", text: "a\n".repeat(40_000) }]); + expect(scaled.events).toEqual([{ type: "text_delta", text: "a\n".repeat(160_000) }]); + }); + + test("still refuses scaffolding after a code point that expands when lowercased", () => { + const events: AdapterEvent[] = []; + const guarded = guardCodeBuddyScaffolding(event => events.push(event)); + + // İ (U+0130) lowercases to two code units, so folded-text offsets no longer match `text`. + guarded({ + type: "text_delta", + text: "note İ here\n<||DSML|| calls>\n<||DSML|| invoke name=\"exec\">private-body", + }); + + expect(events.at(-1)).toEqual(expect.objectContaining({ type: "error", code: "vendor_scaffold_detected" })); + expect(JSON.stringify(events)).not.toContain("private-body"); + }); + test("delivers quoted and inline-code DSML literals unchanged", () => { const events: AdapterEvent[] = []; const guarded = guardCodeBuddyScaffolding(event => events.push(event));