From 9dcd6a07e1f5422205282c573cb763b42bf89552 Mon Sep 17 00:00:00 2001 From: cloud5418 Date: Fri, 8 May 2026 07:04:43 +0800 Subject: [PATCH 01/13] fix: harden desktop auto-update security - require signed public desktop release workflow - add trusted GitHub feed and minimum version gates - validate staged updater metadata and add desktop security tests --- .github/workflows/desktop-beta-release.yml | 1 + .github/workflows/desktop-release.yml | 2 +- desktop/electron-builder.config.cjs | 15 +-- desktop/scripts/stage-desktop.cjs | 4 + desktop/scripts/verify-desktop-package.cjs | 9 ++ .../src/@types/electron-updater/index.d.ts | 6 + desktop/src/main.ts | 2 + desktop/src/runtime/paths.ts | 4 + desktop/src/runtime/updater.ts | 111 ++++++++++++++++++ desktop/tests/updaterSecurity.test.js | 72 ++++++++++++ 10 files changed, 215 insertions(+), 11 deletions(-) create mode 100644 desktop/tests/updaterSecurity.test.js diff --git a/.github/workflows/desktop-beta-release.yml b/.github/workflows/desktop-beta-release.yml index 3d52b7325..e520792b2 100644 --- a/.github/workflows/desktop-beta-release.yml +++ b/.github/workflows/desktop-beta-release.yml @@ -16,6 +16,7 @@ jobs: AI_NOVEL_RELEASE_CHANNEL: beta AI_NOVEL_GITHUB_OWNER: ExplosiveCoderflome AI_NOVEL_GITHUB_REPO: AI-Novel-Writing-Assistant + AI_NOVEL_WINDOWS_PUBLISHER_NAME: AI Novel Writing Assistant Team GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} CSC_LINK: ${{ secrets.WINDOWS_CSC_LINK }} CSC_KEY_PASSWORD: ${{ secrets.WINDOWS_CSC_KEY_PASSWORD }} diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index cfd1dd422..66a7bda6c 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -16,9 +16,9 @@ jobs: runs-on: windows-latest env: AI_NOVEL_RELEASE_CHANNEL: release - AI_NOVEL_ALLOW_UNSIGNED_RELEASE: "true" AI_NOVEL_GITHUB_OWNER: ExplosiveCoderflome AI_NOVEL_GITHUB_REPO: AI-Novel-Writing-Assistant + AI_NOVEL_WINDOWS_PUBLISHER_NAME: AI Novel Writing Assistant Team GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} CSC_LINK: ${{ secrets.WINDOWS_CSC_LINK }} CSC_KEY_PASSWORD: ${{ secrets.WINDOWS_CSC_KEY_PASSWORD }} diff --git a/desktop/electron-builder.config.cjs b/desktop/electron-builder.config.cjs index 1305255b7..c3618cefe 100644 --- a/desktop/electron-builder.config.cjs +++ b/desktop/electron-builder.config.cjs @@ -19,17 +19,12 @@ const windowsSigningLink = firstNonEmpty( process.env.AI_NOVEL_WINDOWS_CSC_LINK, process.env.AI_NOVEL_WINDOWS_CSC_FILE, ); -const allowUnsignedRelease = - firstNonEmpty( - process.env.AI_NOVEL_ALLOW_UNSIGNED_RELEASE, - process.env.AI_NOVEL_ALLOW_UNSIGNED_WINDOWS_RELEASE, - ).toLowerCase() === "true"; const hasWindowsSigningMaterial = Boolean(windowsSigningLink); const builderIconPath = path.join("builder", "app-icon.ico"); -if (!isBetaRelease && !hasWindowsSigningMaterial && !allowUnsignedRelease) { +if (!isBetaRelease && !hasWindowsSigningMaterial) { throw new Error( - "Public Windows desktop releases require signing material. Provide CSC_LINK/WIN_CSC_LINK, or explicitly opt in to an unsigned release.", + "Public Windows desktop releases require signing material. Unsigned public release publishing is not allowed.", ); } @@ -78,11 +73,9 @@ module.exports = { releaseType: isBetaRelease ? "prerelease" : "release", }, ], - electronUpdaterCompatibility: ">=2.16", - generateUpdatesFilesForAllChannels: false, win: { icon: builderIconPath, - // Keep EXE resource editing enabled for unsigned builds so Windows uses the app icon and metadata. + // Keep EXE resource editing enabled so Windows uses the app icon and metadata. signAndEditExecutable: true, target: [ { @@ -95,6 +88,8 @@ module.exports = { }, ], }, + electronUpdaterCompatibility: ">=2.16", + generateUpdatesFilesForAllChannels: false, nsis: { artifactName: "${productName}-${version}-setup-${arch}.${ext}", oneClick: false, diff --git a/desktop/scripts/stage-desktop.cjs b/desktop/scripts/stage-desktop.cjs index 13696822f..b6f311e36 100644 --- a/desktop/scripts/stage-desktop.cjs +++ b/desktop/scripts/stage-desktop.cjs @@ -61,12 +61,16 @@ function writeDesktopUpdaterConfig() { const releaseType = releaseChannel === "beta" ? "prerelease" : "release"; const owner = (process.env.AI_NOVEL_GITHUB_OWNER || "ExplosiveCoderflome").trim(); const repo = (process.env.AI_NOVEL_GITHUB_REPO || "AI-Novel-Writing-Assistant").trim(); + const publisherName = (process.env.AI_NOVEL_WINDOWS_PUBLISHER_NAME || "AI Novel Writing Assistant Team").trim(); + const minimumVersion = (process.env.AI_NOVEL_DESKTOP_MINIMUM_UPDATE_VERSION || "").trim(); const config = [ "provider: github", `owner: ${owner}`, `repo: ${repo}`, `channel: ${releaseChannel}`, `releaseType: ${releaseType}`, + `publisherName: ${publisherName}`, + ...(minimumVersion ? [`minimumAllowedVersion: ${minimumVersion}`] : []), "updaterCacheDirName: ai-novel-writing-assistant-v2-updater", "", ].join("\n"); diff --git a/desktop/scripts/verify-desktop-package.cjs b/desktop/scripts/verify-desktop-package.cjs index 6144bd54c..befc6f75d 100644 --- a/desktop/scripts/verify-desktop-package.cjs +++ b/desktop/scripts/verify-desktop-package.cjs @@ -92,6 +92,15 @@ function main() { if (!updaterConfigSource.includes("provider: github")) { throw new Error("Desktop updater feed configuration is missing the GitHub provider."); } + if (!updaterConfigSource.includes("owner: ExplosiveCoderflome")) { + throw new Error("Desktop updater feed configuration is missing the expected GitHub owner."); + } + if (!updaterConfigSource.includes("repo: AI-Novel-Writing-Assistant")) { + throw new Error("Desktop updater feed configuration is missing the expected GitHub repo."); + } + if (!updaterConfigSource.includes("publisherName: AI Novel Writing Assistant Team")) { + throw new Error("Desktop updater feed configuration is missing the expected Windows publisher name."); + } const packagedFiles = new Set(asar.listPackage(unpackedAppArchive).map((entry) => entry.replace(/^\\/, "").replace(/\\/g, "/"))); const packagedEntries = Array.from(packagedFiles); diff --git a/desktop/src/@types/electron-updater/index.d.ts b/desktop/src/@types/electron-updater/index.d.ts index f16ea4a71..6d3d638f3 100644 --- a/desktop/src/@types/electron-updater/index.d.ts +++ b/desktop/src/@types/electron-updater/index.d.ts @@ -1,8 +1,14 @@ declare module "electron-updater" { + export interface UpdateFileInfo { + url?: string; + } + export interface UpdateInfo { version: string; releaseName?: string | null; releaseNotes?: string | null; + files?: UpdateFileInfo[]; + path?: string | null; } export interface ProgressInfo { diff --git a/desktop/src/main.ts b/desktop/src/main.ts index bd1d04ce2..aacec8a33 100644 --- a/desktop/src/main.ts +++ b/desktop/src/main.ts @@ -14,6 +14,7 @@ import { isPortableDesktopRuntime, resolveDesktopAppDataDir, resolveDesktopLogsDir, + resolveDesktopMinimumUpdateVersion, resolveDesktopRuntimeConfig, resolveDesktopUpdateChannel, resolveDesktopWindowIcon, @@ -91,6 +92,7 @@ function initializeDesktopUpdaterController(): void { updateChannel: resolveDesktopUpdateChannel(), isPackaged: app.isPackaged, isPortable: isPortableDesktopRuntime(), + minimumAllowedVersion: resolveDesktopMinimumUpdateVersion(), }); } diff --git a/desktop/src/runtime/paths.ts b/desktop/src/runtime/paths.ts index 430cc0de8..424f93bd7 100644 --- a/desktop/src/runtime/paths.ts +++ b/desktop/src/runtime/paths.ts @@ -70,6 +70,10 @@ export function resolveDesktopUpdateChannel(): string { return configuredChannel || "beta"; } +export function resolveDesktopMinimumUpdateVersion(): string { + return process.env.AI_NOVEL_DESKTOP_MINIMUM_UPDATE_VERSION?.trim() || ""; +} + export function resolveDesktopRuntimeConfig(options: { port: number; isPackaged: boolean; diff --git a/desktop/src/runtime/updater.ts b/desktop/src/runtime/updater.ts index f8681f064..95bea20e9 100644 --- a/desktop/src/runtime/updater.ts +++ b/desktop/src/runtime/updater.ts @@ -15,6 +15,73 @@ interface DesktopUpdaterOptions { updateChannel: string; isPackaged: boolean; isPortable: boolean; + minimumAllowedVersion: string; +} + +// Security policy for desktop auto-updates is enforced in three places: +// 1. staged feed metadata must point at the expected GitHub repo/channel, +// 2. public release packaging must require signing material, +// 3. runtime rejects update candidates that violate publisher or version-floor policy. +const TRUSTED_UPDATE_HOSTS = new Set([ + "github.com", + "objects.githubusercontent.com", + "github-releases.githubusercontent.com", +]); + +export function isTrustedUpdateUrl(rawUrl: string): boolean { + try { + const parsed = new URL(rawUrl); + return parsed.protocol === "https:" && TRUSTED_UPDATE_HOSTS.has(parsed.hostname); + } catch { + return false; + } +} + +function parseVersionParts(version: string): number[] | null { + const normalized = version.trim().replace(/^v/i, ""); + const match = normalized.match(/^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/); + if (!match) { + return null; + } + + return match.slice(1, 4).map((part) => Number(part)); +} + +function compareVersions(left: string, right: string): number { + const leftParts = parseVersionParts(left); + const rightParts = parseVersionParts(right); + + if (!leftParts || !rightParts) { + return left.localeCompare(right); + } + + for (let index = 0; index < 3; index += 1) { + const diff = leftParts[index] - rightParts[index]; + if (diff !== 0) { + return diff; + } + } + + return 0; +} + +export function isVersionAllowedByFloor(candidateVersion: string, minimumVersion: string): boolean { + if (!minimumVersion.trim()) { + return true; + } + + return compareVersions(candidateVersion, minimumVersion) >= 0; +} + +export function validateWindowsPublisher( + publisherNames: string[], + expectedPublisherNames: string[], +): boolean { + if (publisherNames.length === 0 || expectedPublisherNames.length === 0) { + return false; + } + + return expectedPublisherNames.some((expectedPublisherName) => publisherNames.includes(expectedPublisherName)); } function markUpdaterSnapshot(snapshot: ReturnType): void { @@ -37,6 +104,16 @@ function hasPackagedUpdateFeedConfig(): boolean { return fs.existsSync(path.join(process.resourcesPath, "app-update.yml")); } +function resolveCandidateUpdateUrls(info: { files?: Array<{ url?: string }>; path?: string | null }): string[] { + const fileUrls = Array.isArray(info.files) + ? info.files + .map((file) => file?.url?.trim() || "") + .filter(Boolean) + : []; + const legacyPath = typeof info.path === "string" ? info.path.trim() : ""; + return legacyPath ? [...fileUrls, legacyPath] : fileUrls; +} + export function initializeDesktopUpdater(options: DesktopUpdaterOptions): DesktopUpdaterController { const supported = isUpdaterSupported(options); const hasFeedConfig = !supported || hasPackagedUpdateFeedConfig(); @@ -100,6 +177,40 @@ export function initializeDesktopUpdater(options: DesktopUpdaterOptions): Deskto }); autoUpdater.on("update-available", (info) => { + const candidateUrls = resolveCandidateUpdateUrls(info); + const hasOnlyTrustedUrls = candidateUrls.length === 0 || candidateUrls.every((candidateUrl) => isTrustedUpdateUrl(candidateUrl)); + + if (!hasOnlyTrustedUrls) { + const rejectedUrl = candidateUrls.find((candidateUrl) => !isTrustedUpdateUrl(candidateUrl)) ?? "unknown"; + appendDesktopLog("desktop.updater", `Rejected update ${info.version} because feed URL is not trusted: ${rejectedUrl}.`); + markUpdaterSnapshot(createUpdaterSnapshot({ + ...desktopUpdaterStore.getSnapshot(), + status: "error", + message: `Blocked update ${info.version} because the update source is not trusted.`, + availableVersion: null, + canInstall: false, + progressPercent: null, + bytesPerSecond: null, + lastCheckedAt: new Date().toISOString(), + })); + return; + } + + if (!isVersionAllowedByFloor(info.version, options.minimumAllowedVersion)) { + appendDesktopLog("desktop.updater", `Rejected update ${info.version} below minimum version ${options.minimumAllowedVersion}.`); + markUpdaterSnapshot(createUpdaterSnapshot({ + ...desktopUpdaterStore.getSnapshot(), + status: "error", + message: `Blocked update ${info.version} because it is below the minimum allowed version ${options.minimumAllowedVersion}.`, + availableVersion: null, + canInstall: false, + progressPercent: null, + bytesPerSecond: null, + lastCheckedAt: new Date().toISOString(), + })); + return; + } + appendDesktopLog("desktop.updater", `Update ${info.version} is available and waiting for download approval.`); markUpdaterSnapshot(createUpdaterSnapshot({ ...desktopUpdaterStore.getSnapshot(), diff --git a/desktop/tests/updaterSecurity.test.js b/desktop/tests/updaterSecurity.test.js new file mode 100644 index 000000000..01d69949c --- /dev/null +++ b/desktop/tests/updaterSecurity.test.js @@ -0,0 +1,72 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); + +const updaterRuntime = require("../dist/runtime/updater.js"); + +test("updater runtime exports security policy helpers", () => { + assert.equal(typeof updaterRuntime.isTrustedUpdateUrl, "function"); + assert.equal(typeof updaterRuntime.isVersionAllowedByFloor, "function"); + assert.equal(typeof updaterRuntime.validateWindowsPublisher, "function"); +}); + +test("trusted update URLs are restricted to expected GitHub HTTPS hosts", () => { + assert.equal( + updaterRuntime.isTrustedUpdateUrl("https://github.com/ExplosiveCoderflome/AI-Novel-Writing-Assistant/releases/download/v0.2.11/latest.yml"), + true, + ); + assert.equal( + updaterRuntime.isTrustedUpdateUrl("https://objects.githubusercontent.com/github-production-release-asset-2e65be/example.exe"), + true, + ); + assert.equal( + updaterRuntime.isTrustedUpdateUrl("https://github-releases.githubusercontent.com/asset/example.exe"), + true, + ); + assert.equal( + updaterRuntime.isTrustedUpdateUrl("http://github.com/ExplosiveCoderflome/AI-Novel-Writing-Assistant/releases/download/v0.2.11/latest.yml"), + false, + ); + assert.equal( + updaterRuntime.isTrustedUpdateUrl("https://evil.example.com/update.yml"), + false, + ); +}); + +test("version floor rejects candidate versions below the minimum safe release", () => { + assert.equal(updaterRuntime.isVersionAllowedByFloor("0.2.11", "0.2.10"), true); + assert.equal(updaterRuntime.isVersionAllowedByFloor("0.2.11", "0.2.11"), true); + assert.equal(updaterRuntime.isVersionAllowedByFloor("0.2.11", "0.2.12"), false); + assert.equal(updaterRuntime.isVersionAllowedByFloor("0.2.11-beta.1", "0.2.11"), true); +}); + +test("version floor falls back to allow when no minimum is configured", () => { + assert.equal(updaterRuntime.isVersionAllowedByFloor("0.2.11", ""), true); +}); + +test("publisher validation accepts trusted signers and rejects unexpected values", () => { + assert.equal( + updaterRuntime.validateWindowsPublisher(["AI Novel Writing Assistant Team"], ["AI Novel Writing Assistant Team"]), + true, + ); + assert.equal( + updaterRuntime.validateWindowsPublisher(["Unexpected Publisher"], ["AI Novel Writing Assistant Team"]), + false, + ); + assert.equal( + updaterRuntime.validateWindowsPublisher([], ["AI Novel Writing Assistant Team"]), + false, + ); +}); + +test("public release workflow does not allow unsigned installers", () => { + const workflow = fs.readFileSync( + path.join(__dirname, "..", "..", ".github", "workflows", "desktop-release.yml"), + "utf8", + ); + + assert.ok(!workflow.includes('AI_NOVEL_ALLOW_UNSIGNED_RELEASE: "true"')); + assert.ok(workflow.includes("CSC_LINK: ${{ secrets.WINDOWS_CSC_LINK }}")); + assert.ok(workflow.includes("CSC_KEY_PASSWORD: ${{ secrets.WINDOWS_CSC_KEY_PASSWORD }}")); +}); From 0233fb58638d1576354c3027819e026aeb925cde Mon Sep 17 00:00:00 2001 From: cloud5418 Date: Fri, 8 May 2026 07:24:50 +0800 Subject: [PATCH 02/13] fix(desktop): close updater audit review gaps - persist minimum update version into packaged runtime config - treat prerelease versions as below the matching stable floor - remove deprecated desktop-v public release path --- .github/workflows/desktop-release.yml | 3 +- desktop/src/runtime/paths.ts | 27 +++++++++++++- desktop/src/runtime/updater.ts | 53 +++++++++++++++++++++++++++ desktop/tests/updaterSecurity.test.js | 19 +++++++++- 4 files changed, 98 insertions(+), 4 deletions(-) diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index 66a7bda6c..23320ce0f 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -5,14 +5,13 @@ on: push: tags: - "v*" - - "desktop-v*" permissions: contents: write jobs: publish-release: - if: ${{ startsWith(github.ref_name, 'desktop-v') || !contains(github.ref_name, '-') }} + if: ${{ !contains(github.ref_name, '-') }} runs-on: windows-latest env: AI_NOVEL_RELEASE_CHANNEL: release diff --git a/desktop/src/runtime/paths.ts b/desktop/src/runtime/paths.ts index 424f93bd7..d064d944f 100644 --- a/desktop/src/runtime/paths.ts +++ b/desktop/src/runtime/paths.ts @@ -70,8 +70,33 @@ export function resolveDesktopUpdateChannel(): string { return configuredChannel || "beta"; } +function readDesktopUpdaterConfigValue(key: string): string { + const configPath = path.join(resolveDesktopResourcesDir(), "app-update.yml"); + if (!fs.existsSync(configPath)) { + return ""; + } + + const lines = fs.readFileSync(configPath, "utf8").split(/\r?\n/); + const prefix = `${key}:`; + for (const line of lines) { + const trimmedLine = line.trim(); + if (!trimmedLine.startsWith(prefix)) { + continue; + } + + return trimmedLine.slice(prefix.length).trim().replace(/^['"]|['"]$/g, ""); + } + + return ""; +} + export function resolveDesktopMinimumUpdateVersion(): string { - return process.env.AI_NOVEL_DESKTOP_MINIMUM_UPDATE_VERSION?.trim() || ""; + const configuredVersion = process.env.AI_NOVEL_DESKTOP_MINIMUM_UPDATE_VERSION?.trim(); + if (configuredVersion) { + return configuredVersion; + } + + return readDesktopUpdaterConfigValue("minimumAllowedVersion"); } export function resolveDesktopRuntimeConfig(options: { diff --git a/desktop/src/runtime/updater.ts b/desktop/src/runtime/updater.ts index 95bea20e9..0ef29ca8f 100644 --- a/desktop/src/runtime/updater.ts +++ b/desktop/src/runtime/updater.ts @@ -47,6 +47,16 @@ function parseVersionParts(version: string): number[] | null { return match.slice(1, 4).map((part) => Number(part)); } +function parsePrereleaseParts(version: string): Array | null { + const normalized = version.trim().replace(/^v/i, ""); + const match = normalized.match(/^\d+\.\d+\.\d+-([0-9A-Za-z.-]+)(?:\+.*)?$/); + if (!match) { + return null; + } + + return match[1].split(".").map((part) => (/^\d+$/.test(part) ? Number(part) : part)); +} + function compareVersions(left: string, right: string): number { const leftParts = parseVersionParts(left); const rightParts = parseVersionParts(right); @@ -62,6 +72,49 @@ function compareVersions(left: string, right: string): number { } } + const leftPrerelease = parsePrereleaseParts(left); + const rightPrerelease = parsePrereleaseParts(right); + + if (!leftPrerelease && !rightPrerelease) { + return 0; + } + if (!leftPrerelease) { + return 1; + } + if (!rightPrerelease) { + return -1; + } + + const maxLength = Math.max(leftPrerelease.length, rightPrerelease.length); + for (let index = 0; index < maxLength; index += 1) { + const leftPart = leftPrerelease[index]; + const rightPart = rightPrerelease[index]; + + if (leftPart === undefined) { + return -1; + } + if (rightPart === undefined) { + return 1; + } + if (leftPart === rightPart) { + continue; + } + + const leftIsNumber = typeof leftPart === "number"; + const rightIsNumber = typeof rightPart === "number"; + if (leftIsNumber && rightIsNumber) { + return leftPart - rightPart; + } + if (leftIsNumber) { + return -1; + } + if (rightIsNumber) { + return 1; + } + + return String(leftPart).localeCompare(String(rightPart)); + } + return 0; } diff --git a/desktop/tests/updaterSecurity.test.js b/desktop/tests/updaterSecurity.test.js index 01d69949c..85f46391e 100644 --- a/desktop/tests/updaterSecurity.test.js +++ b/desktop/tests/updaterSecurity.test.js @@ -38,7 +38,7 @@ test("version floor rejects candidate versions below the minimum safe release", assert.equal(updaterRuntime.isVersionAllowedByFloor("0.2.11", "0.2.10"), true); assert.equal(updaterRuntime.isVersionAllowedByFloor("0.2.11", "0.2.11"), true); assert.equal(updaterRuntime.isVersionAllowedByFloor("0.2.11", "0.2.12"), false); - assert.equal(updaterRuntime.isVersionAllowedByFloor("0.2.11-beta.1", "0.2.11"), true); + assert.equal(updaterRuntime.isVersionAllowedByFloor("0.2.11-beta.1", "0.2.11"), false); }); test("version floor falls back to allow when no minimum is configured", () => { @@ -67,6 +67,23 @@ test("public release workflow does not allow unsigned installers", () => { ); assert.ok(!workflow.includes('AI_NOVEL_ALLOW_UNSIGNED_RELEASE: "true"')); + assert.ok(!workflow.includes('desktop-v*')); + assert.ok(!workflow.includes("startsWith(github.ref_name, 'desktop-v')")); assert.ok(workflow.includes("CSC_LINK: ${{ secrets.WINDOWS_CSC_LINK }}")); assert.ok(workflow.includes("CSC_KEY_PASSWORD: ${{ secrets.WINDOWS_CSC_KEY_PASSWORD }}")); }); + +test("staged updater config persists minimum allowed version for packaged builds", () => { + const stageScript = fs.readFileSync( + path.join(__dirname, "..", "scripts", "stage-desktop.cjs"), + "utf8", + ); + const runtimePaths = fs.readFileSync( + path.join(__dirname, "..", "src", "runtime", "paths.ts"), + "utf8", + ); + + assert.ok(stageScript.includes("minimumAllowedVersion:")); + assert.ok(runtimePaths.includes("resolveDesktopMinimumUpdateVersion")); + assert.ok(!runtimePaths.includes("process.env.AI_NOVEL_DESKTOP_MINIMUM_UPDATE_VERSION?.trim() || \"\"")); +}); From ca13108868fe08a700f7e92392e7c80fb241fa2a Mon Sep 17 00:00:00 2001 From: cloud5418 Date: Sat, 9 May 2026 14:27:07 +0800 Subject: [PATCH 03/13] chore: stop tracking local backups and clean .gitignore - remove polluted .gitignore entry `m[[]0])` (between .env.local and dev.db) - ignore .cursor/, *.log, tmp/ to prevent IDE/log noise from leaking - git rm --cached .codex-backups/ and .cursor/ (kept on disk) - delete root-level empty .codex placeholder file --- .../tmp_api.js | 4 ---- .../tmp_art.js | 3 --- .../tmp_cast.js | 3 --- .../tmp_client.js | 3 --- .../tmp_edit.js | 3 --- .../tmp_edit2.js | 3 --- .../tmp_inspect.js | 4 ---- .../tmp_mut.js | 3 --- .../tmp_novels.js | 3 --- .../tmp_search_chars.js | 5 ----- .../tmp_search_get.js | 3 --- .../tmp_view.js | 3 --- .../{if(line.includes(k))console.log(k+'@'+(i+1)+' | 0 .codex-backups/last-debug-cleanup-path.txt | 1 - .codex-backups/temp-file-cleanup-20260325-1140/0 | 0 .../0}).map(function(v){return | 0 .../temp-file-cleanup-20260325-1140/0}).slice(0 | 0 .../0})}).map(function(v){return | 0 .cursor/debug-2b6adb.log | 14 -------------- .gitignore | 4 +++- 20 files changed, 3 insertions(+), 56 deletions(-) delete mode 100644 .codex-backups/debug-cleanup-2026-03-24T07-24-42-092Z/tmp_api.js delete mode 100644 .codex-backups/debug-cleanup-2026-03-24T07-24-42-092Z/tmp_art.js delete mode 100644 .codex-backups/debug-cleanup-2026-03-24T07-24-42-092Z/tmp_cast.js delete mode 100644 .codex-backups/debug-cleanup-2026-03-24T07-24-42-092Z/tmp_client.js delete mode 100644 .codex-backups/debug-cleanup-2026-03-24T07-24-42-092Z/tmp_edit.js delete mode 100644 .codex-backups/debug-cleanup-2026-03-24T07-24-42-092Z/tmp_edit2.js delete mode 100644 .codex-backups/debug-cleanup-2026-03-24T07-24-42-092Z/tmp_inspect.js delete mode 100644 .codex-backups/debug-cleanup-2026-03-24T07-24-42-092Z/tmp_mut.js delete mode 100644 .codex-backups/debug-cleanup-2026-03-24T07-24-42-092Z/tmp_novels.js delete mode 100644 .codex-backups/debug-cleanup-2026-03-24T07-24-42-092Z/tmp_search_chars.js delete mode 100644 .codex-backups/debug-cleanup-2026-03-24T07-24-42-092Z/tmp_search_get.js delete mode 100644 .codex-backups/debug-cleanup-2026-03-24T07-24-42-092Z/tmp_view.js delete mode 100644 .codex-backups/debug-cleanup-2026-03-24T07-24-42-092Z/{if(line.includes(k))console.log(k+'@'+(i+1)+' delete mode 100644 .codex-backups/last-debug-cleanup-path.txt delete mode 100644 .codex-backups/temp-file-cleanup-20260325-1140/0 delete mode 100644 .codex-backups/temp-file-cleanup-20260325-1140/0}).map(function(v){return delete mode 100644 .codex-backups/temp-file-cleanup-20260325-1140/0}).slice(0 delete mode 100644 .codex-backups/temp-file-cleanup-20260325-1140/0})}).map(function(v){return delete mode 100644 .cursor/debug-2b6adb.log diff --git a/.codex-backups/debug-cleanup-2026-03-24T07-24-42-092Z/tmp_api.js b/.codex-backups/debug-cleanup-2026-03-24T07-24-42-092Z/tmp_api.js deleted file mode 100644 index e7af38dfa..000000000 --- a/.codex-backups/debug-cleanup-2026-03-24T07-24-42-092Z/tmp_api.js +++ /dev/null @@ -1,4 +0,0 @@ -const fs=require('fs'); -const p='client/src/api/novel.ts'; -const s=fs.readFileSync(p,'utf8').split(/\r?\n/); -for(const k of ['getNovelCharacters','createNovelCharacter','updateNovelCharacter','syncCharacterTimeline','syncAllCharacterTimeline','getCharacterTimeline']){for(let i=0;i Date: Sat, 9 May 2026 15:28:02 +0800 Subject: [PATCH 04/13] ci(deps): add Dependabot config for npm and github-actions (#22) --- .github/dependabot.yml | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..602df432a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,42 @@ +version: 2 +updates: + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "08:30" + timezone: "Asia/Shanghai" + open-pull-requests-limit: 5 + labels: + - "dependencies" + commit-message: + prefix: "chore(deps)" + include: "scope" + groups: + typescript-tooling: + patterns: + - "typescript" + - "tsx" + - "tslib" + - "@types/*" + eslint: + patterns: + - "eslint" + - "eslint-*" + - "@eslint/*" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "08:30" + timezone: "Asia/Shanghai" + open-pull-requests-limit: 3 + labels: + - "dependencies" + - "ci" + commit-message: + prefix: "ci(deps)" + include: "scope" From 4ab1e1d3e94363b33f8b965a1ed49bec3f7febd5 Mon Sep 17 00:00:00 2001 From: cloud5418 Date: Sat, 9 May 2026 17:31:31 +0800 Subject: [PATCH 05/13] feat(llm): support per-route custom request headers Equivalent re-implementation of fork c6d30e0's headers feature on top of upstream main. The original commit's task-execution-log routing fix is intentionally dropped because upstream's auto-director rewrite removed the /:taskId vs /execution-logs collision that the fix targeted. - ModelRouteConfig.requestHeadersText column with prisma + sqlite migrations - Server: parseRequestHeadersText utility, threaded through ResolvedModel and resolveLLMClientOptions; applied at anthropicClient, connectivity, factory (defaultHeaders for OpenAI), structuredInvoke, routes/llm - Client: textarea on settings page (per-route + bulk), with deferred connectivity probing carried over from the same upstream commit - Tests: parser unit, modelRouter user override, llmProviders parsing - Release notes: 2026-05-09 entry --- client/src/api/settings.ts | 1 + .../src/pages/settings/ModelRouteFields.tsx | 23 +++++- client/src/pages/settings/ModelRoutesPage.tsx | 68 +++++++++++------ .../src/pages/settings/modelRoutes.utils.ts | 6 +- docs/releases/release-notes.md | 4 + server/src/llm/anthropicClient.ts | 2 + server/src/llm/connectivity.ts | 11 +++ server/src/llm/factory.ts | 12 +++ server/src/llm/modelRouter.ts | 26 +++++++ server/src/llm/requestHeaders.ts | 75 +++++++++++++++++++ server/src/llm/structuredInvoke.ts | 7 ++ .../migration.sql | 1 + .../migration.sql | 1 + server/src/prisma/schema.prisma | 1 + server/src/prisma/schema.sqlite.prisma | 1 + server/src/routes/llm.ts | 2 + server/tests/llmProviders.test.js | 5 ++ server/tests/modelRouter.test.js | 29 +++++++ server/tests/requestHeaders.test.js | 42 +++++++++++ shared/types/novel.ts | 1 + 20 files changed, 293 insertions(+), 25 deletions(-) create mode 100644 server/src/llm/requestHeaders.ts create mode 100644 server/src/prisma/migrations.sqlite/20260508120000_model_route_request_headers/migration.sql create mode 100644 server/src/prisma/migrations/20260508120000_model_route_request_headers/migration.sql create mode 100644 server/tests/requestHeaders.test.js diff --git a/client/src/api/settings.ts b/client/src/api/settings.ts index 093d45381..db9ce4070 100644 --- a/client/src/api/settings.ts +++ b/client/src/api/settings.ts @@ -121,6 +121,7 @@ export interface ModelRoutesResponse { maxTokens: number | null; requestProtocol: ModelRouteRequestProtocol; structuredResponseFormat: ModelRouteStructuredResponseFormat; + requestHeadersText: string | null; }>; } diff --git a/client/src/pages/settings/ModelRouteFields.tsx b/client/src/pages/settings/ModelRouteFields.tsx index 35d7166ed..86d4d1f10 100644 --- a/client/src/pages/settings/ModelRouteFields.tsx +++ b/client/src/pages/settings/ModelRouteFields.tsx @@ -25,6 +25,7 @@ interface ModelRouteFieldsProps { modelEmptyText: string; manualModelPlaceholder: string; showProtocolFields?: boolean; + showRequestHeadersField?: boolean; } export default function ModelRouteFields({ @@ -37,11 +38,19 @@ export default function ModelRouteFields({ modelEmptyText, manualModelPlaceholder, showProtocolFields = true, + showRequestHeadersField = showProtocolFields, }: ModelRouteFieldsProps) { const modelOptions = getModelOptions(providerConfigs, draft.provider, draft.model); + const columnClass = showProtocolFields + ? "md:grid-cols-7" + : showRequestHeadersField + ? "md:grid-cols-4" + : "md:grid-cols-4"; + const requestHeadersColumnClass = showProtocolFields ? "md:col-span-7" : "md:col-span-4"; + return ( -
+
服务商